From 5fa83933e434e944d6d3ec0ecc36d52660c9c5b3 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 17:29:02 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[fix]=20postgres-init:=20=EA=B5=AC?= =?UTF-8?q?=EA=B8=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EC=9D=98=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=EB=A5=BC=20ALTER=20?= =?UTF-8?q?=EB=92=A4=EB=A1=9C=20=E2=80=94=20=EA=B8=B0=EC=A1=B4=20DB=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EB=A9=88=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측: dev DB(web4ai_db)에 재적용하니 인덱스 절에서 끊겼다. ERROR: column "provider_uid" does not exist 파일 구조가 CREATE TABLE → 인덱스 → 기존 DB 보정(ALTER) 순이라, 새 DB 에서는 CREATE TABLE 이 컬럼을 만들어 주지만 **기존 DB 에서는 컬럼이 맨 끝 ALTER 로 생긴다.** 인덱스를 위에 두면 ON_ERROR_STOP 에서 나머지 보정까지 통째로 안 돈다 — 새 DB 에서만 테스트하면 안 보이는 종류다. - uq_users_provider_uid 를 ALTER 섹션 끝으로 옮기고, 왜 거기 있어야 하는지 주석으로 못 박았다 dev DB 재적용으로 확인: ALTER 4건 + CREATE INDEX 통과, company.users 에 provider/provider_uid 생성 및 password NULL 허용 확인. --- postgres-init/init-data/init.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 6545250..13b373c 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -358,8 +358,6 @@ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id); -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; --- 같은 구글 계정으로 두 번 가입되지 않게. provider 를 키에 넣어 수단이 늘어도 이 인덱스가 그대로 쓰인다. -CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) WHERE deleted = FALSE AND provider_uid IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE; @@ -441,3 +439,7 @@ ALTER TABLE company.users ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64); ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; +-- ★ 이 인덱스는 위 인덱스 절이 아니라 **여기** 있어야 한다. 기존 DB 에서는 컬럼이 ALTER 로 +-- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) + WHERE deleted = FALSE AND provider_uid IS NOT NULL; From 4d6802b4f2d241a40874cb2a3de66ab424721d94 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 17:29:02 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[feat]=20solution/frontend:=20=EC=97=90?= =?UTF-8?q?=EB=94=94=ED=84=B0=EC=97=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=91=9C=EC=8B=9C=20=E2=80=94=20?= =?UTF-8?q?6=EB=8B=A8=EA=B3=84=EC=97=94=20=EC=8B=A0=EC=9B=90=EB=8F=84=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EB=8A=94=20=EA=B8=B8=EB=8F=84=20=EC=97=86?= =?UTF-8?q?=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위저드(1~5단계)는 AppShell 사이드바가 사용자와 [로그아웃]을 들고 있는데, 에디터(6단계)는 전체 화면이라 AppShell 을 안 쓴다. 그래서 편집 화면에 들어가는 순간 **누구로 로그인했는지도, 나가는 방법도 화면에서 사라졌다.** - BuilderPage: 에디터 상단 바 오른쪽에 사용자 · 상호와 [로그아웃] 추가 - stores/auth.userLabel: 이름 → 이메일 → 아이디 순. 구글 계정의 로그인 아이디는 google_ 라 그대로 보이면 안 된다. AppShell 도 같은 규칙을 쓰게 바꿨다 (기존 `name ?? id` 는 이름이 빈 문자열이면 그대로 통과시켰다) 브라우저 확인: 가입 → 로그인 → 사이드바 '김사장 · 달빛스테이', 에디터 상단 바 동일 표시, [로그아웃] 클릭 시 RequireAuth 가 /login 으로 되돌림. tsc·eslint·vite build 통과. --- .../src/components/layout/AppShell.tsx | 4 +- solution/frontend/src/pages/BuilderPage.tsx | 69 +++++++++++++------ solution/frontend/src/stores/auth.ts | 8 +++ 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index b78b597..d1d943c 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -2,7 +2,7 @@ import type {ComponentType, ReactNode} from 'react'; import {Link, NavLink, useLocation} from 'react-router'; import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react'; import {cn} from '@/lib/utils'; -import {useAuthStore} from '@/stores/auth'; +import {userLabel, useAuthStore} from '@/stores/auth'; export type NavItem = { to: string; @@ -60,7 +60,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
- {user?.name ?? user?.id} + {user ? userLabel(user) : ''} {user?.companyName ? ` · ${user.companyName}` : ''}
+ + )} +
diff --git a/solution/frontend/src/stores/auth.ts b/solution/frontend/src/stores/auth.ts index 50f291c..82fad43 100644 --- a/solution/frontend/src/stores/auth.ts +++ b/solution/frontend/src/stores/auth.ts @@ -31,6 +31,14 @@ export function toAuthUser(res: ResMe): AuthUser { }; } +/** + * 화면에 띄울 이름. 구글 계정의 로그인 아이디는 `google_` 라 그대로 보이면 안 된다 — + * 이름 → 이메일 순으로 떨어뜨리고 아이디는 마지막이다. + */ +export function userLabel(user: AuthUser): string { + return user.name || user.email || user.id; +} + interface AuthState { user: AuthUser | null; /** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */ From 71c0c1f6abdff2561df82bdb7117e554c86456af Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 21:30:25 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[feat]=20solution/shared,frontend,site,ba?= =?UTF-8?q?ckend:=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=97=AC=EC=84=AF=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9C=ED=96=89=EB=B3=B8=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B3=A0=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=94=B0=EB=A5=B4?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아이템 넷(가요·일력·승차권·스케줄)만 있었고, 그마저 **발행본에는 하나도 안 나갔다.** `SectionSetting` 계약에 data 가 없어 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다 — 소개문 body 와 같은 사연이다. 빌더에서는 보이는데 발행하면 없는 섹션이었다. 그리고 아이템 전부가 갱지색·주(朱)잉크·간판체를 hex 로 박고 있어, 템플릿을 매거진으로 바꿔도 아이템 섹션만 레트로로 남았다. 발행본은 색만 템플릿을 따랐다(계약에 생김새가 없었다). - shared/section-data: 읽는 쪽 계약을 계약 패키지로 — 항목 타입 · parseSectionData. 같은 JSON 을 빌더와 발행본이 읽는다. 파서가 두 벌이면 슬러그 규칙처럼 조용히 어긋난다 - frontend/dataSpec: 아이템 6종 추가 — 인물 열전 · 시간의 골목 · 문학 서가 · 오늘의 엽서 · 뒤집어 보는 질문 · 계절별 추천 하루. [+ 섹션 추가] 목록은 dataSpec 에서 파생돼 손댈 곳이 없다 - shared/planDay: 계절별 추천 하루는 시각을 **계산한다**. schedule 과 축이 다르다 — 저쪽은 사장님이 시각을 적고 여기는 출발 시각·소요 분에서 시각을 만든다. 조립 규칙을 shared 에 둔 이유는 파서와 같다(빌더와 발행본이 같은 시각을 내야 한다). 21시를 넘기는 칸은 넣지 않고 뺐다고 화면에 밝힌다 — 숨기면 왜 없는지 사장님이 모른다 - shared/site-payload: SectionSetting.data · SiteTheme.look 추가. backend/site_payload 는 해석 없이 싣는다 — 모양을 검사하면 프론트가 필드를 늘린 날 조용히 떨어뜨린다 - site/sections/items: 발행본 아이템 10종. **인터랙션은 옮기지 않았다** — 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. 인용이 이 사이트의 존재 이유다 - site/prerender: 아이템 항목을 고유 콘텐츠로 계수. 안 세면 "곡을 여덟 개 채웠는데 0건으로 발행이 막힌다"가 된다(intro.body 와 같은 구멍). 백엔드 fake 도 같은 규칙으로 맞췄다 - 아이템 색·서체를 전부 --tpl-* 토큰으로. retro/common → items/common, RETRO_* → ITEM_*. 글자 단계는 stone-400/500/600 대신 불투명도로 만든다 — 팔레트가 바뀌어도 위계가 남는다 - site/seo/head: look 을 --tpl-* 로 심고, 웹폰트는 템플릿이 쓰는 것만 내려보낸다. 전부 항상 실으면 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다 - shared/color: deriveSurfaces 를 계약 패키지로. 캔버스·쇼케이스·발행본이 같은 식을 써야 미리보기가 거짓말을 하지 않는다. 프론트 lib/color 는 재수출만 남겼다 밟은 함정: 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 순위 배지가 사라진다(연한 accent + 밝은 바탕). color-mix(accent 70%, currentColor) 로 색조는 남기고 대비만 확보했다. '확인/확인필요' 배지는 디자인이 아니라 신호라 신호색을 지키되 둘레 글자색만 섞는다. tsc·eslint·vite build 통과(frontend·admin·site), site 테스트 17 passed. 실물 프리렌더(레트로 look + 아이템): 열 섹션과 본문 문장 전부 포함, --tpl-font-heading 'Gugi' · border-width 2px, family=Gugi&Gowun+Batang 링크, 계절 묶음·순위·계산된 시각(09:30 출발 → 09:45 도착 → 11:15 → 11:25) 확인. 고유 콘텐츠 12건 ok=true. 옛 payload(look 없음)로 다시 구워 예전과 동일하게 나오는 것까지 확인. 백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — _theme·_sections 는 함수 단위로 확인. --- docs/DEVLOG.md | 76 +++ solution/backend/conftest.py | 20 + solution/backend/router/v1/site/protocol.py | 6 +- solution/backend/router/v1/site/site.py | 4 +- solution/backend/services/site_payload.py | 15 +- .../src/features/builder/RightTabsPanel.tsx | 3 +- .../src/features/builder/canvas/dataSpec.ts | 579 ++++++++++++------ .../src/features/builder/canvas/registry.ts | 72 +++ .../variants/chronicle/ChronicleRail.tsx | 139 +++++ .../canvas/variants/course/CourseTickets.tsx | 51 +- .../canvas/variants/daily/DailyCalendar.tsx | 50 +- .../builder/canvas/variants/items/common.tsx | 188 ++++++ .../builder/canvas/variants/items/items.css | 108 ++++ .../variants/literature/LiteratureShelf.tsx | 149 +++++ .../variants/people/PeopleFilmstrip.tsx | 153 +++++ .../canvas/variants/planner/PlannerPodium.tsx | 194 ++++++ .../variants/postcard/PostcardStack.tsx | 141 +++++ .../builder/canvas/variants/quiz/QuizFlip.tsx | 128 ++++ .../builder/canvas/variants/retro/common.tsx | 153 ----- .../builder/canvas/variants/retro/retro.css | 69 --- .../variants/schedule/ScheduleTimetable.tsx | 60 +- .../canvas/variants/songs/SongsTurntable.tsx | 85 ++- .../src/features/publish/siteTheme.ts | 10 +- solution/frontend/src/lib/color.ts | 38 +- solution/shared/src/index.ts | 2 + solution/shared/src/lib/color.ts | 38 ++ solution/shared/src/lib/section-data.ts | 411 +++++++++++++ solution/shared/src/types/site-payload.ts | 19 + solution/site/scripts/prerender.ts | 28 +- solution/site/src/index.css | 13 +- solution/site/src/lib/derive.ts | 14 + solution/site/src/pages/HomePage.tsx | 5 + solution/site/src/sections/index.ts | 2 + .../src/sections/items/ChronicleSection.tsx | 66 ++ .../site/src/sections/items/CourseSection.tsx | 88 +++ .../site/src/sections/items/DailySection.tsx | 76 +++ .../src/sections/items/LiteratureSection.tsx | 70 +++ .../site/src/sections/items/PeopleSection.tsx | 59 ++ .../src/sections/items/PlannerSection.tsx | 119 ++++ .../src/sections/items/PostcardSection.tsx | 63 ++ .../site/src/sections/items/QuizSection.tsx | 54 ++ .../src/sections/items/ScheduleSection.tsx | 95 +++ .../site/src/sections/items/SongsSection.tsx | 78 +++ solution/site/src/sections/items/common.tsx | 140 +++++ solution/site/src/sections/items/index.ts | 29 + solution/site/src/sections/items/items.css | 109 ++++ solution/site/src/seo/head.ts | 82 ++- 47 files changed, 3579 insertions(+), 572 deletions(-) create mode 100644 solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/items/common.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/items/items.css create mode 100644 solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/postcard/PostcardStack.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/quiz/QuizFlip.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/retro/common.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/retro/retro.css create mode 100644 solution/shared/src/lib/color.ts create mode 100644 solution/shared/src/lib/section-data.ts create mode 100644 solution/site/src/sections/items/ChronicleSection.tsx create mode 100644 solution/site/src/sections/items/CourseSection.tsx create mode 100644 solution/site/src/sections/items/DailySection.tsx create mode 100644 solution/site/src/sections/items/LiteratureSection.tsx create mode 100644 solution/site/src/sections/items/PeopleSection.tsx create mode 100644 solution/site/src/sections/items/PlannerSection.tsx create mode 100644 solution/site/src/sections/items/PostcardSection.tsx create mode 100644 solution/site/src/sections/items/QuizSection.tsx create mode 100644 solution/site/src/sections/items/ScheduleSection.tsx create mode 100644 solution/site/src/sections/items/SongsSection.tsx create mode 100644 solution/site/src/sections/items/common.tsx create mode 100644 solution/site/src/sections/items/index.ts create mode 100644 solution/site/src/sections/items/items.css diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 1222ff6..78bcc77 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,82 @@ --- +## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거 + +**왜** +아이템 열 개가 전부 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 박고 있었다. 사장님이 템플릿을 +매거진으로 바꿔도 **아이템 섹션만 레트로로 남아** 화면이 두 벌로 보였다. 아이템은 레트로 전용 +부품이 아니라 어느 템플릿에나 들어가는 섹션이다. +그리고 발행본은 **색만** 템플릿을 따랐다 — `theme` 계약에 생김새(look)가 없어서, 레트로를 골라도 +발행 페이지는 늘 같은 고딕으로 나갔다. 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다. + +**한 일** +- 아이템 1종 추가 — **계절별 추천 하루**(`planner.podium`). 계절 탭 + 1·2·3위 카드. + 기존 `schedule` 과 축이 다르다: 저쪽은 사장님이 시각을 적고, 여기는 **시각을 계산한다**. + 사장님은 출발 시각과 "몇 분 걸리나"만 적고, 출발을 당기면 하루가 통째로 밀린다. + 조립 규칙(`planDay`·`plannerTop`·`plannerSeasons`)은 파서와 같은 이유로 `@o2o/shared` 한 벌이다 — + 빌더와 발행본이 같은 조건에서 **같은 시각**을 내야 한다. + 밤 9시를 넘기는 칸은 넣지 않고 **뺐다고 화면에 밝힌다**(숨기면 사장님은 왜 없는지 모른다). +- 아이템 색·서체를 전부 템플릿 토큰(`--tpl-*`)으로. `retro/common.tsx` → `items/common.tsx`, + `RETRO_*` 상수 → `ITEM_*` 토큰. 글자 단계는 stone-400/500/600 대신 **불투명도**로 만든다 — + 팔레트가 바뀌어도 위계가 유지된다. 질감(도넛판 홈·톱니·필름 구멍)도 `currentColor` 로 판다. +- **`SiteTheme.look` 계약 추가** — 서체·모서리·테두리 두께·그림자·섹션 여백이 발행본까지 간다. + 프론트가 저장하고(`toThemePayload`), 서버는 해석 없이 싣고(`_theme`), `seo/head.ts` 가 `--tpl-*` 로 심는다. + 발행본 `.serif`·`body` 도 이 토큰을 읽는다. +- 웹폰트는 **템플릿이 쓰는 것만** 내려보낸다(서체 스택을 훑어 아는 것만). 전부 항상 실으면 + 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다. +- 색 유도식(`deriveSurfaces`)을 `@o2o/shared` 로. 캔버스·쇼케이스·**발행본**이 같은 식을 써야 + 미리보기가 거짓말을 하지 않는다. 프론트 `lib/color.ts` 는 재수출만 남겼다. + +**밟은 함정** +- 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 배지가 **사라진다**(연한 accent + 밝은 바탕). + → `color-mix(accent 70%, currentColor)` — 색조는 남고 대비만 확보된다. 어두운 면에서는 밝은 쪽으로 붙는다. +- 순위 배지를 accent 로 채웠더니 같은 이유로 글자가 안 보였다. 1위만 **글자색**으로 채운다. +- '확인/확인필요' 배지는 디자인이 아니라 신호다. 신호색은 지키되 둘레 글자색을 섞어 대비만 맞춘다. + +**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed. +쇼케이스에서 팔레트를 바꿔 제목 서체·날짜 색이 함께 바뀌는 것 확인. +실물 프리렌더(레트로 look + planner): `` 에 `--tpl-font-heading: 'Gugi'…`·`--tpl-border-width: 2px`, +`family=Gugi&family=Gowun+Batang` 링크, 계절 묶음 여름·가을, 순위 1·2위, +계산된 시각(09:30 출발 → 09:45 도착 → 11:15 → 11:25…) 확인. 고유 콘텐츠 12건 · ok=true. +**옛 payload(look 없음)** 로 다시 구워 서체 링크가 예전 두 벌 그대로이고 look 토큰이 안 실리는 것까지 확인. +백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — `_theme`·`_sections` 는 함수 단위로 직접 확인했다. + +## 2026-09-02 — 붙여넣기 아이템 다섯을 더하고, 아홉 개를 발행 사이트까지 내보낸다 + +**왜** +아이템 카탈로그에서 고른 여덟 중 넷(가요·일력·승차권 + 스케줄)만 있었다. 나머지 다섯은 +빌더에 칸 자체가 없었다. 더 큰 구멍은 그 아래에 있었다 — **아홉 개 전부 발행본에 안 나갔다.** +`SectionSetting` 계약에 `data` 가 없어서, 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다 +(소개문 `body` 와 같은 사연). 빌더에서는 보이는데 발행하면 없는 섹션이었다. + +**한 일** +- 아이템 5종 추가 — 인물 열전(필름 스트립) · 시간의 골목(가로 연표) · 문학 서가(책등·세로쓰기) · + 오늘의 엽서(엽서 뒷면) · 뒤집어 보는 질문(갱지 시험지 플립). + `dataSpec` 에 스키마·프롬프트·예시, `registry` 에 배리에이션 한 줄씩. + [+ 섹션 추가] 목록은 `dataSpec` 에서 파생돼(addable.ts) 따로 손댈 곳이 없다. +- **읽는 쪽 계약을 `@o2o/shared` 로 옮겼다**(`lib/section-data.ts`) — 항목 타입 · `parseSectionData`. + 같은 JSON 을 빌더와 발행 사이트가 함께 읽는다. 파서를 각자 두면 슬러그 규칙처럼 조용히 어긋난다. + 빌더에는 **쓰는 쪽**(프롬프트·예시·라벨)만 남았다. +- `SectionSetting.data` 계약 추가 · `site_payload._sections()` 가 그대로 실어 보낸다(서버는 파싱하지 않는다). +- 발행 사이트에 아이템 섹션 아홉(`site/src/sections/items/`). **인터랙션은 옮기지 않았다** — + 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. + 이 사이트의 존재 이유가 AI·검색의 인용이라 발행본은 전 항목을 펴고 가로로만 민다. +- 프리렌더 고유 콘텐츠 계수에 아이템 항목을 넣었다. 안 세면 "곡을 여덟 개 채웠는데 + 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — `intro.body` 와 같은 구멍이다(백엔드 fake 도 같이). +- 간판체(Gugi)는 **아이템을 실제로 쓰는 사이트에만** `` 로 내려보낸다. 서체 하나가 + 모든 발행 사이트의 첫 렌더를 늦출 이유가 없다. + +**안 한 것** +레트로 템플릿 시드(`defaultSectionTypes`)는 넷 그대로 뒀다. 붙여넣기 아이템은 내용이 없으면 +빈 섹션이라, 아홉을 시드에 박으면 아무도 안 쓰는 칸이 늘 붙어 있게 된다(addable.ts 의 근거). + +**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed. +실물 프리렌더: 아이템 아홉이 든 payload → `ok=true`, 고유 콘텐츠 18건, 발행 HTML 에 아홉 섹션과 +본문 문장 전부 포함, `family=Gugi` 링크 있음. 같은 payload 에서 아이템을 빼면 8건 · Gugi 링크 없음. +백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 — +바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다. + ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 **왜** diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 4fb6a94..acef4e4 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -196,6 +196,15 @@ def fake_renderer(monkeypatch, tmp_path_factory): # 고유 콘텐츠 계수 규칙은 렌더러(prerender.ts countUniqueContent)와 같아야 한다. MIN_UNIQUE_TEXT = 8 + def _has_long_text(value, long) -> bool: + if isinstance(value, str): + return long(value) + if isinstance(value, list): + return any(_has_long_text(v, long) for v in value) + if isinstance(value, dict): + return any(_has_long_text(v, long) for v in value.values()) + return False + def _count(payload: dict) -> int: def long(value) -> bool: return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT @@ -207,6 +216,17 @@ def fake_renderer(monkeypatch, tmp_path_factory): ) if intro and intro.get("enabled") and long(intro.get("body")): count += 1 + # 붙여넣기 아이템(theme.sections[].data)의 항목도 고유 콘텐츠다 — 렌더러와 같은 규칙. + for section in (payload.get("theme") or {}).get("sections") or []: + if not section.get("enabled") or not section.get("data"): + continue + try: + envelope = json.loads(section["data"]) + except (ValueError, TypeError): + continue + for item in (envelope or {}).get("items") or []: + if _has_long_text(item, long): + count += 1 facts = list(payload.get("facts") or []) for unit in payload.get("units") or []: facts.extend(unit.get("facts") or []) diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index b8de4fd..6574fb1 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -87,9 +87,9 @@ class Req_SiteTheme(SiteProtocol): 떨어뜨리므로 화면은 깨지지 않는다. ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: - {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} - sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 - (별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다. + {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}} + sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 + (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다. pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — 서버는 배달부지 심판이 아니다. diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index f0f8d3a..d34fd59 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -105,8 +105,8 @@ async def set_template( summary="디자인(색·서체·섹션) 저장", description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다" "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: " - "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\"," - "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. " + "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\"," + "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. " "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). " "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. " "직렬화 크기(64KB)만 막는다. " diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 44ae29a..22ad95e 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -295,7 +295,7 @@ def _theme(site, theme_spec: dict) -> dict: # ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 — # 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다. - return { + out = { # 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다. # 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다). "templateId": _text(_get(site, "template_id")) or theme_spec["templateId"], @@ -303,6 +303,13 @@ def _theme(site, theme_spec: dict) -> dict: "fontStyle": font_style, "sections": _sections(saved.get("sections"), theme_spec["sections"]), } + # ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 — + # 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다. + # 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다. + look = saved.get("look") + if isinstance(look, dict) and look: + out["look"] = {k: v for k, v in look.items() if isinstance(v, str) and v.strip()} + return out def _sections(saved_sections, default_spec) -> list: @@ -372,6 +379,12 @@ def _sections(saved_sections, default_spec) -> list: body = _text(item.get("body")) if body: entry["body"] = body + # ★ 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON. body 와 같은 이유로 그대로 싣는다. + # 서버는 파싱하지 않는다 — 모양을 검사하면 프론트가 필드를 하나 늘린 날 조용히 떨어뜨린다. + # 깨진 JSON 은 렌더러가 그 섹션만 비우고 넘어간다(shared/lib/section-data.ts). + data = _text(item.get("data")) + if data: + entry["data"] = data out.append(entry) for sid, label, locked in default_spec: diff --git a/solution/frontend/src/features/builder/RightTabsPanel.tsx b/solution/frontend/src/features/builder/RightTabsPanel.tsx index c8ad7cb..fbb6620 100644 --- a/solution/frontend/src/features/builder/RightTabsPanel.tsx +++ b/solution/frontend/src/features/builder/RightTabsPanel.tsx @@ -16,7 +16,7 @@ import { Wand2, X, } from 'lucide-react'; -import type {InfoField} from '@o2o/shared'; +import {parseSectionData, type InfoField} from '@o2o/shared'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; @@ -30,7 +30,6 @@ import {resolveVariant} from './canvas/registry'; import { buildPrompt, dataSpecFor, - parseSectionData, SECTION_DATA_MAX_CHARS, } from './canvas/dataSpec'; diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts index 7b1fd77..39ea8aa 100644 --- a/solution/frontend/src/features/builder/canvas/dataSpec.ts +++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts @@ -1,86 +1,17 @@ /** - * 붙여넣기 아이템 계약 — "이 섹션 타입은 어떤 JSON 을 먹나"의 단일 출처. + * 붙여넣기 아이템의 **쓰는 쪽** — "이 섹션 타입의 JSON 을 어떻게 받아 오나". + * + * 프롬프트·예시·라벨이 여기 있고, **읽는 쪽 계약**(항목 타입 · 파서)은 `@o2o/shared` 에 있다 — + * 같은 JSON 을 빌더 캔버스와 발행 사이트가 함께 읽기 때문이다(`shared/lib/section-data.ts`). * * 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. * 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다. */ -/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */ -export type DataVerified = '확인' | '확인필요'; - -export interface DataSource { - name: string; - url?: string; -} - -export interface SongItem { - title: string; - artist?: string; - lyricist?: string; - composer?: string; - year?: number; - label?: string; - labelColor?: string; - story?: string; - connection?: string; - verified?: DataVerified; - source?: DataSource; -} - -export interface DailyItem { - monthDay: string; - category?: string; - title: string; - body?: string; - season?: string; - tags?: string[]; - verified?: DataVerified; - source?: DataSource; -} - -export interface CourseStop { - order?: number; - name: string; - minutes?: number; - note?: string; - searchQuery?: string; -} - -export interface CourseItem { - name: string; - duration?: string; - startsFrom?: string; - stops?: CourseStop[]; - verified?: DataVerified; - source?: DataSource; -} - -export interface ScheduleSlot { - /** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */ - time: string; - title: string; - place?: string; - minutes?: number; - note?: string; - searchQuery?: string; -} - -export interface ScheduleItem { - name: string; - /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */ - audience?: string; - season?: string; - slots?: ScheduleSlot[]; - verified?: DataVerified; - source?: DataSource; -} - export interface SectionDataSpec { /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ kind: string; label: string; - /** 없으면 그 줄을 통째로 버리는 키. 빈 껍데기가 화면에 줄만 남기는 걸 막는다. */ - requiredKey: string; /** [예시 넣기] 가 그대로 넣는 값. */ sample: string; /** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */ @@ -153,7 +84,6 @@ export const SECTION_DATA_SPEC: Record = { songs: { kind: 'songs', label: '가요 다방', - requiredKey: 'title', sample: JSON.stringify( { kind: 'songs', @@ -217,7 +147,6 @@ export const SECTION_DATA_SPEC: Record = { daily: { kind: 'daily', label: '오늘의 한 장', - requiredKey: 'title', sample: JSON.stringify( { kind: 'daily', @@ -279,7 +208,6 @@ export const SECTION_DATA_SPEC: Record = { course: { kind: 'course', label: '반나절 산책', - requiredKey: 'name', sample: JSON.stringify( { kind: 'course', @@ -326,7 +254,6 @@ export const SECTION_DATA_SPEC: Record = { schedule: { kind: 'schedule', label: '여행 스케줄', - requiredKey: 'name', sample: JSON.stringify( { kind: 'schedule', @@ -372,118 +299,396 @@ export const SECTION_DATA_SPEC: Record = { · 장소에 url 을 넣지 않는다. searchQuery 만 넣는다. · 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`, }, + people: { + kind: 'people', + label: '인물 열전', + sample: JSON.stringify( + { + kind: 'people', + version: 1, + title: '인물 열전', + subtitle: '이 도시가 배출한 이름들', + items: [ + { + name: '채만식', + aka: '백릉', + years: '1902–1950', + role: '소설가', + oneLine: '군산을 무대로 근대의 뒷면을 풍자로 그려낸 작가입니다.', + imageQuery: '채만식문학관 채만식 초상', + verified: '확인', + source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'}, + }, + { + name: '고은', + years: '1933–', + role: '시인', + oneLine: '군산에서 태어난 시인으로 소개됩니다. 지역 문학을 이야기할 때 함께 불립니다.', + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + { + name: '박화요비', + role: '가수', + oneLine: '군산 출신으로 소개되는 가수입니다.', + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다. +문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다. + +[스키마] +{ "kind":"people", "version":1, "title":"인물 열전", "items":[ + { "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가", + "oneLine":"한 문장 소개", "imageQuery":"사진 검색어", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다. +· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다. +· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.`, + }, + + chronicle: { + kind: 'chronicle', + label: '시간의 골목', + sample: JSON.stringify( + { + kind: 'chronicle', + version: 1, + title: '시간의 골목', + subtitle: '이 도시를 만든 해들', + items: [ + { + year: 1899, + title: '군산 개항', + summary: '항구가 열리며 도시의 성격이 한 번에 바뀝니다.', + place: '군산 내항', + turning: true, + verified: '확인', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + { + year: 1926, + title: '내항 부잔교 축조', + summary: '조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로이기도 했습니다.', + place: '군산 내항 부잔교', + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + { + year: 1937, + title: '『탁류』 연재 시작', + summary: '채만식이 이 도시를 소설의 무대로 세웁니다.', + place: '채만식문학관', + verified: '확인', + source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'}, + }, + { + year: 2010, + title: '새만금 방조제 개통', + summary: '간척으로 해안선이 다시 그려집니다.', + place: '새만금 방조제', + turning: true, + verified: '확인필요', + source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다. +가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다. + +[스키마] +{ "kind":"chronicle", "version":1, "title":"시간의 골목", "items":[ + { "year":1899, "title":"사건 이름", "summary":"두 문장 이내", + "place":"지금 가 볼 수 있는 자리", "turning":true, + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다. +· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다. +· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.`, + }, + + literature: { + kind: 'literature', + label: '문학 서가', + sample: JSON.stringify( + { + kind: 'literature', + version: 1, + title: '문학 서가', + subtitle: '이 도시를 쓴 책들', + items: [ + { + workTitle: '탁류', + author: '채만식', + year: '1937–1938 연재', + genre: '장편소설', + spineColor: '#7d3320', + background: '일제강점기 군산을 무대로 한 여자의 삶과 도시의 경제를 겹쳐 놓았습니다.', + whyHere: '소설 속 거리를 따라 걸으면 지금의 내항이 겹쳐 보입니다.', + verified: '확인', + source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'}, + }, + { + workTitle: '레디메이드 인생', + author: '채만식', + year: '1934', + genre: '단편소설', + spineColor: '#2a4a6b', + background: '배운 사람이 일자리를 얻지 못하는 식민지 도시의 앞뒤를 풍자로 잘라 보입니다.', + whyHere: '같은 작가의 문학관이 금강 하구 옆에 서 있습니다.', + verified: '확인필요', + source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'}, + }, + { + workTitle: '아리랑', + author: '조정래', + year: '대하소설', + genre: '장편소설', + spineColor: '#3f5c33', + background: '이 지역이 겪은 시간을 인물들의 이동으로 따라갑니다.', + whyHere: '스탬프투어 아리랑길이 소설과 같은 자리를 지납니다.', + verified: '확인필요', + source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[지역]을 배경으로 하거나 [지역]에서 쓰인 문학 작품을 8편까지 찾는다. +소설·시·수필을 고루 섞고, 작품이 그 지역과 어떻게 닿는지 한 문장으로 적는다. + +[스키마] +{ "kind":"literature", "version":1, "title":"문학 서가", "items":[ + { "workTitle":"작품명", "author":"작가", "year":"1937", "genre":"장편소설", + "spineColor":"#7d3320", "background":"작품 배경 두 문장 이내", + "whyHere":"[지역]과 잇는 한 문장", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· 작품 원문(구절·시행·문장)을 옮기지 않는다. 줄거리와 배경만 쓴다. +· 저작권이 살아 있는 작품일수록 짧게 쓴다. +· spineColor 는 책등 색이다. 작품 분위기에 맞는 어두운 색 하나를 hex 로 고른다.`, + }, + + postcard: { + kind: 'postcard', + label: '오늘의 엽서', + sample: JSON.stringify( + { + kind: 'postcard', + version: 1, + title: '오늘의 엽서', + subtitle: '그대로 붙여 쓰는 한 문장', + items: [ + { + line: '부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다.', + hashtags: ['#군산', '#내항', '#부잔교'], + place: '군산 내항', + postmark: '군산 內港', + verified: '확인', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + { + line: '짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다.', + hashtags: ['#군산짬뽕', '#항구도시'], + place: '군산 중앙로', + postmark: '군산 中央', + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + { + line: '단팥빵을 사러 줄을 서는 일이 여행이 되는 도시.', + hashtags: ['#이성당', '#오래된가게'], + place: '이성당', + postmark: '군산 中央', + verified: '확인', + source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다. +사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다. + +[스키마] +{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[ + { "line":"한 문장", "hashtags":["#태그"], "place":"장소", + "postmark":"소인에 찍을 짧은 지명", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다. +· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다. +· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.`, + }, + + quiz: { + kind: 'quiz', + label: '뒤집어 보는 질문', + sample: JSON.stringify( + { + kind: 'quiz', + version: 1, + title: '뒤집어 보는 질문', + subtitle: '뒤집으면 힌트가 나옵니다', + items: [ + { + question: '부잔교는 왜 물 위에 뜨도록 만들었을까?', + hint: '서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.', + topic: '군산 내항', + level: '초등', + verified: '확인', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + { + question: '『탁류』의 “탁류”는 무엇을 가리키는 말일까?', + hint: '금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.', + topic: '채만식문학관', + level: '중등', + verified: '확인', + source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'}, + }, + { + question: '고군산군도의 “고(古)”는 왜 붙었을까?', + hint: '지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.', + topic: '고군산군도', + level: '어른', + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다. +질문은 검색하면 바로 나오는 단답형이 아니라 "왜" 와 "어떻게" 를 묻는 것으로 한다. + +[스키마] +{ "kind":"quiz", "version":1, "title":"뒤집어 보는 질문", "items":[ + { "question":"질문 한 문장", "hint":"두 문장 이내 힌트", + "topic":"관련 장소·주제", "level":"초등|중등|어른", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다. +· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다. +· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.`, + }, + + planner: { + kind: 'planner', + label: '계절별 추천 하루', + sample: JSON.stringify( + { + kind: 'planner', + version: 1, + title: '계절별 추천 하루', + subtitle: '계절을 고르면 시각까지 짜 드립니다', + items: [ + { + name: '개항장 골목 한 바퀴', + season: '가을', + rank: 1, + startTime: '09:30', + audience: '처음 온 손님', + why: '해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.', + stops: [ + {name: '군산근대역사박물관', minutes: 90, moveMinutes: 15, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관'}, + {name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'}, + {name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당'}, + {name: '경암동 철길마을', minutes: 60, moveMinutes: 20, note: '해 질 무렵 빛이 가장 좋습니다.', searchQuery: '군산 경암동 철길마을'}, + ], + verified: '확인', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + { + name: '섬으로 건너가는 날', + season: '가을', + rank: 2, + startTime: '10:00', + audience: '차를 가져온 손님', + why: '바람이 차기 전 마지막으로 바다가 열리는 계절입니다.', + stops: [ + {name: '고군산군도 선유도', minutes: 150, moveMinutes: 50, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도'}, + {name: '장자도', minutes: 60, moveMinutes: 15, note: '섬과 섬 사이를 걸어 넘습니다.', searchQuery: '군산 장자도'}, + ], + verified: '확인필요', + source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'}, + }, + { + name: '비 오면 여기로', + season: '여름', + rank: 1, + startTime: '10:30', + audience: '혼자 온 손님', + why: '장마철에 실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.', + stops: [ + {name: '군산근대건축관', minutes: 60, moveMinutes: 12, note: '옛 은행 건물 안이 통째로 전시입니다.', searchQuery: '군산근대건축관'}, + {name: '한일옥', minutes: 60, moveMinutes: 8, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'}, + {name: '째보선창 근처 카페', minutes: 90, moveMinutes: 10, note: '비 그칠 때까지 앉아 있기 좋습니다.', searchQuery: '군산 째보선창 카페'}, + ], + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[업소]에 묵는 손님을 위한 [지역] 하루 코스를 **계절마다 1~3위**로 추천한다. +계절 넷(봄·여름·가을·겨울)을 모두 채우고, 계절마다 성격이 다른 코스 세 개를 1·2·3위로 매긴다. +코스마다 정거장 3~5곳을 **도는 순서대로** 적는다. + +★ 시각은 적지 않는다. 출발 시각(startTime)과 각 정거장의 머무는 시간(minutes)·이동 시간(moveMinutes)만 +적으면 화면이 시각을 계산해 채운다. + +[스키마] +{ "kind":"planner", "version":1, "title":"계절별 추천 하루", "items":[ + { "name":"코스 이름", "season":"봄|여름|가을|겨울", "rank":1, + "startTime":"09:30", "audience":"누구에게 맞는 하루", + "why":"왜 이 계절에 이 코스인가 (한 문장)", + "stops":[ {"name":"장소","minutes":90,"moveMinutes":15, + "note":"한 문장","searchQuery":"지도 검색어"} ], + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· rank 는 계절마다 1·2·3 만 쓴다. 4위는 만들지 않는다 — 아무도 안 고른다. +· 같은 계절의 세 코스가 서로 달라야 한다. 정거장이 겹치면 순위가 아니라 중복이다. +· moveMinutes 는 앞 정거장에서 오는 시간이다. 첫 정거장은 [업소]에서 나서는 시간이다. +· 걸어서 못 가는 곳은 note 에 "차로 이동" 이라고 적는다. +· 영업시간·휴무일·요금은 넣지 않는다. 바뀌면 짜 준 일정이 손님을 헛걸음시킨다. +· 장소에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.`, + }, + }; export function dataSpecFor(sectionType: string): SectionDataSpec | undefined { return SECTION_DATA_SPEC[sectionType]; } - -export interface ParsedSectionData { - items: T[]; - title?: string; - subtitle?: string; - /** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */ - error?: string; - /** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */ - kindMismatch?: string; - /** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */ - unverified: number; - /** source 가 붙은 항목 수. */ - sourced: number; -} - -const EMPTY: ParsedSectionData = {items: [], unverified: 0, sourced: 0}; - -/** - * JSON.parse 실패를 "몇 번째 줄"로 바꾼다. - * - * ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과, - * 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다. - * 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다. - */ -function locate(raw: string, message: string): string { - const where = (pos: number) => { - const before = raw.slice(0, Math.max(0, pos)); - const line = before.split('\n').length; - const col = pos - before.lastIndexOf('\n'); - return `${line}번째 줄 ${col}번째 글자`; - }; - - const token = /Unexpected token '(.)'/.exec(message)?.[1]; - // 쉼표를 하나 더 찍은 경우가 압도적으로 많다 — 그 말을 먼저 해 준다. - const hint = - token === '}' || token === ']' - ? '닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.' - : '그 앞의 쉼표·따옴표·괄호를 확인해 주세요.'; - - const lineCol = /line (\d+) column (\d+)/.exec(message); - if (lineCol) return `${lineCol[1]}번째 줄 ${lineCol[2]}번째 글자에서 JSON 이 끊깁니다. ${hint}`; - - const at = /position (\d+)/.exec(message); - if (at) return `${where(Number(at[1]))}에서 JSON 이 끊깁니다. ${hint}`; - - // 위치 없이 조각만 인용하는 형 — 그 조각을 원문에서 되찾는다. - const quoted = /\.\.\."([\s\S]*?)" is not valid JSON/.exec(message)?.[1]; - const found = quoted ? raw.indexOf(quoted) : -1; - if (found >= 0) return `${where(found + quoted!.length)} 부근에서 JSON 이 끊깁니다. ${hint}`; - - return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.'; -} - -/** - * 붙여넣은 문자열 → 렌더 가능한 항목. - * - * ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. - */ -export function parseSectionData( - sectionType: string, - raw: string | undefined, -): ParsedSectionData { - const spec = SECTION_DATA_SPEC[sectionType]; - const text = (raw ?? '').trim(); - if (!spec || !text) return EMPTY as ParsedSectionData; - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (error) { - return {...EMPTY, error: locate(text, error instanceof Error ? error.message : '')}; - } - - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return {...EMPTY, error: '바깥이 { } 로 감싸인 JSON 이어야 합니다.'}; - } - - const envelope = parsed as Record; - const kind = typeof envelope.kind === 'string' ? envelope.kind : undefined; - const rawItems = envelope.items; - if (!Array.isArray(rawItems)) { - return {...EMPTY, error: 'items 배열이 없습니다. 프롬프트로 다시 만들어 주세요.'}; - } - - const items = rawItems.filter( - (item): item is T => - typeof item === 'object' && - item !== null && - !Array.isArray(item) && - typeof (item as Record)[spec.requiredKey] === 'string' && - ((item as Record)[spec.requiredKey] as string).trim().length > 0, - ); - - let unverified = 0; - let sourced = 0; - for (const item of items) { - const row = item as Record; - if (row.verified !== '확인') unverified += 1; - if (row.source && typeof row.source === 'object') sourced += 1; - } - - return { - items, - title: typeof envelope.title === 'string' ? envelope.title : undefined, - subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined, - kindMismatch: kind && kind !== spec.kind ? kind : undefined, - unverified, - sourced, - }; -} diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts index ab28cf0..2fb4c78 100644 --- a/solution/frontend/src/features/builder/canvas/registry.ts +++ b/solution/frontend/src/features/builder/canvas/registry.ts @@ -68,6 +68,12 @@ import {SongsTurntable} from './variants/songs/SongsTurntable'; import {DailyCalendar} from './variants/daily/DailyCalendar'; import {CourseTickets} from './variants/course/CourseTickets'; import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable'; +import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip'; +import {ChronicleRail} from './variants/chronicle/ChronicleRail'; +import {LiteratureShelf} from './variants/literature/LiteratureShelf'; +import {PostcardStack} from './variants/postcard/PostcardStack'; +import {QuizFlip} from './variants/quiz/QuizFlip'; +import {PlannerPodium} from './variants/planner/PlannerPodium'; export const SECTION_VARIANTS: Record = { hero: [ @@ -447,6 +453,72 @@ export const SECTION_VARIANTS: Record = { }, ], + people: [ + { + id: 'people.filmstrip', + name: '필름 스트립', + description: '프레임 하나가 인물 하나. 사진이 없으면 이름 활자가 대신 들어간다.', + thumb: 'carousel', + Component: PeopleFilmstrip, + isDefault: true, + }, + ], + + chronicle: [ + { + id: 'chronicle.rail', + name: '가로 연표', + description: '연도가 큰 활자로 서고 사건이 붙는다. 붉은 점이 도시를 바꾼 해다.', + thumb: 'timeline', + Component: ChronicleRail, + isDefault: true, + }, + ], + + literature: [ + { + id: 'literature.shelf', + name: '책등 서가', + description: '책등이 가로로 흐르고 고른 책만 세로쓰기로 펼쳐진다.', + thumb: 'carousel', + Component: LiteratureShelf, + isDefault: true, + }, + ], + + postcard: [ + { + id: 'postcard.stack', + name: '엽서 뒷면', + description: '우표 자리와 소인이 찍힌 뒷면 한 장. [복사] 로 손님이 그대로 퍼 간다.', + thumb: 'carousel', + Component: PostcardStack, + isDefault: true, + }, + ], + + quiz: [ + { + id: 'quiz.flip', + name: '시험지 플립', + description: '앞면은 질문, 뒤집으면 힌트와 출처. 정답은 두지 않는다.', + thumb: 'cards', + Component: QuizFlip, + isDefault: true, + }, + ], + + planner: [ + { + id: 'planner.podium', + name: '시즌 랭킹', + description: '계절을 고르면 1·2·3위 하루가 뜬다. 시각은 계산해서 채운다.', + thumb: 'timeline', + Component: PlannerPodium, + isDefault: true, + }, + ], + exhibition: [ { id: 'exhibition.gallery', diff --git a/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx b/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx new file mode 100644 index 0000000..6d232c7 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx @@ -0,0 +1,139 @@ +/** + * 시간의 골목 — 가로 연표. + * + * 연도가 큰 활자로 서고 사건이 그 아래 붙는다. 레일 위의 **붉은 점은 도시의 성격이 바뀐 해**다 — + * 점의 색이 장식이 아니라 정보라서, 범례를 한 줄 달아 그 약속을 밝힌다. + * ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다. + */ +import {useMemo} from 'react'; +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type ChronicleItem} from '@o2o/shared'; +import { + CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_INK, + ParseError, + PasteHint, + SourceLine, + useCarousel, +} from '../items/common'; +import '../items/items.css'; + +function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) { + const turning = item.turning === true; + + return ( +
+

+ {item.year ?? '연도 미상'} +

+ + {/* 레일 — 점이 선 위에 놓여야 '흐르는 시간 위의 한 해'로 읽힌다 */} +
+ + +
+ +
+

+ {item.title} +

+ {item.summary && ( +

+ {item.summary} +

+ )} + {item.place &&

지금 이 자리 · {item.place}

} + +
+
+ ); +} + +export function ChronicleRail(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + const {ref, scrollBy} = useCarousel(); + + // 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다. + const items = useMemo( + () => + [...parsed.items].sort((a, b) => { + if (a.year == null) return b.year == null ? 0 : 1; + if (b.year == null) return -1; + return a.year - b.year; + }), + [parsed.items], + ); + + const turningCount = items.filter((item) => item.turning === true).length; + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : items.length === 0 ? ( + + ) : ( +
+
+

+ + 도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개 +

+ {items.length > 3 && ( + scrollBy(-1)} onNext={() => scrollBy(1)} label="연표" /> + )} +
+ +
+ {items.map((item, index) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/course/CourseTickets.tsx b/solution/frontend/src/features/builder/canvas/variants/course/CourseTickets.tsx index 27c0a86..947b356 100644 --- a/solution/frontend/src/features/builder/canvas/variants/course/CourseTickets.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/course/CourseTickets.tsx @@ -6,21 +6,22 @@ */ import {SectionBody, SectionFrame} from '../../primitives'; import type {SectionRenderProps} from '../../types'; -import {parseSectionData, type CourseItem, type CourseStop} from '../../dataSpec'; +import {parseSectionData, type CourseItem, type CourseStop} from '@o2o/shared'; import { CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_INK, + ITEM_SURFACE, ParseError, PasteHint, - RETRO_BODY, - RETRO_LINE, - RETRO_PAPER, - RETRO_PAPER_LIGHT, - RETRO_RED, - RETRO_SIGN, SourceLine, useCarousel, -} from '../retro/common'; -import '../retro/retro.css'; +} from '../items/common'; +import '../items/items.css'; function Ticket({ stop, @@ -38,31 +39,31 @@ function Ticket({ return (
{courseName} NO.{no}
-

+

{no}

-

+

{stop.name}

{stop.note && ( -

+

{stop.note}

)}
{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'} {stop.searchQuery && 지도 검색 · {stop.searchQuery}} @@ -71,17 +72,17 @@ function Ticket({ {/* 펀치 구멍 — 표를 표로 만드는 자리 */} {isLast && ( 완주
@@ -100,10 +101,10 @@ function CourseRow({course}: {course: CourseItem}) {
-

+

{course.name}

- + {[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`] .filter(Boolean) .join(' · ')} @@ -115,7 +116,7 @@ function CourseRow({course}: {course: CourseItem}) {
{stops.length === 0 ? ( -

+

정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.

) : ( @@ -145,11 +146,11 @@ export function CourseTickets(props: SectionRenderProps) {
-

+

{parsed.title || section.name}

{(parsed.subtitle || section.description) && ( -

+

{parsed.subtitle || section.description}

)} diff --git a/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx b/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx index c97a8a5..75291b5 100644 --- a/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx @@ -7,20 +7,20 @@ import {useMemo, useState} from 'react'; import {SectionBody, SectionFrame} from '../../primitives'; import type {SectionRenderProps} from '../../types'; -import {parseSectionData, type DailyItem} from '../../dataSpec'; +import {parseSectionData, type DailyItem} from '@o2o/shared'; import { CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_SURFACE, ParseError, PasteHint, - RETRO_BODY, - RETRO_LINE, - RETRO_PAPER, - RETRO_PAPER_LIGHT, - RETRO_RED, - RETRO_SIGN, SourceLine, -} from '../retro/common'; -import '../retro/retro.css'; +} from '../items/common'; +import '../items/items.css'; const DOW = ['일', '월', '화', '수', '목', '금', '토']; @@ -47,47 +47,49 @@ function CalendarPage({item, muted}: {item: DailyItem; muted?: boolean}) { className={`w4-paper relative w-[254px] shrink-0 border shadow-[5px_6px_0_rgba(27,26,21,.13)] transition-all ${ muted ? 'scale-[.93] opacity-45' : '' }`} - style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_LINE}} + style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} > {/* 뜯어낸 자국 */}
{[0, 1, 2].map((i) => ( ))}
-
-

{month}月

-

+

+

{month}月

+

{String(Number(day) || day)}

- {dow &&

{dow}曜

} + {dow &&

{dow}曜

}
{item.category && ( -

{item.category}

+

+ {item.category} +

)} -

+

{item.title}

{item.body && ( -

+

{item.body}

)} {item.tags && item.tags.length > 0 && ( -

{item.tags.join(' ')}

+

{item.tags.join(' ')}

)} -
+
@@ -122,11 +124,11 @@ export function DailyCalendar(props: SectionRenderProps) {
-

+

{parsed.title || section.name}

{(parsed.subtitle || section.description) && ( -

+

{parsed.subtitle || section.description}

)} @@ -150,7 +152,7 @@ export function DailyCalendar(props: SectionRenderProps) { label="날짜" /> )} -

+

오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장 {parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`}

diff --git a/solution/frontend/src/features/builder/canvas/variants/items/common.tsx b/solution/frontend/src/features/builder/canvas/variants/items/common.tsx new file mode 100644 index 0000000..b85540e --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/items/common.tsx @@ -0,0 +1,188 @@ +/** + * 붙여넣기 아이템이 공유하는 조각 — 색·서체 토큰, 출처 각주, 캐러셀 화살표. + * + * ★ 색과 서체를 여기에 박지 않는다. 전부 템플릿 토큰(`--tpl-*`)을 읽는다 — + * 한때 이 파일은 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고, + * 그 바람에 사장님이 템플릿을 매거진으로 바꿔도 아이템만 레트로로 남았다. + * 아이템은 '레트로 전용 부품'이 아니라 **어느 템플릿에나 들어가는 섹션**이다. + * ★ 질감(도넛판 홈·톱니·펀치 구멍)은 남긴다 — 그건 색이 아니라 물건의 생김새다. + * 질감의 색도 토큰을 따라가게 `items.css` 에서 var() 로 받는다. + */ +import {useCallback, useRef} from 'react'; +import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react'; +import {cn} from '@/lib/utils'; +import type {DataSource, DataVerified} from '@o2o/shared'; + +/** 제목 서체. 템플릿이 정한다(심플=고딕 · 매거진=명조 · 레트로=간판체). */ +export const ITEM_HEADING = 'var(--tpl-font-heading, inherit)'; +/** 본문 서체. */ +export const ITEM_BODY = 'var(--tpl-font-body, inherit)'; + +/** 섹션 바탕. */ +export const ITEM_SURFACE = 'var(--tpl-surface, #faf7f2)'; +/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */ +export const ITEM_CARD = 'var(--tpl-card, #ffffff)'; +/** 글자색. */ +export const ITEM_INK = 'var(--tpl-text, #1c1917)'; +/** 선·테두리. */ +export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)'; +/** + * 강조 — 연표의 전환점, 일력의 날짜, 소인 도장. + * + * ★ 템플릿 강조색을 **그대로 쓰지 않고 둘레 글자색을 섞는다.** 팔레트에 따라 accent 가 + * 바탕과 거의 같은 밝기일 수 있는데(연한 베이지·파스텔), 그러면 큰 날짜 숫자와 배지가 + * 화면에서 사라진다. 섞으면 색조는 남고 대비만 확보된다 — + * currentColor 라서 어두운 면에서는 밝은 쪽으로, 밝은 면에서는 어두운 쪽으로 붙는다. + */ +export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #bf2f1b) 70%, currentColor)'; +/** 어두운 면(필름·플립보드·턴테이블). */ +export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)'; +/** + * 어두운 면 위의 글자색. + * + * ★ 흰색을 박지 않는다. 템플릿 배경색을 글자색으로 쓰면 어떤 팔레트에서도 대비가 선다 — + * 밝은 템플릿이면 밝은 글자, 어두운 템플릿이면 그 템플릿이 정한 밝은 면 색이다. + */ +export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)'; + +/** + * 출처 한 줄 + 확신 배지. + * + * ★ 확인되지 않은 값을 숨기지 않고 **드러낸다.** 숨기면 사장님이 그게 미검증인 줄 모르고 발행한다. + * ★ 배지 색만은 토큰이 아니다 — '확인/확인필요'는 디자인이 아니라 신호다. 템플릿 색을 따라가면 + * 팔레트에 따라 경고가 안 보이는 색이 될 수 있다. + */ +export function SourceLine({ + source, + verified, + tone = 'light', +}: { + source?: DataSource; + verified?: DataVerified; + tone?: 'light' | 'dark'; +}) { + if (!source && !verified) return null; + + return ( +

+ {verified && ( + + {verified} + + )} + {source?.name && ( + + 출처 ·{' '} + {source.url ? ( + event.stopPropagation()} + className="underline underline-offset-2" + > + {source.name} + + ) : ( + source.name + )} + + )} +

+ ); +} + +/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */ +export function useCarousel() { + const ref = useRef(null); + const scrollBy = useCallback((dir: -1 | 1) => { + const box = ref.current; + if (!box) return; + box.scrollBy({left: box.clientWidth * 0.8 * dir, behavior: 'smooth'}); + }, []); + return {ref, scrollBy}; +} + +export function CarouselNav({ + onPrev, + onNext, + tone = 'light', + label, +}: { + onPrev: () => void; + onNext: () => void; + tone?: 'light' | 'dark'; + label: string; +}) { + const dark = tone === 'dark'; + const style = { + borderColor: dark ? 'color-mix(in oklab, currentColor 35%, transparent)' : ITEM_BORDER, + backgroundColor: dark ? 'transparent' : ITEM_CARD, + color: 'inherit', + }; + const stop = (fn: () => void) => (event: React.MouseEvent) => { + event.stopPropagation(); + fn(); + }; + + return ( +
+ + +
+ ); +} + +/** + * 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다. + * + * ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다. + * ★ 이건 발행되지 않는 **에디터 안내**다. 그래서 템플릿 색이 아니라 관리자 색을 그대로 쓴다. + */ +export function PasteHint({label}: {label: string}) { + return ( +
+ +

{label} 내용이 아직 없습니다

+

+ 오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다. +

+
+ ); +} + +/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다(에디터 안내라 관리자 색). */ +export function ParseError({message}: {message: string}) { + return ( +
+

붙여넣은 JSON 을 읽지 못했습니다

+

{message}

+
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/items/items.css b/solution/frontend/src/features/builder/canvas/variants/items/items.css new file mode 100644 index 0000000..fdf7bd2 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/items/items.css @@ -0,0 +1,108 @@ +/** + * 붙여넣기 아이템의 질감 — Tailwind 로는 못 그리는 것만 여기 둔다. + * 도넛판 홈, 종이 결, 톱니, 필름 구멍, 시험지 뒤집기. + * + * ★ 색은 전부 템플릿 토큰(--tpl-*)에서 받는다. 한때 갱지색·먹색을 hex 로 박아 뒀는데, + * 템플릿을 바꿔도 이 질감들만 레트로 색으로 남아 화면이 두 벌로 보였다. + */ + +/* 종이 결 — 두 방향이 겹쳐야 종이로 읽힌다. 글자색을 옅게 깔아 어떤 팔레트에서도 결이 보인다. */ +.w4-paper { + background-image: + repeating-linear-gradient(0deg, color-mix(in oklab, currentcolor 4%, transparent) 0 1px, transparent 1px 3px), + repeating-linear-gradient(90deg, color-mix(in oklab, currentcolor 3%, transparent) 0 1px, transparent 1px 4px); +} + +/* 도넛판. --lbl 이 라벨 색이다. */ +.w4-disc { + /* 판의 검정은 템플릿의 어두운 면(--tpl-inverse)이다. 홈은 그 위에 밝기 차로만 판다. */ + background: + radial-gradient(circle at 50% 50%, transparent 0 15.5%, var(--lbl) 15.5% 33%, transparent 33%), + repeating-radial-gradient( + circle at 50% 50%, + color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.4px, + var(--w4-vinyl) 1.4px 3px + ), + var(--w4-vinyl); + box-shadow: inset 0 0 40px rgb(0 0 0 / 55%); +} +.w4-disc-sheen { + background: conic-gradient( + from 210deg, + rgb(255 255 255 / 12%), + transparent 22%, + transparent 70%, + rgb(255 255 255 / 7%) + ); +} +.w4-spin { + animation: w4-rev 2.2s linear infinite; +} +@keyframes w4-rev { + to { + transform: rotate(360deg); + } +} + +/* 미니 판 — 캐러셀에 늘어서는 작은 것. */ +.w4-disc-mini { + background: + radial-gradient(circle at 50% 50%, transparent 0 14%, var(--lbl) 14% 34%, transparent 34%), + repeating-radial-gradient( + circle at 50% 50%, + color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.2px, + var(--w4-vinyl) 1.2px 2.6px + ), + var(--w4-vinyl); + box-shadow: 0 3px 10px rgb(0 0 0 / 35%); +} + +/* 일력 톱니 — 뜯어낸 자국. --tear 는 섹션 바탕색이 들어온다(뜯긴 자리로 바탕이 비쳐야 한다). */ +.w4-perf { + background: repeating-linear-gradient(90deg, transparent 0 8px, var(--tear) 8px 9px); +} + +/* 승차권 절취선 */ +.w4-dash { + border-top: 1px dashed currentcolor; +} + +/* 필름 퍼포레이션 — 위아래 구멍이 있어야 한 장면이 아니라 '롤'로 읽힌다. + 구멍은 currentColor(어두운 면 위의 글자색)로 뚫어 팔레트를 따른다. */ +.w4-film-perf { + background: repeating-linear-gradient(90deg, currentcolor 0 9px, transparent 9px 21px); +} + +/* 시험지 뒤집기. 카드가 얇아 보이지 않게 두 면을 같은 자리에 겹쳐 둔다. */ +.w4-flip { + perspective: 900px; +} +.w4-flip-inner { + transform-style: preserve-3d; + transition: transform 0.5s; +} +.w4-flip-on .w4-flip-inner { + transform: rotateY(180deg); +} +.w4-flip-face { + backface-visibility: hidden; +} +.w4-flip-back { + transform: rotateY(180deg); +} + +.w4-scroll { + scrollbar-width: none; +} +.w4-scroll::-webkit-scrollbar { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .w4-spin { + animation: none; + } + .w4-flip-inner { + transition: none; + } +} diff --git a/solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx b/solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx new file mode 100644 index 0000000..bf34c19 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx @@ -0,0 +1,149 @@ +/** + * 문학 서가 — 책등 캐러셀 + 세로쓰기 펼침면. + * + * 책등이 가로로 흐르고, 하나를 고르면 그 책만 앞으로 나와 펼쳐진다. + * 펼친 면의 제목은 **세로쓰기**다 — 원문을 못 싣는 대신(DB_Guide: 원문 전재 금지) + * 활자의 결로 문학을 느끼게 하는 자리다. + */ +import {useState} from 'react'; +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type LiteratureItem} from '@o2o/shared'; +import { + CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_INVERSE_INK, + ParseError, + PasteHint, + SourceLine, + useCarousel, +} from '../items/common'; +import '../items/items.css'; + +/** 책등 색을 안 줬을 때. 템플릿 강조색으로 떨어뜨린다 — 색을 박으면 팔레트를 바꿔도 서가만 남는다. */ +const FALLBACK_SPINE = ITEM_ACCENT; + +export function LiteratureShelf(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + const [opened, setOpened] = useState(0); + const {ref, scrollBy} = useCarousel(); + + // 항목이 줄어 인덱스가 범위를 벗어나도 첫 책으로 떨어진다 — 빈 화면을 만들지 않는다. + const current = parsed.items[opened] ?? parsed.items[0]; + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : !current ? ( + + ) : ( +
+ {/* 서가 — 아래 선반이 있어야 책이 '꽂혀 있다'로 읽힌다 */} +
+
+ {parsed.items.map((book, index) => ( + + ))} +
+ {parsed.items.length > 6 && ( + scrollBy(-1)} onNext={() => scrollBy(1)} label="책" /> + )} +
+ + {/* 펼친 면 */} +
+

+ {current.workTitle} +

+ +
+

+ {[current.author, current.year, current.genre].filter(Boolean).join(' · ')} +

+ {current.background && ( +

+ {current.background} +

+ )} + {current.whyHere && ( +

+ {current.whyHere} +

+ )} + + ◎ 원문 대신 배경 — 작품 문장은 싣지 않습니다 + + +
+
+
+ )} +
+
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx b/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx new file mode 100644 index 0000000..9c034e0 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx @@ -0,0 +1,153 @@ +/** + * 인물 열전 — 필름 스트립. + * + * 프레임 하나가 인물 하나다. 사진 자리에는 **활판 이니셜**이 들어간다 — + * 이 데이터에는 쓸 수 있는 인물 사진이 없고, 빈 회색 상자를 두면 "사진을 못 넣은 화면"으로 읽힌다. + * 이니셜을 넣으면 없는 것이 형식이 된다. + * ★ 이미지 URL 을 받지 않는다(dataSpec: imageQuery). 초상권 확인은 사장님 몫이라 검색어까지만 싣는다. + */ +import {useState} from 'react'; +import {cn} from '@/lib/utils'; +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type PeopleItem} from '@o2o/shared'; +import { + CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_HEADING, + ITEM_INVERSE, + ParseError, + PasteHint, + SourceLine, + useCarousel, +} from '../items/common'; +import '../items/items.css'; + +/** 이니셜 한 글자. 성이 두 글자인 이름도 첫 자만 — 프레임이 좁아 두 글자는 안 읽힌다. */ +function initialOf(name: string): string { + return name.trim().charAt(0) || '?'; +} + +export function PeopleFilmstrip(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + const [picked, setPicked] = useState(0); + const {ref, scrollBy} = useCarousel(); + + // 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다. + const current = parsed.items[picked] ?? parsed.items[0]; + + return ( + + {/* ★ 어두운 면 위의 글자색을 여기서 한 번만. 필름 구멍(w4-film-perf)도 이 색을 따라간다. */} + +
+

+ PORTRAIT ROLL +

+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : !current ? ( + + ) : ( +
+
+
+
+ {parsed.items.map((person, index) => ( + + ))} +
+
+
+ + {parsed.items.length > 4 && ( + scrollBy(-1)} onNext={() => scrollBy(1)} tone="dark" label="인물" /> + )} + + {/* 고른 프레임의 자막 — 프레임 안에 넣으면 이름조차 안 읽힌다 */} +
+

+ {current.name} + {current.aka && 호 {current.aka}} +

+

+ {[current.role, current.years].filter(Boolean).join(' · ')} +

+ {current.oneLine && ( +

+ {current.oneLine} +

+ )} + {current.imageQuery && ( +

사진 검색어 · {current.imageQuery}

+ )} + +
+ +

+ 사진이 없는 인물은 이름 활자로 대신합니다 · 총 {parsed.items.length}명 + {parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}명`} +

+
+ )} + + + ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx b/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx new file mode 100644 index 0000000..b4bfea8 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx @@ -0,0 +1,194 @@ +/** + * 계절별 추천 하루 — 계절 탭 + 순위 카드. + * + * ★ 여행 스케줄(schedule)과 축이 다르다. 저쪽은 사장님이 시각을 적어 둔 시간표고, + * 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다. + * 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다). + * ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다. + */ +import {useMemo, useState} from 'react'; +import {cn} from '@/lib/utils'; +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; +import { + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_INK, + ITEM_INVERSE_INK, + ParseError, + PasteHint, + SourceLine, +} from '../items/common'; +import '../items/items.css'; + +const RANK_LABEL = ['1위', '2위', '3위']; + +/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */ +function spanText(minutes: number): string { + const hour = Math.floor(minutes / 60); + const rest = minutes % 60; + return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분'; +} + +function PlanCard({item, rank}: {item: PlannerItem; rank: number}) { + const day = planDay(item); + + return ( +
+
+ {/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면 + 팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */} + + {RANK_LABEL[rank] ?? `${rank + 1}위`} + + + {day.from}–{day.to} · {spanText(day.totalMinutes)} + +
+ +
+

+ {item.name} +

+ {item.audience &&

{item.audience}

} + {item.why && ( +

+ {item.why} +

+ )} +
+ + {day.stops.length === 0 ? ( +

+ 정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요. +

+ ) : ( +
    + {day.stops.map((planned, index) => ( +
  1. + {/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */} + + {planned.time} + +
    + {planned.move > 0 && ( +

    ↓ {planned.move}분 이동

    + )} +

    + {planned.stop.name} +

    + {planned.stop.note && ( +

    + {planned.stop.note} +

    + )} +

    + {planned.time}–{planned.until} + {planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`} +

    +
    +
  2. + ))} +
+ )} + +
+ {/* ★ 상한에 걸려 뺀 칸을 숨기지 않는다. 숨기면 사장님은 자기가 적은 곳이 왜 없는지 모른다. */} + {day.dropped > 0 && ( +

+ 밤 9시를 넘겨 {day.dropped}곳을 뺐습니다 — 머무는 시간을 줄이거나 출발을 당겨 보세요. +

+ )} + +
+
+ ); +} + +export function PlannerPodium(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]); + const [picked, setPicked] = useState(0); + + // 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다. + const season = seasons[picked] ?? seasons[0]; + const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]); + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : parsed.items.length === 0 ? ( + + ) : ( +
+ {seasons.length > 1 && ( +
+ {seasons.map((name, index) => ( + + ))} +
+ )} + +
+ {top.map((item, index) => ( + + ))} +
+ +

+ 시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '} + {top.length}개 / 전체 {parsed.items.length}개 +

+
+ )} +
+
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/postcard/PostcardStack.tsx b/solution/frontend/src/features/builder/canvas/variants/postcard/PostcardStack.tsx new file mode 100644 index 0000000..4422644 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/postcard/PostcardStack.tsx @@ -0,0 +1,141 @@ +/** + * 오늘의 엽서 — 엽서 뒷면 더미. + * + * 앞면(사진)은 없다. 쓸 수 있는 사진이 없기도 하고, 이 아이템의 목적이 **손님이 자기 계정에 + * 붙여넣는 것**이라 글이 놓이는 뒷면이 전부다. 우표 자리·소인·가운데 괘선이 그 형식을 만든다. + */ +import {useState} from 'react'; +import {Check, Copy} from 'lucide-react'; +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type PostcardItem} from '@o2o/shared'; +import { + CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_INK, + ParseError, + PasteHint, + SourceLine, + useCarousel, +} from '../items/common'; +import '../items/items.css'; + +function Postcard({item}: {item: PostcardItem}) { + const [copied, setCopied] = useState(false); + const text = [item.line, (item.hashtags ?? []).join(' ')].filter(Boolean).join(' '); + + const copy = (event: React.MouseEvent) => { + event.stopPropagation(); + navigator.clipboard.writeText(text).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1600); + }, + () => { + // 클립보드가 막힌 브라우저 — 직접 긁어 갈 수 있게 띄운다. + window.prompt('아래 문장을 복사하세요', text); + }, + ); + }; + + return ( +
+
+ {/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */} +
+

+ “{item.line}” +

+ {item.hashtags && item.hashtags.length > 0 && ( +

{item.hashtags.join(' ')}

+ )} +
+ +
+ + 郵票 +
+ 10원 +
+ + {item.postmark || item.place || '소인'} + +
+
+ +
+ + +
+
+ ); +} + +export function PostcardStack(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + const {ref, scrollBy} = useCarousel(); + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : parsed.items.length === 0 ? ( + + ) : ( +
+
+ {parsed.items.map((item, index) => ( + + ))} +
+
+

+ [복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장 +

+ {parsed.items.length > 2 && ( + scrollBy(-1)} onNext={() => scrollBy(1)} label="엽서" /> + )} +
+
+ )} +
+
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/quiz/QuizFlip.tsx b/solution/frontend/src/features/builder/canvas/variants/quiz/QuizFlip.tsx new file mode 100644 index 0000000..cee6b03 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/quiz/QuizFlip.tsx @@ -0,0 +1,128 @@ +/** + * 뒤집어 보는 질문 — 갱지 시험지 플립. + * + * 앞면은 질문 하나, 뒤집으면 힌트와 출처가 나온다. + * ★ 정답을 단정하지 않는다(dataSpec: answer 필드 없음). 이 데이터는 검증되지 않은 줄이 더 많아서 + * 정답을 걸면 틀린 걸 확신하는 화면이 된다. 힌트까지가 우리가 책임질 수 있는 선이다. + */ +import {useState} from 'react'; +import {cn} from '@/lib/utils'; +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type QuizItem} from '@o2o/shared'; +import { + CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_INK, + ParseError, + PasteHint, + SourceLine, + useCarousel, +} from '../items/common'; +import '../items/items.css'; + +function QuizCard({item, no}: {item: QuizItem; no: number}) { + const [flipped, setFlipped] = useState(false); + const face = 'w4-flip-face absolute inset-0 flex flex-col justify-between border p-4'; + const faceStyle = {backgroundColor: ITEM_CARD, borderColor: ITEM_INK}; + + return ( +
{ + event.stopPropagation(); + setFlipped((v) => !v); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + event.stopPropagation(); + setFlipped((v) => !v); + }} + className={cn('w4-flip h-[216px] w-[262px] shrink-0 cursor-pointer snap-center', flipped && 'w4-flip-on')} + > +
+
+

+ 문제 {no} + {item.level ? ` · ${item.level}` : ''} +

+

+ {item.question} +

+

+ 뒤집기 → +

+
+ +
+

+ 힌트 +

+ {item.hint ? ( +

+ {item.hint} +

+ ) : ( +

힌트가 아직 없습니다.

+ )} +
+ {item.topic &&

{item.topic}

} + +
+
+
+
+ ); +} + +export function QuizFlip(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + const {ref, scrollBy} = useCarousel(); + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : parsed.items.length === 0 ? ( + + ) : ( +
+
+ {parsed.items.map((item, index) => ( + + ))} +
+
+

+ 정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항 +

+ {parsed.items.length > 2 && ( + scrollBy(-1)} onNext={() => scrollBy(1)} label="질문" /> + )} +
+
+ )} +
+
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/retro/common.tsx b/solution/frontend/src/features/builder/canvas/variants/retro/common.tsx deleted file mode 100644 index 7fee6ee..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/retro/common.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 7080 아이템이 공유하는 조각 — 서체 스택, 출처 각주, 캐러셀 화살표. - * - * 세 아이템이 각자 만들면 같은 각주가 세 모양이 된다. 출처 표기는 이 레포의 규약이라 특히 갈리면 안 된다. - */ -import {useCallback, useRef} from 'react'; -import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react'; -import {cn} from '@/lib/utils'; -import type {DataSource, DataVerified} from '../../dataSpec'; - -/** 옛 간판체. 큰 글자에만 쓴다 — 본문에 쓰면 읽히지 않는다. */ -export const RETRO_SIGN = "'Gugi', 'Noto Sans KR', sans-serif"; -/** 본문 명조. */ -export const RETRO_BODY = "'Gowun Batang', 'Noto Serif KR', serif"; -/** 손글씨. 엽서·주석 한 줄에만. */ -export const RETRO_HAND = "'Nanum Pen Script', 'Gowun Batang', cursive"; - -export const RETRO_PAPER = '#e4dac0'; -export const RETRO_PAPER_LIGHT = '#f2ebd9'; -export const RETRO_INK = '#1b1a15'; -export const RETRO_INK_SOFT = '#4c4739'; -export const RETRO_LINE = '#c0b493'; -export const RETRO_RED = '#bf2f1b'; - -/** - * 출처 한 줄 + 확신 배지. - * - * ★ 확인되지 않은 값을 숨기지 않고 **드러낸다.** 숨기면 사장님이 그게 미검증인 줄 모르고 발행한다. - */ -export function SourceLine({ - source, - verified, - tone = 'light', -}: { - source?: DataSource; - verified?: DataVerified; - tone?: 'light' | 'dark'; -}) { - const dark = tone === 'dark'; - if (!source && !verified) return null; - - return ( -

- {verified && ( - - {verified} - - )} - {source?.name && ( - - 출처 ·{' '} - {source.url ? ( - event.stopPropagation()} - className="underline underline-offset-2" - > - {source.name} - - ) : ( - source.name - )} - - )} -

- ); -} - -/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */ -export function useCarousel() { - const ref = useRef(null); - const scrollBy = useCallback((dir: -1 | 1) => { - const box = ref.current; - if (!box) return; - box.scrollBy({left: box.clientWidth * 0.8 * dir, behavior: 'smooth'}); - }, []); - return {ref, scrollBy}; -} - -export function CarouselNav({ - onPrev, - onNext, - tone = 'light', - label, -}: { - onPrev: () => void; - onNext: () => void; - tone?: 'light' | 'dark'; - label: string; -}) { - const dark = tone === 'dark'; - const base = cn( - 'flex size-8 items-center justify-center border transition-colors', - dark - ? 'border-stone-600 bg-stone-800/60 text-stone-300 hover:bg-stone-700' - : 'border-stone-400 bg-white/70 text-stone-700 hover:bg-stone-900 hover:text-white', - ); - const stop = (fn: () => void) => (event: React.MouseEvent) => { - event.stopPropagation(); - fn(); - }; - - return ( -
- - -
- ); -} - -/** - * 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다. - * - * ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다. - */ -export function PasteHint({label}: {label: string}) { - return ( -
- -

{label} 내용이 아직 없습니다

-

- 오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다. -

-
- ); -} - -/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다. */ -export function ParseError({message}: {message: string}) { - return ( -
-

붙여넣은 JSON 을 읽지 못했습니다

-

{message}

-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/retro/retro.css b/solution/frontend/src/features/builder/canvas/variants/retro/retro.css deleted file mode 100644 index c08003c..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/retro/retro.css +++ /dev/null @@ -1,69 +0,0 @@ -/** - * 7080 아이템 전용 질감 — Tailwind 로는 못 그리는 것만 여기 둔다. - * 도넛판 홈, 갱지 결, 절취선, 펀치 구멍. 색은 전부 인라인 변수로 받아 템플릿 팔레트를 따른다. - */ - -/* 갱지 — 두 방향 결이 겹쳐야 종이로 읽힌다. */ -.w4-paper { - background-image: - repeating-linear-gradient(0deg, rgb(27 26 21 / 3%) 0 1px, transparent 1px 3px), - repeating-linear-gradient(90deg, rgb(27 26 21 / 2%) 0 1px, transparent 1px 4px); -} - -/* 도넛판. --lbl 이 라벨 색이다. */ -.w4-disc { - background: - radial-gradient(circle at 50% 50%, transparent 0 15.5%, var(--lbl, #d4551f) 15.5% 33%, transparent 33%), - repeating-radial-gradient(circle at 50% 50%, #1a1812 0 1.4px, #15140f 1.4px 3px), - #15140f; - box-shadow: 0 0 0 1px #000, inset 0 0 40px rgb(0 0 0 / 80%); -} -.w4-disc-sheen { - background: conic-gradient( - from 210deg, - rgb(255 255 255 / 12%), - transparent 22%, - transparent 70%, - rgb(255 255 255 / 7%) - ); -} -.w4-spin { - animation: w4-rev 2.2s linear infinite; -} -@keyframes w4-rev { - to { - transform: rotate(360deg); - } -} - -/* 미니 판 — 캐러셀에 늘어서는 작은 것. */ -.w4-disc-mini { - background: - radial-gradient(circle at 50% 50%, transparent 0 14%, var(--lbl, #d4551f) 14% 34%, transparent 34%), - repeating-radial-gradient(circle at 50% 50%, #1a1812 0 1.2px, #15140f 1.2px 2.6px), - #15140f; - box-shadow: 0 3px 10px rgb(0 0 0 / 45%); -} - -/* 일력 톱니 — 뜯어낸 자국. */ -.w4-perf { - background: repeating-linear-gradient(90deg, transparent 0 8px, var(--tear, #e4dac0) 8px 9px); -} - -/* 승차권 절취선 */ -.w4-dash { - border-top: 1px dashed currentcolor; -} - -.w4-scroll { - scrollbar-width: none; -} -.w4-scroll::-webkit-scrollbar { - display: none; -} - -@media (prefers-reduced-motion: reduce) { - .w4-spin { - animation: none; - } -} diff --git a/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx b/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx index deeb7bf..d658797 100644 --- a/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx @@ -8,21 +8,23 @@ */ import {SectionBody, SectionFrame} from '../../primitives'; import type {SectionRenderProps} from '../../types'; -import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec'; +import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '@o2o/shared'; import { CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ITEM_INK, + ITEM_INVERSE, + ITEM_INVERSE_INK, ParseError, PasteHint, - RETRO_BODY, - RETRO_INK, - RETRO_LINE, - RETRO_PAPER_LIGHT, - RETRO_RED, - RETRO_SIGN, SourceLine, useCarousel, -} from '../retro/common'; -import '../retro/retro.css'; +} from '../items/common'; +import '../items/items.css'; /** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */ function splitTime(time: string): {head: string; tail?: string} { @@ -38,43 +40,47 @@ function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */} -
+ {/* 플립보드는 템플릿의 어두운 면이다. 위의 글자는 밝은 면 색을 그대로 쓴다. */} +
{head} - {tail && :{tail}} + {tail && :{tail}}
-

+

{slot.title}

{slot.place && ( -

+

{slot.place}

)} {slot.note && ( -

+

{slot.note}

)}
{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'} {slot.searchQuery && 지도 검색 · {slot.searchQuery}} @@ -84,7 +90,7 @@ function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) { {/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */} {!isLast && (
- +
)}
@@ -101,10 +107,10 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
-

+

{schedule.name}

- + {[schedule.audience, schedule.season, span, `${slots.length}칸`] .filter(Boolean) .join(' · ')} @@ -117,8 +123,8 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) { {slots.length === 0 ? (

시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.

@@ -147,11 +153,11 @@ export function ScheduleTimetable(props: SectionRenderProps) {
-

+

{parsed.title || section.name}

{(parsed.subtitle || section.description) && ( -

+

{parsed.subtitle || section.description}

)} diff --git a/solution/frontend/src/features/builder/canvas/variants/songs/SongsTurntable.tsx b/solution/frontend/src/features/builder/canvas/variants/songs/SongsTurntable.tsx index e4f3a7c..5090f05 100644 --- a/solution/frontend/src/features/builder/canvas/variants/songs/SongsTurntable.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/songs/SongsTurntable.tsx @@ -7,19 +7,22 @@ import {useState} from 'react'; import {SectionBody, SectionFrame} from '../../primitives'; import type {SectionRenderProps} from '../../types'; -import {parseSectionData, type SongItem} from '../../dataSpec'; +import {parseSectionData, type SongItem} from '@o2o/shared'; import { CarouselNav, + ITEM_ACCENT, + ITEM_BODY, + ITEM_HEADING, + ITEM_INVERSE, ParseError, PasteHint, - RETRO_BODY, - RETRO_SIGN, SourceLine, useCarousel, -} from '../retro/common'; -import '../retro/retro.css'; +} from '../items/common'; +import '../items/items.css'; -const FALLBACK_LABEL = '#d4551f'; +/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다 — hex 를 박으면 팔레트를 바꿔도 판만 남는다. */ +const FALLBACK_LABEL = ITEM_ACCENT; export function SongsTurntable(props: SectionRenderProps) { const {section, isSelected, onSelect, template} = props; @@ -32,14 +35,18 @@ export function SongsTurntable(props: SectionRenderProps) { return ( - + {/* ★ 어두운 면 위의 글자색을 여기서 한 번만 정한다. 아래는 전부 currentColor·opacity 로 단을 만든다 — + 자식마다 색을 박으면 템플릿을 바꿨을 때 한두 곳이 옛 색으로 남는다. */} +
-

33⅓ RPM

-

+

+ 33⅓ RPM +

+

{parsed.title || section.name}

{(parsed.subtitle || section.description) && ( -

+

{parsed.subtitle || section.description}

)} @@ -51,34 +58,57 @@ export function SongsTurntable(props: SectionRenderProps) { ) : ( <> -
+
{/* 턴테이블 */}
-
+ {/* 플래터 — 판 아래 깔리는 원반. 어두운 면 위에 글자색을 옅게 얹어 단을 만든다. */} +
-
+
{/* 톤암 — 곡이 얹혀 있으니 항상 내려와 있다. */}
-
-
-
+
+
+
{/* 지금 도는 곡 */}
-

+

A면 · {playing + 1} / {parsed.items.length}

-

+

{current.title}

-

+

{[ current.artist, current.year ? String(current.year) : undefined, @@ -92,8 +122,8 @@ export function SongsTurntable(props: SectionRenderProps) {

{current.story && (

{current.story}

@@ -101,12 +131,13 @@ export function SongsTurntable(props: SectionRenderProps) { {current.connection && (

{current.connection}

)} - + ◎ 가사 대신 이야기 — 원문은 싣지 않습니다 @@ -131,13 +162,15 @@ export function SongsTurntable(props: SectionRenderProps) { className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105" style={{ ['--lbl' as string]: song.labelColor || FALLBACK_LABEL, + ['--w4-vinyl' as string]: ITEM_INVERSE, + // 고른 판만 강조색 링 — 금색을 박으면 팔레트를 바꿔도 이 링만 남는다. boxShadow: index === playing - ? '0 0 0 2px #c9930f, 0 3px 10px rgba(0,0,0,.45)' + ? `0 0 0 2px ${ITEM_ACCENT}, 0 3px 10px rgba(0,0,0,.4)` : undefined, }} /> - + {song.title} diff --git a/solution/frontend/src/features/publish/siteTheme.ts b/solution/frontend/src/features/publish/siteTheme.ts index 2b45ff0..d8f69b1 100644 --- a/solution/frontend/src/features/publish/siteTheme.ts +++ b/solution/frontend/src/features/publish/siteTheme.ts @@ -1,4 +1,4 @@ -import type {InfoField, SectionItem, TemplateItem} from '@o2o/shared'; +import type {InfoField, SectionItem, TemplateItem, TemplateLook} from '@o2o/shared'; import {ApiError} from '@/api'; import {notifyApiError} from '@/lib/notify'; import {callSiteApi, type ResultEnvelope} from './siteApi'; @@ -40,6 +40,13 @@ export interface SiteThemePayload { * 서버는 어느 쪽도 해석하지 않는다. */ colorPaletteId?: string | null; + /** + * 템플릿의 생김새(서체·모서리·테두리·그림자·여백). + * + * ★ 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다 — + * 사장님이 레트로를 골라도 발행 페이지만 고딕이었다. 서버는 해석하지 않고 보관만 한다. + */ + look?: TemplateLook; sections: { id: string; /** @@ -81,6 +88,7 @@ export function toThemePayload( return { colors: template.colors, fontStyle: template.fontStyle, + look: template.look, colorPaletteId, sections: sections.map((s) => ({ id: s.id, diff --git a/solution/frontend/src/lib/color.ts b/solution/frontend/src/lib/color.ts index 145c23b..5e0b5fa 100644 --- a/solution/frontend/src/lib/color.ts +++ b/solution/frontend/src/lib/color.ts @@ -1,37 +1,7 @@ /** - * 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다. + * 색 계산은 계약 패키지가 소유한다(`@o2o/shared/lib/color.ts`). * - * ★ 쇼케이스(개발 도구)와 실제 캔버스가 **같은 식**을 써야 한다. - * 미리보기에서 예쁜 조합이 발행 화면에서 달라지면 미리보기가 거짓말이 된다. + * ★ 발행 사이트도 같은 식으로 면 토큰을 만든다. 두 벌로 두면 캔버스와 발행본의 바탕색이 갈린다. + * 여기는 기존 import 경로(`@/lib/color`)를 지키는 재수출만 남긴다. */ - -function rgb(hex: string): [number, number, number] { - const n = parseInt(hex.replace('#', ''), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; -} - -/** 두 색을 섞는다. ratio=0 이면 a, 1 이면 b. */ -export function mix(a: string, b: string, ratio: number): string { - // hex 가 아닌 값(css 변수·색 이름)이 들어오면 계산이 무의미하다 — 원본을 그대로 돌려준다. - if (!/^#[0-9a-fA-F]{6}$/.test(a) || !/^#[0-9a-fA-F]{6}$/.test(b)) return a; - const [ar, ag, ab] = rgb(a); - const [br, bg, bb] = rgb(b); - const ch = (x: number, y: number) => Math.round(x + (y - x) * ratio); - const hex = (n: number) => n.toString(16).padStart(2, '0'); - return `#${hex(ch(ar, br))}${hex(ch(ag, bg))}${hex(ch(ab, bb))}`; -} - -/** - * 템플릿 색 6개 → 면 토큰 4개. - * - * 캔버스와 쇼케이스가 공유하는 유도 규칙이다. 섹션 기본(bg) · paper · tint 가 - * 서로 다른 밝기여야 경계선 없이도 섹션이 나뉜다. - */ -export function deriveSurfaces(colors: {bg: string; card: string; text: string; secondary: string}) { - return { - surface: mix(colors.bg, colors.text, 0.06), - surfaceAlt: colors.card, - inverse: '#1c1917', - border: colors.secondary, - }; -} +export {mix, deriveSurfaces} from '@o2o/shared'; diff --git a/solution/shared/src/index.ts b/solution/shared/src/index.ts index 9b641f8..7700236 100644 --- a/solution/shared/src/index.ts +++ b/solution/shared/src/index.ts @@ -2,3 +2,5 @@ export * from './types'; export * from './lib/cn'; export * from './lib/slug'; export * from './lib/facts'; +export * from './lib/section-data'; +export * from './lib/color'; diff --git a/solution/shared/src/lib/color.ts b/solution/shared/src/lib/color.ts new file mode 100644 index 0000000..821c275 --- /dev/null +++ b/solution/shared/src/lib/color.ts @@ -0,0 +1,38 @@ +/** + * 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다. + * + * ★ 빌더 캔버스 · 쇼케이스 · **발행 사이트**가 같은 식을 써야 한다. + * 미리보기에서 예쁜 조합이 발행 화면에서 달라지면 미리보기가 거짓말이 된다. + * 그래서 프론트 lib 이 아니라 계약 패키지에 둔다. + */ + +function rgb(hex: string): [number, number, number] { + const n = parseInt(hex.replace('#', ''), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** 두 색을 섞는다. ratio=0 이면 a, 1 이면 b. */ +export function mix(a: string, b: string, ratio: number): string { + // hex 가 아닌 값(css 변수·색 이름)이 들어오면 계산이 무의미하다 — 원본을 그대로 돌려준다. + if (!/^#[0-9a-fA-F]{6}$/.test(a) || !/^#[0-9a-fA-F]{6}$/.test(b)) return a; + const [ar, ag, ab] = rgb(a); + const [br, bg, bb] = rgb(b); + const ch = (x: number, y: number) => Math.round(x + (y - x) * ratio); + const hex = (n: number) => n.toString(16).padStart(2, '0'); + return `#${hex(ch(ar, br))}${hex(ch(ag, bg))}${hex(ch(ab, bb))}`; +} + +/** + * 템플릿 색 6개 → 면 토큰 4개. + * + * 캔버스와 쇼케이스가 공유하는 유도 규칙이다. 섹션 기본(bg) · paper · tint 가 + * 서로 다른 밝기여야 경계선 없이도 섹션이 나뉜다. + */ +export function deriveSurfaces(colors: {bg: string; card: string; text: string; secondary: string}) { + return { + surface: mix(colors.bg, colors.text, 0.06), + surfaceAlt: colors.card, + inverse: '#1c1917', + border: colors.secondary, + }; +} diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts new file mode 100644 index 0000000..a3f8f0f --- /dev/null +++ b/solution/shared/src/lib/section-data.ts @@ -0,0 +1,411 @@ +/** + * 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 JSON 은 어떤 모양인가". + * + * ★ 왜 shared 인가 + * 같은 JSON 을 두 렌더러가 읽는다 — 빌더 캔버스(solution/frontend)와 발행 사이트(solution/site). + * 파서를 각자 두면 "빌더에서는 보이는데 발행하면 없다"가 조용히 생긴다(슬러그 규칙과 같은 함정). + * 프롬프트·예시·라벨처럼 **쓰는 쪽**만 필요한 것은 빌더에 남는다(`canvas/dataSpec.ts`). + */ + +/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */ +export type DataVerified = '확인' | '확인필요'; + +export interface DataSource { + name: string; + url?: string; +} + +export interface SongItem { + title: string; + artist?: string; + lyricist?: string; + composer?: string; + year?: number; + label?: string; + labelColor?: string; + story?: string; + connection?: string; + verified?: DataVerified; + source?: DataSource; +} + +export interface DailyItem { + monthDay: string; + category?: string; + title: string; + body?: string; + season?: string; + tags?: string[]; + verified?: DataVerified; + source?: DataSource; +} + +export interface CourseStop { + order?: number; + name: string; + minutes?: number; + note?: string; + searchQuery?: string; +} + +export interface CourseItem { + name: string; + duration?: string; + startsFrom?: string; + stops?: CourseStop[]; + verified?: DataVerified; + source?: DataSource; +} + +export interface ScheduleSlot { + /** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */ + time: string; + title: string; + place?: string; + minutes?: number; + note?: string; + searchQuery?: string; +} + +export interface ScheduleItem { + name: string; + /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */ + audience?: string; + season?: string; + slots?: ScheduleSlot[]; + verified?: DataVerified; + source?: DataSource; +} + +export interface PeopleItem { + name: string; + /** 호·예명. 본명 옆에 나란히 불리는 이름이 있으면 프레임 아래 각인으로 붙는다. */ + aka?: string; + years?: string; + role?: string; + oneLine?: string; + /** + * 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고, + * 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약). + */ + imageQuery?: string; + verified?: DataVerified; + source?: DataSource; +} + +export interface ChronicleItem { + /** 연도가 없으면 레일 끝으로 밀린다 — 순서를 지어내지 않는다. */ + year?: number; + title: string; + summary?: string; + place?: string; + /** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */ + turning?: boolean; + verified?: DataVerified; + source?: DataSource; +} + +export interface LiteratureItem { + workTitle: string; + author?: string; + /** "1937" · "1937–1938 연재" 처럼 원문 그대로. 숫자로 좁히면 연재물이 안 들어간다. */ + year?: string; + genre?: string; + spineColor?: string; + background?: string; + whyHere?: string; + verified?: DataVerified; + source?: DataSource; +} + +export interface PostcardItem { + line: string; + hashtags?: string[]; + place?: string; + /** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */ + postmark?: string; + verified?: DataVerified; + source?: DataSource; +} + +export interface QuizItem { + question: string; + /** ★ answer 는 없다. 이 데이터는 검증되지 않은 줄이 더 많아서, 정답을 단정하면 틀린 걸 단정한다. */ + hint?: string; + topic?: string; + level?: string; + verified?: DataVerified; + source?: DataSource; +} + +export interface PlannerStop { + name: string; + /** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */ + minutes?: number; + /** 앞 칸에서 여기까지 오는 시간(분). 첫 칸은 업소에서 나서는 시간이다. */ + moveMinutes?: number; + note?: string; + searchQuery?: string; +} + +export interface PlannerItem { + name: string; + /** '봄' · '여름' · '가을' · '겨울'. 화면의 계절 탭이 이 값에서 파생된다. */ + season?: string; + /** 그 계절 안에서의 순위. 1·2·3 만 쓴다 — 4위부터는 아무도 안 고른다. */ + rank?: number; + /** 하루가 시작하는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */ + startTime?: string; + audience?: string; + /** 왜 이 계절에 이 코스인가. 순위를 납득시키는 한 문장. */ + why?: string; + stops?: PlannerStop[]; + verified?: DataVerified; + source?: DataSource; +} + +/** + * 섹션 타입 → 없으면 그 줄을 통째로 버리는 키. + * + * ★ 이 표가 곧 "붙여넣기 아이템이 무엇무엇인가"의 목록이다. 여기 없는 타입의 data 는 파싱하지 않는다. + * 빈 껍데기(제목 없는 줄)가 화면에 줄만 남기는 걸 막는 자리이기도 하다. + */ +export const SECTION_ITEM_REQUIRED_KEY: Record = { + songs: 'title', + daily: 'title', + course: 'name', + schedule: 'name', + people: 'name', + chronicle: 'title', + literature: 'workTitle', + postcard: 'line', + quiz: 'question', + planner: 'name', +}; + +export interface ParsedSectionData { + items: T[]; + title?: string; + subtitle?: string; + /** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */ + error?: string; + /** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */ + kindMismatch?: string; + /** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */ + unverified: number; + /** source 가 붙은 항목 수. */ + sourced: number; +} + +const EMPTY: ParsedSectionData = {items: [], unverified: 0, sourced: 0}; + +/** + * JSON.parse 실패를 "몇 번째 줄"로 바꾼다. + * + * ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과, + * 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다. + * 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다. + */ +function locate(raw: string, message: string): string { + const where = (pos: number) => { + const before = raw.slice(0, Math.max(0, pos)); + const line = before.split('\n').length; + const col = pos - before.lastIndexOf('\n'); + return `${line}번째 줄 ${col}번째 글자`; + }; + + const token = /Unexpected token '(.)'/.exec(message)?.[1]; + // 쉼표를 하나 더 찍은 경우가 압도적으로 많다 — 그 말을 먼저 해 준다. + const hint = + token === '}' || token === ']' + ? '닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.' + : '그 앞의 쉼표·따옴표·괄호를 확인해 주세요.'; + + const lineCol = /line (\d+) column (\d+)/.exec(message); + if (lineCol) return `${lineCol[1]}번째 줄 ${lineCol[2]}번째 글자에서 JSON 이 끊깁니다. ${hint}`; + + const at = /position (\d+)/.exec(message); + if (at) return `${where(Number(at[1]))}에서 JSON 이 끊깁니다. ${hint}`; + + // 위치 없이 조각만 인용하는 형 — 그 조각을 원문에서 되찾는다. + const quoted = /\.\.\."([\s\S]*?)" is not valid JSON/.exec(message)?.[1]; + const found = quoted ? raw.indexOf(quoted) : -1; + if (found >= 0) return `${where(found + quoted!.length)} 부근에서 JSON 이 끊깁니다. ${hint}`; + + return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.'; +} + +/** + * 붙여넣은 문자열 → 렌더 가능한 항목. + * + * ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. + * 발행 사이트에서도 같다 — 프리렌더가 예외로 죽으면 사이트 전체가 안 구워진다. + */ +export function parseSectionData( + sectionType: string, + raw: string | undefined, +): ParsedSectionData { + const requiredKey = SECTION_ITEM_REQUIRED_KEY[sectionType]; + const text = (raw ?? '').trim(); + if (!requiredKey || !text) return EMPTY as ParsedSectionData; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return {...EMPTY, error: locate(text, error instanceof Error ? error.message : '')}; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return {...EMPTY, error: '바깥이 { } 로 감싸인 JSON 이어야 합니다.'}; + } + + const envelope = parsed as Record; + const kind = typeof envelope.kind === 'string' ? envelope.kind : undefined; + const rawItems = envelope.items; + if (!Array.isArray(rawItems)) { + return {...EMPTY, error: 'items 배열이 없습니다. 프롬프트로 다시 만들어 주세요.'}; + } + + const items = rawItems.filter( + (item): item is T => + typeof item === 'object' && + item !== null && + !Array.isArray(item) && + typeof (item as Record)[requiredKey] === 'string' && + ((item as Record)[requiredKey] as string).trim().length > 0, + ); + + let unverified = 0; + let sourced = 0; + for (const item of items) { + const row = item as Record; + if (row.verified !== '확인') unverified += 1; + if (row.source && typeof row.source === 'object') sourced += 1; + } + + return { + items, + title: typeof envelope.title === 'string' ? envelope.title : undefined, + subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined, + kindMismatch: kind && kind !== sectionType ? kind : undefined, + unverified, + sourced, + }; +} + + +/* ───────────────────────────────────────────────────────────── + * 계절별 추천 하루 — 시각을 **계산해서** 짜 준다. + * + * ★ 왜 shared 인가: 파서와 같은 이유다. 빌더 캔버스와 발행 사이트가 같은 코스에서 **같은 시각**을 + * 내놓아야 한다. 조립 규칙이 두 벌이면 사장님이 본 일정과 손님이 보는 일정이 조용히 갈린다. + * ★ 여행 스케줄(schedule)과 축이 다르다 — 저쪽은 사장님이 시각을 적고, 여기는 시각을 계산한다. + * 그래서 사장님은 "몇 분 걸리나"만 알면 되고, 출발 시각을 바꾸면 하루가 통째로 밀린다. + * ───────────────────────────────────────────────────────────── */ + +/** 밤 9시를 넘기는 칸은 넣지 않는다 — 짜 준 일정이 손님을 밤까지 끌고 다니면 그 순간 신뢰를 잃는다. */ +const PLAN_ENDS_BY = 21 * 60; +/** 머무는 시간을 안 적었을 때. 0 으로 두면 한 시각에 칸이 겹쳐 쌓인다. */ +const PLAN_DEFAULT_STAY = 60; +/** 출발 시각을 안 적었을 때. 체크아웃 뒤 움직이는 시각을 기본으로 잡는다. */ +const PLAN_DEFAULT_START = '10:00'; + +/** 계절 탭 순서. 데이터에 있는 계절만 이 순서로 세운다 — 붙여넣은 순서대로 두면 겨울이 맨 앞에 온다. */ +const SEASON_ORDER = ['봄', '여름', '가을', '겨울']; + +/** "HH:MM" → 자정부터의 분. 형식이 아니면 undefined — 지어내지 않는다. */ +export function minutesOfTime(time: string): number | undefined { + const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!match) return undefined; + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) return undefined; + return hour * 60 + minute; +} + +export function timeOfMinutes(total: number): string { + const wrapped = ((total % 1440) + 1440) % 1440; + return `${String(Math.floor(wrapped / 60)).padStart(2, '0')}:${String(wrapped % 60).padStart(2, '0')}`; +} + +export interface PlannedStop { + stop: PlannerStop; + /** 도착 시각 "HH:MM". */ + time: string; + /** 떠나는 시각 "HH:MM". */ + until: string; + /** 앞 칸에서 오는 데 걸린 분. 0 이면 화면이 이동 줄을 그리지 않는다. */ + move: number; +} + +export interface PlannedDay { + stops: PlannedStop[]; + /** 하루가 시작·끝나는 시각. 카드 머리에 "09:30–16:40" 으로 뜬다. */ + from: string; + to: string; + /** 총 소요(분) — 이동 시간까지 포함한다. */ + totalMinutes: number; + /** 21시 상한에 걸려 못 넣은 칸 수. 숨기지 않고 화면에 밝힌다. */ + dropped: number; +} + +/** + * 코스 하나 → 시각이 박힌 하루. + * + * 정거장 순서는 사장님이 적은 그대로다(적은 순서가 곧 도는 순서다). 출발 시각부터 + * 이동·머무는 시간을 누적해 칸마다 도착·출발 시각을 박고, 상한을 넘기는 칸은 버린다 — + * 넘겨서라도 다 넣으면 자정에 끝나는 일정이 나온다. + */ +export function planDay(item: PlannerItem): PlannedDay { + const start = minutesOfTime(item.startTime ?? '') ?? minutesOfTime(PLAN_DEFAULT_START) ?? 600; + const stops: PlannedStop[] = []; + let clock = start; + let dropped = 0; + + for (const stop of item.stops ?? []) { + const move = Math.max(0, stop.moveMinutes ?? 0); + const stay = Math.max(1, stop.minutes ?? PLAN_DEFAULT_STAY); + const arrive = clock + move; + if (arrive + stay > PLAN_ENDS_BY) { + dropped += 1; + continue; + } + stops.push({stop, time: timeOfMinutes(arrive), until: timeOfMinutes(arrive + stay), move}); + clock = arrive + stay; + } + + return { + stops, + from: timeOfMinutes(start), + to: timeOfMinutes(clock), + totalMinutes: clock - start, + dropped, + }; +} + +/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */ +export function plannerSeasons(items: PlannerItem[]): string[] { + const seen: string[] = []; + for (const item of items) { + const season = item.season?.trim(); + if (season && !seen.includes(season)) seen.push(season); + } + return seen.sort((a, b) => { + const ai = SEASON_ORDER.indexOf(a); + const bi = SEASON_ORDER.indexOf(b); + return (ai < 0 ? SEASON_ORDER.length : ai) - (bi < 0 ? SEASON_ORDER.length : bi); + }); +} + +/** + * 그 계절의 추천 코스 — 순위대로 최대 세 개. + * + * ★ 셋에서 끊는다. 넷째부터는 아무도 안 고르고, 화면에서는 "추천"이 아니라 목록이 된다. + * ★ rank 를 안 적었으면 붙여넣은 순서가 순위다 — 순위 없는 코스를 1위로 올리지 않는다. + */ +export function plannerTop(items: PlannerItem[], season?: string): PlannerItem[] { + const picked = season ? items.filter((item) => item.season?.trim() === season) : [...items]; + picked.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER)); + return picked.slice(0, 3); +} diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index eb0670e..08f5801 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -1,3 +1,4 @@ +import type {TemplateLook} from './builder'; import type { FactStatus, LinkChannel, @@ -229,6 +230,15 @@ export interface SiteTheme { accent: string; }; fontStyle: string; + /** + * 템플릿의 생김새 — 서체 · 모서리 · 테두리 두께 · 그림자 · 섹션 여백. + * + * ★ 이 필드가 없던 동안 발행본은 **색만** 템플릿을 따랐다. 사장님이 레트로(간판체)를 골라도 + * 발행 페이지는 늘 같은 고딕·명조로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다. + * ★ 값은 전부 CSS 에 그대로 들어가는 문자열이다(`TemplateLook`). 렌더러는 이걸 `--tpl-*` 로 + * 내려보내기만 한다. 비어 있으면 렌더러 기본 서체로 떨어진다 — 화면이 깨지지 않는다. + */ + look?: TemplateLook; /** 섹션 순서와 노출 여부. 관리자 에디터의 좌측 패널이 만든 결과 그대로. */ sections: SectionSetting[]; } @@ -258,4 +268,13 @@ export interface SectionSetting { * ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로

를 나눈다. */ body?: string; + /** + * 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON. + * + * ★ body·variantId 와 같은 사연이다 — 이 필드가 없으면 사장님이 붙여넣은 곡 목록이 + * payload 경계에서 버려져 빌더에서는 보이는데 발행본에는 없다. + * ★ **문자열 그대로** 싣는다. 서버는 파싱하지 않는다 — 렌더러가 `parseSectionData()` + * (shared/lib/section-data.ts)로 읽고, 깨진 JSON 이면 그 섹션만 조용히 비운다. + */ + data?: string; } diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 1b79dd5..a42c424 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -12,7 +12,12 @@ import { import {basename, dirname, join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {SitePayload} from '@o2o/shared'; -import {joinUrl, sanitizePayloadForPublish} from '@o2o/shared'; +import { + joinUrl, + parseSectionData, + sanitizePayloadForPublish, + SECTION_ITEM_REQUIRED_KEY, +} from '@o2o/shared'; import {render} from '@/entry-server'; import { collectJsonLd, @@ -397,6 +402,27 @@ function countUniqueContent(payload: SitePayload): number { const intro = payload.theme.sections.find((section) => section.id === 'intro'); if (intro?.enabled && long(intro.body)) count += 1; + /** + * 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다. + * + * ★ 안 세면 "곡을 여덟 개 채웠는데 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — + * 소개 본문(intro.body)이 계약에 없던 시절과 같은 구멍이다. + * ★ 긴 문장이 한 줄이라도 있는 항목만 센다. 제목·연도만 있는 줄은 가게를 구분하지 못한다. + */ + const hasLongText = (value: unknown): boolean => { + if (typeof value === 'string') return long(value); + if (Array.isArray(value)) return value.some(hasLongText); + if (value && typeof value === 'object') return Object.values(value).some(hasLongText); + return false; + }; + for (const section of payload.theme.sections) { + if (!section.enabled || !SECTION_ITEM_REQUIRED_KEY[section.id]) continue; + const parsed = parseSectionData>(section.id, section.data); + for (const item of parsed.items) { + if (hasLongText(item)) count += 1; + } + } + // 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다. for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) { if (fact.type === 'text' && long(fact.value)) count += 1; diff --git a/solution/site/src/index.css b/solution/site/src/index.css index a94fa47..7977d64 100644 --- a/solution/site/src/index.css +++ b/solution/site/src/index.css @@ -38,13 +38,22 @@ html { body { background-color: var(--color-surface); color: var(--color-ink); - font-family: var(--font-sans); + /* ★ 본문 서체도 템플릿이 정한다(theme.look → seo/head.ts). 토큰이 없는 옛 payload 에서는 + 지금까지와 똑같이 Noto Sans KR 로 떨어진다. */ + font-family: var(--tpl-font-body, var(--font-sans)); font-feature-settings: "tnum" 1; } @layer components { + /* 제목 서체. 이름은 'serif' 로 남아 있지만 값은 템플릿이 정한다 — + 레트로면 간판체, 심플이면 고딕이다. 토큰이 없으면 예전처럼 명조로 떨어진다. */ .serif { - font-family: var(--font-serif); + font-family: var(--tpl-font-heading, var(--font-serif)); + letter-spacing: var(--tpl-heading-tracking, normal); + } + /* 카드·패널 테두리 두께도 템플릿이 정한다. 레트로는 2px 라야 인쇄물처럼 보인다. */ + .tpl-border { + border-width: var(--tpl-border-width, 1px); } /* 섹션 공통 폭 — 본문이 한 줄에 너무 길어지지 않게. */ diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 7316e5e..dc0274b 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -2,6 +2,7 @@ import { LinkChannel, PlaceCategory, factText, + parseSectionData, sanitizeUnits, selectPublishable, selectPublishableFaqs, @@ -127,6 +128,19 @@ export function sectionBody(payload: SitePayload, id: string): string[] { return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? []; } +/** + * 붙여넣기 아이템의 JSON → 렌더 가능한 항목. + * + * ★ 섹션 id 가 곧 아이템 종류다. 붙여넣기 아이템은 [+ 섹션 추가]가 `id = type` 으로 만든다 + * (frontend `canvas/addable.ts`). 그래서 payload 에 type 이 없어도 id 로 종류를 안다. + * ★ 파서는 shared 한 벌이다 — 빌더와 발행본이 같은 JSON 을 같은 규칙으로 읽어야 + * "빌더에서는 보이는데 발행하면 없다"가 안 생긴다. + */ +export function sectionItems(payload: SitePayload, id: string) { + const section = payload.theme.sections.find((entry) => entry.id === id); + return parseSectionData(id, section?.data); +} + export function unitSpec(payload: SitePayload) { return UNIT_SPEC[payload.place.category]; } diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index 6c21cae..cbc3635 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -8,6 +8,7 @@ import { GallerySection, HeroSection, InquirySection, + ITEM_SECTIONS, LocalGuideSection, WeatherSection, LocationSection, @@ -31,6 +32,7 @@ import {isSectionEnabled} from '@/lib/derive'; * 기본 레이아웃으로 그린다. 배리에이션 40종을 발행본에 옮기는 건 별도 작업이다. * 렌더러가 모르는 키가 와도 화면이 깨지지 않아야 한다는 규칙(site-payload.ts)은 * "값을 통째로 무시한다"로 이미 지켜진다. + * ★ 붙여넣기 아이템(songs·daily·…)은 배리에이션이 타입당 하나뿐이라 지금은 어긋날 것이 없다. */ export function HomePage() { const payload = useSite(); @@ -60,6 +62,9 @@ export function HomePage() { weather: WeatherSection, map: LocationSection, faq: FaqSection, + // 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다. + // ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다. + ...ITEM_SECTIONS, }; const rendered = new Set(); diff --git a/solution/site/src/sections/index.ts b/solution/site/src/sections/index.ts index 54c0073..a6ec6ed 100644 --- a/solution/site/src/sections/index.ts +++ b/solution/site/src/sections/index.ts @@ -16,3 +16,5 @@ export {LocationSection} from './LocationSection'; export {FaqSection} from './FaqSection'; export {SiteFooter} from './SiteFooter'; export {MobileTabBar} from './MobileTabBar'; +// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(theme.sections[].data). +export {ITEM_SECTIONS} from './items'; diff --git a/solution/site/src/sections/items/ChronicleSection.tsx b/solution/site/src/sections/items/ChronicleSection.tsx new file mode 100644 index 0000000..66bc9dd --- /dev/null +++ b/solution/site/src/sections/items/ChronicleSection.tsx @@ -0,0 +1,66 @@ +/** + * 시간의 골목 — 발행본. 연도가 큰 활자로 서고 사건이 붙는다. + * ★ 붉은 점은 도시의 성격이 바뀐 해다. 점의 색이 장식이 아니라 정보라 범례를 함께 낸다. + */ +import type {ChronicleItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, Rail, SourceLine} from './common'; + +export function ChronicleSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'chronicle'); + if (parsed.items.length === 0) return null; + + // 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다. + const items = [...parsed.items].sort((a, b) => { + if (a.year == null) return b.year == null ? 0 : 1; + if (b.year == null) return -1; + return a.year - b.year; + }); + const turning = items.filter((item) => item.turning === true).length; + + return ( + + + {items.map((item, index) => { + const isTurning = item.turning === true; + return ( +

+

+ {item.year ?? '연도 미상'} +

+
+ + +
+
+

{item.title}

+ {item.summary &&

{item.summary}

} + {item.place &&

지금 이 자리 · {item.place}

} + +
+
+ ); + })} + + + ); +} diff --git a/solution/site/src/sections/items/CourseSection.tsx b/solution/site/src/sections/items/CourseSection.tsx new file mode 100644 index 0000000..a8c0e6b --- /dev/null +++ b/solution/site/src/sections/items/CourseSection.tsx @@ -0,0 +1,88 @@ +/** + * 반나절 산책 — 발행본. 표 한 장이 정거장 하나다. + * ★ 링크를 만들지 않는다. 지도 검색어만 적는다 — 지어낸 주소를 링크하지 않는 이 레포의 규약. + */ +import type {CourseItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine} from './common'; + +export function CourseSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'course'); + if (parsed.items.length === 0) return null; + + return ( + +
+ {parsed.items.map((course, courseIndex) => { + const stops = course.stops ?? []; + return ( +
+
+

{course.name}

+ + {[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`] + .filter(Boolean) + .join(' · ')} + +
+ + + {stops.map((stop, index) => { + const no = String(stop.order ?? index + 1).padStart(2, '0'); + return ( +
+
+ {course.name} + NO.{no} +
+

+ {no} +

+

{stop.name}

+ {stop.note &&

{stop.note}

} +
+ {stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'} + {stop.searchQuery && 지도 검색 · {stop.searchQuery}} +
+ {/* 펀치 구멍 — 표를 표로 만드는 자리 */} + + + {index === stops.length - 1 && ( + + 완주 +
+ 도장 +
+ )} +
+ ); + })} +
+ + +
+ ); + })} +
+
+ ); +} diff --git a/solution/site/src/sections/items/DailySection.tsx b/solution/site/src/sections/items/DailySection.tsx new file mode 100644 index 0000000..c2b3fbb --- /dev/null +++ b/solution/site/src/sections/items/DailySection.tsx @@ -0,0 +1,76 @@ +/** + * 오늘의 한 장 — 발행본. + * + * ★ '오늘'을 서버에서 고르지 않는다. 프리렌더는 굽는 날짜로만 알고, 그 HTML 은 며칠씩 산다 — + * 구울 때의 '오늘'을 박으면 내일 틀린 날짜가 오늘로 걸린다. + * 그래서 전부 펴서 굽고, 브라우저에서 오늘 자리에만 표시를 얹는다(useEffect: SSR 과 안 어긋난다). + */ +import {useEffect, useState} from 'react'; +import type {DailyItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, Rail, SourceLine, tally} from './common'; + +export function DailySection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'daily'); + const [today, setToday] = useState(); + + useEffect(() => { + const now = new Date(); + setToday(`${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`); + }, []); + + if (parsed.items.length === 0) return null; + const pages = [...parsed.items].sort((a, b) => a.monthDay.localeCompare(b.monthDay)); + + return ( + + + {pages.map((page, index) => { + const [month, day] = page.monthDay.split('-'); + const isToday = today === page.monthDay; + return ( +
+
+
+

{month}月

+

+ {String(Number(day) || day)} +

+ {isToday &&

오늘

} +
+
+ {page.category &&

{page.category}

} +

{page.title}

+ {page.body &&

{page.body}

} + {page.tags && page.tags.length > 0 && ( +

{page.tags.join(' ')}

+ )} +
+ +
+
+
+ ); + })} +
+
+ ); +} diff --git a/solution/site/src/sections/items/LiteratureSection.tsx b/solution/site/src/sections/items/LiteratureSection.tsx new file mode 100644 index 0000000..a7badbc --- /dev/null +++ b/solution/site/src/sections/items/LiteratureSection.tsx @@ -0,0 +1,70 @@ +/** + * 문학 서가 — 발행본. + * + * 캔버스는 책등을 눌러 한 권만 펴지만, 발행본은 책등과 펼친 면을 함께 낸다 — + * 줄거리·배경 문장이 전부 HTML 에 있어야 인용된다. + * ★ 작품 원문은 한 줄도 없다. 배경과 "왜 여기냐"만 싣는다(원문 전재 금지). + */ +import type {LiteratureItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine, tally} from './common'; + +/** 책등 색을 안 줬을 때. 템플릿 강조색으로 떨어뜨린다. */ +const FALLBACK_SPINE = ITEM_ACCENT; + +export function LiteratureSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'literature'); + if (parsed.items.length === 0) return null; + + return ( + + + {parsed.items.map((book, index) => ( +
+ {/* 책등 — 세로쓰기가 이 아이템의 인상이다 */} +

+ {book.workTitle} + {book.author ? ` · ${book.author}` : ''} +

+ +
+

{book.workTitle}

+

+ {[book.author, book.year, book.genre].filter(Boolean).join(' · ')} +

+ {book.background &&

{book.background}

} + {book.whyHere && ( +

+ {book.whyHere} +

+ )} +

+ ◎ 원문 대신 배경 — 작품 문장은 싣지 않습니다 +

+ +
+
+ ))} +
+
+ ); +} diff --git a/solution/site/src/sections/items/PeopleSection.tsx b/solution/site/src/sections/items/PeopleSection.tsx new file mode 100644 index 0000000..7ed82b7 --- /dev/null +++ b/solution/site/src/sections/items/PeopleSection.tsx @@ -0,0 +1,59 @@ +/** + * 인물 열전 — 발행본. 필름 한 롤에 프레임이 이어진다. + * ★ 사진 자리는 활판 이니셜이다. 이미지 URL 을 받지 않기 때문에(imageQuery 만) 빈 상자 대신 활자를 둔다. + */ +import type {PeopleItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common'; + +export function PeopleSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'people'); + if (parsed.items.length === 0) return null; + + return ( + +
+
+
+ + {parsed.items.map((person, index) => ( +
+ + {person.name.trim().charAt(0)} + +

+ {person.name} + {person.aka && 호 {person.aka}} +

+

+ {[person.role, person.years].filter(Boolean).join(' · ')} +

+ {person.oneLine &&

{person.oneLine}

} +
+ +
+
+ ))} +
+
+
+
+ + ); +} diff --git a/solution/site/src/sections/items/PlannerSection.tsx b/solution/site/src/sections/items/PlannerSection.tsx new file mode 100644 index 0000000..5053206 --- /dev/null +++ b/solution/site/src/sections/items/PlannerSection.tsx @@ -0,0 +1,119 @@ +/** + * 계절별 추천 하루 — 발행본. + * + * ★ 캔버스는 계절 탭으로 하나씩 보여주지만, 발행본은 **계절 전부를 편다.** + * 탭 뒤에 숨은 계절은 HTML 에 없는 것과 같고, 이 사이트의 존재 이유가 인용이다. + * ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다. + * 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다. + */ +import {planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common'; + +const RANK_LABEL = ['1위', '2위', '3위']; + +/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */ +function spanText(minutes: number): string { + const hour = Math.floor(minutes / 60); + const rest = minutes % 60; + return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분'; +} + +function PlanCard({item, rank}: {item: PlannerItem; rank: number}) { + const day = planDay(item); + + return ( +
+
+ {/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면 + 팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */} + + {RANK_LABEL[rank] ?? `${rank + 1}위`} + + + {day.from}–{day.to} · {spanText(day.totalMinutes)} + +
+ +
+

{item.name}

+ {item.audience &&

{item.audience}

} + {item.why &&

{item.why}

} +
+ +
    + {day.stops.map((planned, index) => ( +
  1. + {/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */} + {planned.time} +
    + {planned.move > 0 &&

    ↓ {planned.move}분 이동

    } +

    {planned.stop.name}

    + {planned.stop.note && ( +

    {planned.stop.note}

    + )} +

    + {planned.time}–{planned.until} + {planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`} +

    +
    +
  2. + ))} +
+ +
+ +
+
+ ); +} + +export function PlannerSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'planner'); + if (parsed.items.length === 0) return null; + + const seasons = plannerSeasons(parsed.items); + // 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다. + const groups = seasons.length > 0 ? seasons : [undefined]; + + return ( + +
+ {groups.map((season) => { + const top = plannerTop(parsed.items, season); + if (top.length === 0) return null; + return ( +
+ {season &&

{season}

} + + {top.map((item, index) => ( + + ))} + +
+ ); + })} +
+
+ ); +} diff --git a/solution/site/src/sections/items/PostcardSection.tsx b/solution/site/src/sections/items/PostcardSection.tsx new file mode 100644 index 0000000..fefdbc8 --- /dev/null +++ b/solution/site/src/sections/items/PostcardSection.tsx @@ -0,0 +1,63 @@ +/** + * 오늘의 엽서 — 발행본. 우표 자리·소인·가운데 괘선이 있는 뒷면 한 장. + * ★ 손님이 자기 계정에 붙여넣는 것이 목적이라, 문장을 그대로 긁을 수 있게 평문으로 둔다 + * ([복사] 버튼은 두지 않는다 — 스크립트가 없어도 되는 화면을 스크립트로 만들 이유가 없다). + */ +import type {PostcardItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine, tally} from './common'; + +export function PostcardSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'postcard'); + if (parsed.items.length === 0) return null; + + return ( + + + {parsed.items.map((card, index) => ( +
+
+ {/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */} +
+

“{card.line}”

+ {card.hashtags && card.hashtags.length > 0 && ( +

{card.hashtags.join(' ')}

+ )} +
+
+ + 郵票 +
+ 10원 +
+ + {card.postmark || card.place || '소인'} + +
+
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/solution/site/src/sections/items/QuizSection.tsx b/solution/site/src/sections/items/QuizSection.tsx new file mode 100644 index 0000000..d8827a1 --- /dev/null +++ b/solution/site/src/sections/items/QuizSection.tsx @@ -0,0 +1,54 @@ +/** + * 뒤집어 보는 질문 — 발행본. + * + * ★ 캔버스처럼 뒤집지 않는다. 뒤집으면 힌트 문장이 HTML 에 있어도 화면에 없는 것처럼 읽히고, + * 무엇보다 종이로 뽑아 로비에 두는 쓰임이 이 아이템의 절반이다 — 앞뒤를 함께 인쇄한다. + * ★ 정답 칸은 없다. 이 데이터는 검증되지 않은 줄이 더 많아 단정하면 틀린 걸 단정한다. + */ +import type {QuizItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine} from './common'; + +export function QuizSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'quiz'); + if (parsed.items.length === 0) return null; + + return ( + + + {parsed.items.map((item, index) => ( +
+

+ 문제 {index + 1} + {item.level ? ` · ${item.level}` : ''} +

+

{item.question}

+ {item.hint && ( +

+ + 힌트 + + {item.hint} +

+ )} +
+ {item.topic &&

{item.topic}

} + +
+
+ ))} +
+
+ ); +} diff --git a/solution/site/src/sections/items/ScheduleSection.tsx b/solution/site/src/sections/items/ScheduleSection.tsx new file mode 100644 index 0000000..9e54cdc --- /dev/null +++ b/solution/site/src/sections/items/ScheduleSection.tsx @@ -0,0 +1,95 @@ +/** + * 여행 스케줄 — 발행본. 칸 하나가 시간대 하나다. + * ★ 승차권(course)과 축이 다르다 — 저쪽은 '어디를 도는가'(순번), 여기는 '몇 시에 무엇을'(시각). + */ +import type {ScheduleItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common'; + +/** "09:30" → 9시 30분. 형식이 어긋나면 원문 그대로 — 지어내지 않는다. */ +function splitTime(time: string): {head: string; tail?: string} { + const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!match) return {head: time.trim()}; + return {head: match[1].padStart(2, '0'), tail: match[2]}; +} + +export function ScheduleSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'schedule'); + if (parsed.items.length === 0) return null; + + return ( + +
+ {parsed.items.map((schedule, scheduleIndex) => { + const slots = schedule.slots ?? []; + const span = + slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined; + return ( +
+
+

{schedule.name}

+ + {[schedule.audience, schedule.season, span, `${slots.length}칸`].filter(Boolean).join(' · ')} + +
+ + + {slots.map((slot, index) => { + const {head, tail} = splitTime(slot.time ?? ''); + return ( +
+ {/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */} + {/* 플립보드는 템플릿의 어두운 면이다. 위의 글자는 밝은 면 색을 그대로 쓴다. */} +
+ + {head} + {tail && :{tail}} + + +
+
+

{slot.title}

+ {slot.place && ( +

+ {slot.place} +

+ )} + {slot.note &&

{slot.note}

} +
+
+ {slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'} + {slot.searchQuery && 지도 검색 · {slot.searchQuery}} +
+
+ ); + })} +
+ + +
+ ); + })} +
+
+ ); +} diff --git a/solution/site/src/sections/items/SongsSection.tsx b/solution/site/src/sections/items/SongsSection.tsx new file mode 100644 index 0000000..86e4365 --- /dev/null +++ b/solution/site/src/sections/items/SongsSection.tsx @@ -0,0 +1,78 @@ +/** + * 가요 다방 — 발행본. + * + * 캔버스는 턴테이블에 한 장만 얹지만 발행본은 곡마다 판을 하나씩 놓는다. + * 인용될 문장(story·connection)이 전부 HTML 에 있어야 하기 때문이다. + * ★ 가사는 어디에도 없다 — 스키마에 lyrics 칸 자체가 없다(원문 전재 금지). + */ +import type {SongItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common'; + +/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */ +const FALLBACK_LABEL = ITEM_ACCENT; + +export function SongsSection() { + const payload = useSite(); + const parsed = sectionItems(payload, 'songs'); + if (parsed.items.length === 0) return null; + + return ( + + + {parsed.items.map((song, index) => ( +
+
+ +
+ +

{song.title}

+

+ {[ + song.artist, + song.year ? String(song.year) : undefined, + song.lyricist || song.composer + ? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}` + : undefined, + song.label, + ] + .filter(Boolean) + .join(' · ')} +

+ {song.story &&

{song.story}

} + {song.connection && ( +

+ {song.connection} +

+ )} +

+ ◎ 가사 대신 이야기 — 원문은 싣지 않습니다 +

+
+ +
+
+ ))} +
+
+ ); +} diff --git a/solution/site/src/sections/items/common.tsx b/solution/site/src/sections/items/common.tsx new file mode 100644 index 0000000..bf090e0 --- /dev/null +++ b/solution/site/src/sections/items/common.tsx @@ -0,0 +1,140 @@ +/** + * 붙여넣기 아이템의 발행본 공통 조각. + * + * ★ 색과 서체를 박지 않는다. 전부 템플릿 토큰(`--tpl-*`, `seo/head.ts` 가 심는다)을 읽는다 — + * 한때 이 파일이 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고, 그 바람에 + * 사장님이 템플릿을 바꿔도 아이템 섹션만 레트로로 남았다. 아이템은 레트로 전용 부품이 아니다. + * ★ 인상은 빌더 캔버스와 같게 그린다. 다만 **인터랙션은 옮기지 않는다** — + * 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. + * 이 사이트의 존재 이유가 AI·검색의 인용이라, 발행본은 전 항목을 펴고 가로로만 민다. + */ +import type {ReactNode} from 'react'; +import type {DataSource, DataVerified} from '@o2o/shared'; +import './items.css'; + +/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */ +export const ITEM_CARD = 'var(--tpl-card, #fafafa)'; +/** 선·테두리. */ +export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)'; +/** + * 강조 — 연표의 전환점, 일력의 날짜, 소인 도장. + * + * ★ 강조색을 그대로 쓰지 않고 둘레 글자색을 섞는다(빌더 캔버스와 같은 규칙). 팔레트에 따라 + * accent 가 바탕과 같은 밝기면 큰 숫자와 배지가 화면에서 사라진다. + */ +export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)'; +/** 어두운 면(필름·플립보드). */ +export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)'; +/** 어두운 면 위의 글자색 — 흰색을 박지 않고 템플릿의 밝은 면 색을 쓴다. */ +export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)'; +/** 글자색. */ +export const ITEM_INK = 'var(--tpl-text, #09090b)'; + +/** + * 아이템 섹션 껍데기. + * + * 바탕은 `--tpl-surface`(섹션 바탕) 또는 `--tpl-inverse`(어두운 아이템)다. + * 다른 섹션들이 쓰는 `border-black/8` 대신 토큰 테두리를 쓴다 — 어두운 면에서 검은 선은 안 보인다. + */ +export function ItemSection({ + id, + name, + subtitle, + count, + children, + dark, +}: { + id: string; + name: string; + subtitle?: string; + /** 각주 한 줄("총 8곡 · 확인 필요 2"). 없으면 각주를 그리지 않는다. */ + count?: string; + children: ReactNode; + dark?: boolean; +}) { + return ( +
+
+

+ {name} +

+ {subtitle &&

{subtitle}

} +
{children}
+ {count &&

{count}

} +
+
+ ); +} + +/** 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다. */ +export function Rail({children, label}: {children: ReactNode; label: string}) { + return ( +
+ {children} +
+ ); +} + +/** + * 출처 한 줄 + 확신 배지. + * + * ★ 확인되지 않은 값을 숨기지 않고 드러낸다 — 발행본에서도 같다. 손님이 그 문장을 + * 그대로 옮겨 적을 수 있어서, 어디까지가 확인된 것인지 화면이 말해야 한다. + * ★ 배지 색만은 토큰이 아니다. '확인/확인필요'는 디자인이 아니라 신호라, 팔레트를 따라가다 + * 경고가 안 보이는 색이 되면 안 된다. + */ +export function SourceLine({source, verified}: {source?: DataSource; verified?: DataVerified}) { + if (!source && !verified) return null; + return ( +

+ {verified && ( + + {verified} + + )} + {source?.name && ( + + 출처 ·{' '} + {source.url ? ( + + {source.name} + + ) : ( + source.name + )} + + )} +

+ ); +} + +/** "총 N개 · 확인 필요 M" 각주. 단위(곡·장·명)는 아이템마다 다르다. */ +export function tally(total: number, unverified: number, unit: string): string { + return `총 ${total}${unit}${unverified > 0 ? ` · 확인 필요 ${unverified}${unit}` : ''}`; +} diff --git a/solution/site/src/sections/items/index.ts b/solution/site/src/sections/items/index.ts new file mode 100644 index 0000000..81196a9 --- /dev/null +++ b/solution/site/src/sections/items/index.ts @@ -0,0 +1,29 @@ +/** + * 붙여넣기 아이템 섹션 — 섹션 id → 발행본 컴포넌트. + * + * ★ 이 표의 키는 `shared/lib/section-data.ts` 의 SECTION_ITEM_REQUIRED_KEY 와 같아야 한다. + * 한쪽에만 있으면 사장님이 켜 둔 섹션이 발행본에서 말없이 사라진다(HomePage 주석과 같은 함정). + */ +import {SongsSection} from './SongsSection'; +import {DailySection} from './DailySection'; +import {CourseSection} from './CourseSection'; +import {ScheduleSection} from './ScheduleSection'; +import {PeopleSection} from './PeopleSection'; +import {ChronicleSection} from './ChronicleSection'; +import {LiteratureSection} from './LiteratureSection'; +import {PostcardSection} from './PostcardSection'; +import {QuizSection} from './QuizSection'; +import {PlannerSection} from './PlannerSection'; + +export const ITEM_SECTIONS: Record React.ReactElement | null> = { + songs: SongsSection, + daily: DailySection, + course: CourseSection, + schedule: ScheduleSection, + people: PeopleSection, + chronicle: ChronicleSection, + literature: LiteratureSection, + postcard: PostcardSection, + quiz: QuizSection, + planner: PlannerSection, +}; diff --git a/solution/site/src/sections/items/items.css b/solution/site/src/sections/items/items.css new file mode 100644 index 0000000..f23f7e2 --- /dev/null +++ b/solution/site/src/sections/items/items.css @@ -0,0 +1,109 @@ +/** + * 붙여넣기 아이템의 질감 — Tailwind 로는 못 그리는 것만 여기 둔다. + * + * ★ 빌더 캔버스의 `canvas/variants/items/items.css` 와 **같은 내용**이다. 한쪽만 고치면 + * 사장님이 빌더에서 본 것과 발행본이 갈린다. + * ★ 색은 전부 템플릿 토큰(--tpl-*)에서 받는다. + */ + + +/* 종이 결 — 두 방향이 겹쳐야 종이로 읽힌다. 글자색을 옅게 깔아 어떤 팔레트에서도 결이 보인다. */ +.w4-paper { + background-image: + repeating-linear-gradient(0deg, color-mix(in oklab, currentcolor 4%, transparent) 0 1px, transparent 1px 3px), + repeating-linear-gradient(90deg, color-mix(in oklab, currentcolor 3%, transparent) 0 1px, transparent 1px 4px); +} + +/* 도넛판. --lbl 이 라벨 색이다. */ +.w4-disc { + /* 판의 검정은 템플릿의 어두운 면(--tpl-inverse)이다. 홈은 그 위에 밝기 차로만 판다. */ + background: + radial-gradient(circle at 50% 50%, transparent 0 15.5%, var(--lbl) 15.5% 33%, transparent 33%), + repeating-radial-gradient( + circle at 50% 50%, + color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.4px, + var(--w4-vinyl) 1.4px 3px + ), + var(--w4-vinyl); + box-shadow: inset 0 0 40px rgb(0 0 0 / 55%); +} +.w4-disc-sheen { + background: conic-gradient( + from 210deg, + rgb(255 255 255 / 12%), + transparent 22%, + transparent 70%, + rgb(255 255 255 / 7%) + ); +} +.w4-spin { + animation: w4-rev 2.2s linear infinite; +} +@keyframes w4-rev { + to { + transform: rotate(360deg); + } +} + +/* 미니 판 — 캐러셀에 늘어서는 작은 것. */ +.w4-disc-mini { + background: + radial-gradient(circle at 50% 50%, transparent 0 14%, var(--lbl) 14% 34%, transparent 34%), + repeating-radial-gradient( + circle at 50% 50%, + color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.2px, + var(--w4-vinyl) 1.2px 2.6px + ), + var(--w4-vinyl); + box-shadow: 0 3px 10px rgb(0 0 0 / 35%); +} + +/* 일력 톱니 — 뜯어낸 자국. --tear 는 섹션 바탕색이 들어온다(뜯긴 자리로 바탕이 비쳐야 한다). */ +.w4-perf { + background: repeating-linear-gradient(90deg, transparent 0 8px, var(--tear) 8px 9px); +} + +/* 승차권 절취선 */ +.w4-dash { + border-top: 1px dashed currentcolor; +} + +/* 필름 퍼포레이션 — 위아래 구멍이 있어야 한 장면이 아니라 '롤'로 읽힌다. + 구멍은 currentColor(어두운 면 위의 글자색)로 뚫어 팔레트를 따른다. */ +.w4-film-perf { + background: repeating-linear-gradient(90deg, currentcolor 0 9px, transparent 9px 21px); +} + +/* 시험지 뒤집기. 카드가 얇아 보이지 않게 두 면을 같은 자리에 겹쳐 둔다. */ +.w4-flip { + perspective: 900px; +} +.w4-flip-inner { + transform-style: preserve-3d; + transition: transform 0.5s; +} +.w4-flip-on .w4-flip-inner { + transform: rotateY(180deg); +} +.w4-flip-face { + backface-visibility: hidden; +} +.w4-flip-back { + transform: rotateY(180deg); +} + +.w4-scroll { + scrollbar-width: none; +} +.w4-scroll::-webkit-scrollbar { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .w4-spin { + animation: none; + } + .w4-flip-inner { + transition: none; + } +} diff --git a/solution/site/src/seo/head.ts b/solution/site/src/seo/head.ts index a5197e2..4cdfb5c 100644 --- a/solution/site/src/seo/head.ts +++ b/solution/site/src/seo/head.ts @@ -1,4 +1,4 @@ -import type {SitePayload} from '@o2o/shared'; +import {deriveSurfaces, type SitePayload} from '@o2o/shared'; import {collectJsonLd} from './jsonld'; import type {PageMeta} from './meta'; @@ -50,20 +50,72 @@ export interface HeadOptions { } /** 템플릿 색을 CSS 변수로. 사장님이 고른 색이 여기서 실제 페이지 색이 된다. */ +/** + * 템플릿 토큰을 에 심는다. + * + * ★ 색만 내려보내던 자리다. 그래서 사장님이 레트로(간판체·2px 테두리·갱지)를 골라도 + * 발행 페이지는 늘 같은 고딕으로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다. + * 이제 `theme.look` 의 서체·모서리·테두리·여백까지 같이 심는다. + * ★ 면 토큰(surface/-alt/inverse/border)은 캔버스와 **같은 식**으로 유도한다(shared/lib/color). + * 식이 두 벌이면 미리보기와 발행본의 바탕색이 갈린다. + */ export function themeStyle(payload: SitePayload): string { - const {colors} = payload.theme; - return [ - ' ', - ].join('\n'); + const {colors, look} = payload.theme; + const surfaces = deriveSurfaces(colors); + const lines = [ + `--tpl-primary: ${colors.primary};`, + `--tpl-secondary: ${colors.secondary};`, + `--tpl-bg: ${colors.bg};`, + `--tpl-card: ${colors.card};`, + `--tpl-text: ${colors.text};`, + `--tpl-accent: ${colors.accent};`, + `--tpl-surface: ${surfaces.surface};`, + `--tpl-surface-alt: ${surfaces.surfaceAlt};`, + `--tpl-inverse: ${surfaces.inverse};`, + `--tpl-border: ${surfaces.border};`, + ]; + if (look) { + // ★ 값은 그대로 CSS 에 들어간다. `<`·`}` 이 섞이면 '].join('\n'); +} + +/** + * 템플릿이 요구하는 웹폰트만 골라 한 번에 받아 온다. + * + * ★ 서체 스택 문자열에서 이름을 훑어 아는 것만 붙인다. 전부 항상 실으면 쓰지도 않는 서체가 + * 모든 발행 사이트의 첫 렌더를 늦춘다 — 서체 하나가 그럴 이유가 없다. + */ +const WEB_FONTS: [RegExp, string][] = [ + [/Noto Sans KR/i, 'family=Noto+Sans+KR:wght@300..900'], + [/Noto Serif KR/i, 'family=Noto+Serif+KR:wght@300..700'], + [/Gugi/i, 'family=Gugi'], + [/Gowun Batang/i, 'family=Gowun+Batang:wght@400;700'], + [/Nanum Pen Script/i, 'family=Nanum+Pen+Script'], +]; + +function fontHref(payload: SitePayload): string { + const stacks = [ + 'Noto Sans KR', + 'Noto Serif KR', + payload.theme.look?.fontHeading ?? '', + payload.theme.look?.fontBody ?? '', + ].join(' '); + const families = WEB_FONTS.filter(([pattern]) => pattern.test(stacks)).map(([, param]) => param); + return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`; } export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOptions): string { @@ -124,7 +176,7 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption lines.push( ' ', ' ', - ' ', + ` `, // 기계용 파일을 head 에서도 가리킨다 — llms.txt 는 아직 표준이 아니라 링크로 힌트를 준다. tag('link', {rel: 'sitemap', type: 'application/xml', href: `${base}/sitemap.xml`}), tag('link', {rel: 'alternate', type: 'text/plain', href: `${base}/llms.txt`, title: 'LLM 요약'}), From e1423418ae71481a73fbcc05f3866100a5d63ed8 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 21:38:43 +0900 Subject: [PATCH 04/10] =?UTF-8?q?[fix]=20solution/frontend:=20=EB=B9=84?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=83=81=ED=83=9C=EC=9D=98=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=E2=80=94=20=EB=88=8C?= =?UTF-8?q?=EB=9F=AC=EB=8F=84=20=EC=95=84=EB=AC=B4=20=EC=9D=BC=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20[=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83]=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위저드는 로그인 없이 열린다(관문은 에디터 진입이다). 그런데 사이드바는 로그인 여부와 무관하게 [로그아웃]만 그렸다 — 로그인한 적 없는 사람에게는 **이름이 빈 줄로 나오고, 버튼을 눌러도 지울 세션이 없어 아무 일도 일어나지 않았다.** "유저 정보가 어디에도 안 보인다"가 이것이다. - 비로그인: '로그인하지 않았습니다' + [로그인] 링크 - 로그인: 이름 · 상호 + [로그아웃]. 로그아웃은 스토어만 비우면 화면이 그대로라 눌러도 아무 일이 없는 것처럼 보인다 — /login 으로 보낸다 브라우저 확인: 비로그인 위저드에서 안내와 [로그인] 노출 → 로그인 후 '김사장 · 달빛스테이' → [로그아웃] 클릭 시 /login 이동. tsc·eslint·vite build 통과. --- .../src/components/layout/AppShell.tsx | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index d1d943c..61a1fe7 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,6 @@ import type {ComponentType, ReactNode} from 'react'; -import {Link, NavLink, useLocation} from 'react-router'; -import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react'; +import {Link, NavLink, useLocation, useNavigate} from 'react-router'; +import {LayoutGrid, LogIn, LogOut, Search, Wand2} from 'lucide-react'; import {cn} from '@/lib/utils'; import {userLabel, useAuthStore} from '@/stores/auth'; @@ -30,6 +30,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? const user = useAuthStore((s) => s.user); const signOut = useAuthStore((s) => s.signOut); const location = useLocation(); + const navigate = useNavigate(); return (
@@ -58,19 +59,43 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? ))} + {/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는 + **비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만 + 남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
- {user ? userLabel(user) : ''} - {user?.companyName ? ` · ${user.companyName}` : ''} + {user ? ( + <> + {userLabel(user)} + {user.companyName ? ` · ${user.companyName}` : ''} + + ) : ( + 로그인하지 않았습니다 + )}
- + {user ? ( + + ) : ( + + + 로그인 + + )}
From 65705c5bed43924702d2f69c8a5370070f1cc218 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 21:58:50 +0900 Subject: [PATCH 05/10] =?UTF-8?q?[feat]=20solution/shared,site,frontend:?= =?UTF-8?q?=20=EA=B3=84=EC=A0=88=EB=B3=84=20=EC=B6=94=EC=B2=9C=20=ED=95=98?= =?UTF-8?q?=EB=A3=A8=EB=8A=94=20=EC=A7=80=EA=B8=88=20=EA=B3=84=EC=A0=88?= =?UTF-8?q?=EB=A7=8C=20=E2=80=94=20=EA=B0=84=EC=A0=88=EA=B8=B0=EC=97=94=20?= =?UTF-8?q?=EB=91=90=20=EA=B3=84=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다. 12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다. - shared/currentSeasons: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다 — 9월 초에 여름만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다 - site/PlannerSection: HTML 에는 전 계절을 굽고 화면에서만 접는다(hidden). ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸려서, 계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법) ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다 지금 계절에 코스가 없으면 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다 - frontend/PlannerPodium: 탭은 그대로 두되 지금 계절로 열리고, '·지금' 표시와 "손님 화면에는 지금 계절만 나갑니다" 한 줄. 안 적으면 사장님은 손님도 넷을 다 본다고 오해한다 tsc·eslint 통과(frontend·site). 경계 12일자 확인(3/5 겨울·봄 · 9/2 여름·가을 · 9/16 가을). 실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘 여름·가을만 보이고 봄·겨울은 hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인. --- docs/DEVLOG.md | 25 +++++++++++ .../canvas/variants/planner/PlannerPodium.tsx | 44 ++++++++++++++----- solution/shared/src/lib/section-data.ts | 22 ++++++++++ .../src/sections/items/PlannerSection.tsx | 36 ++++++++++++--- 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 78bcc77..0a5e4db 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,31 @@ --- +## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절 + +**왜** +네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다. +12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다. + +**한 일** +- `shared/currentSeasons()` — 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. + **계절 첫 달의 전반(1~15일)은 간절기**로 보고 앞 계절과 함께 둘을 돌려준다. + 9월 초에 여름 코스만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다. +- 발행본은 **HTML 에 전 계절을 굽고 화면에서만 접는다**(`hidden`). 두 가지 이유다 — + ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸린다. + 그래서 계절 판정을 **브라우저에서** 한다(일력의 '오늘'과 같은 수법). + ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다. +- 지금 계절에 코스가 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 — + 빈 섹션보다 철 지난 코스가 낫다. +- 빌더는 탭을 그대로 두되 **지금 계절로 열리고**, 탭에 '·지금' 표시와 + "손님 화면에는 지금 계절만 나갑니다" 한 줄을 붙였다. 안 적으면 사장님은 손님도 네 계절을 + 다 본다고 오해한다. + +**검증** — `tsc·eslint` 통과(frontend·site). 경계 12일자 단위 확인 +(3/5→겨울·봄, 3/20→봄, 6/7→봄·여름, 9/2→여름·가을, 9/16→가을, 12/10→가을·겨울). +실물 payload(스테이,머뭄 `/s/stay`, 9코스 4계절)로 구워 **오늘(9/2) 여름·가을만 보이고 +봄·겨울은 `hidden`, HTML 에는 네 계절 전부** 있는 것을 브라우저에서 확인. + ## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거 **왜** diff --git a/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx b/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx index b4bfea8..38e139a 100644 --- a/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx @@ -5,12 +5,22 @@ * 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다. * 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다). * ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다. + * ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을 + * 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 — + * 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다. */ import {useMemo, useState} from 'react'; import {cn} from '@/lib/utils'; import {SectionBody, SectionFrame} from '../../primitives'; import type {SectionRenderProps} from '../../types'; -import {parseSectionData, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; +import { + currentSeasons, + parseSectionData, + planDay, + plannerSeasons, + plannerTop, + type PlannerItem, +} from '@o2o/shared'; import { ITEM_ACCENT, ITEM_BODY, @@ -126,10 +136,14 @@ export function PlannerPodium(props: SectionRenderProps) { const {section, isSelected, onSelect} = props; const parsed = parseSectionData(section.type, section.data); const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]); - const [picked, setPicked] = useState(0); + + // 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다. + const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]); + const [picked, setPicked] = useState(); + const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? '')); // 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다. - const season = seasons[picked] ?? seasons[0]; + const season = seasons[index] ?? seasons[0]; const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]); return ( @@ -154,31 +168,36 @@ export function PlannerPodium(props: SectionRenderProps) {
{seasons.length > 1 && (
- {seasons.map((name, index) => ( + {seasons.map((name, tabIndex) => ( ))}
)}
- {top.map((item, index) => ( - + {top.map((item, rank) => ( + ))}
@@ -186,6 +205,11 @@ export function PlannerPodium(props: SectionRenderProps) { 시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '} {top.length}개 / 전체 {parsed.items.length}개

+ {live.length > 0 && ( +

+ 손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다. +

+ )}
)} diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index a3f8f0f..be4c7ec 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -384,6 +384,28 @@ export function planDay(item: PlannerItem): PlannedDay { }; } +/** + * 지금 계절. **간절기에는 두 개**를 돌려준다. + * + * ★ 왜 둘인가 — 9월 초에 온 손님에게 여름 코스만 보이면 이미 지난 계절이고, 가을 코스만 + * 보이면 아직 이른 코스다. 경계에서는 둘 다 보여야 손님이 고를 수 있다. + * ★ 경계는 계절 첫 달의 전반(1~15일)로 잡는다. 실측이 아니라 규약이라 이 한 곳에만 둔다 — + * 빌더와 발행본이 같은 날 다른 계절을 고르면 사장님이 본 것과 손님이 보는 것이 갈린다. + * ★ 절대 빌드 시각으로 계산하지 않는다. 발행본은 정적이라 한 번 구우면 몇 달을 사는데, + * 구운 날의 계절을 박으면 12월에도 가을 코스가 걸린다(일력이 '오늘'을 다루는 방식과 같다). + */ +export function currentSeasons(now: Date = new Date()): string[] { + const month = now.getMonth() + 1; + // 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 3월을 0 으로 당겨 3으로 끊는다. + const index = Math.floor(((month - 3 + 12) % 12) / 3); + const season = SEASON_ORDER[index]; + const isFirstMonth = month % 3 === 0; + if (isFirstMonth && now.getDate() <= 15) { + return [SEASON_ORDER[(index + 3) % 4], season]; + } + return [season]; +} + /** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */ export function plannerSeasons(items: PlannerItem[]): string[] { const seen: string[] = []; diff --git a/solution/site/src/sections/items/PlannerSection.tsx b/solution/site/src/sections/items/PlannerSection.tsx index 5053206..3fef443 100644 --- a/solution/site/src/sections/items/PlannerSection.tsx +++ b/solution/site/src/sections/items/PlannerSection.tsx @@ -1,12 +1,18 @@ /** * 계절별 추천 하루 — 발행본. * - * ★ 캔버스는 계절 탭으로 하나씩 보여주지만, 발행본은 **계절 전부를 편다.** - * 탭 뒤에 숨은 계절은 HTML 에 없는 것과 같고, 이 사이트의 존재 이유가 인용이다. + * ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 12월에 온 손님에게 + * 봄·여름 코스까지 늘어놓으면 고를 것이 열두 개가 되고, 그건 추천이 아니라 목록이다. + * ★ 그런데 HTML 에는 **전 계절을 굽는다.** 두 가지 이유다 — + * ① 발행본은 정적이라 한 번 구우면 몇 달을 산다. 굽는 시점의 계절을 박으면 12월에도 + * 가을 코스가 걸린다. 그래서 계절 판정은 **브라우저에서** 한다(일력의 '오늘'과 같은 수법). + * ② 이 사이트의 존재 이유가 인용이다. HTML 에서 지우면 검색·AI 가 나머지 계절을 못 읽는다. + * → 안 맞는 계절은 `hidden` 으로 접어 둔다. 문서에는 있고 화면에는 없다. * ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다. * 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다. */ -import {planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; +import {useEffect, useState} from 'react'; +import {currentSeasons, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; import {useSite} from '@/lib/site-context'; import {sectionItems, sectionName} from '@/lib/derive'; import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common'; @@ -85,25 +91,45 @@ function PlanCard({item, rank}: {item: PlannerItem; rank: number}) { export function PlannerSection() { const payload = useSite(); const parsed = sectionItems(payload, 'planner'); + + // ★ 서버 렌더에서는 undefined 다 — SSR 과 첫 렌더가 어긋나면 하이드레이션이 깨진다. + // 브라우저에 올라온 뒤에야 계절이 정해지고, 그때 안 맞는 묶음이 접힌다. + const [now, setNow] = useState(); + useEffect(() => setNow(currentSeasons()), []); + if (parsed.items.length === 0) return null; const seasons = plannerSeasons(parsed.items); // 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다. const groups = seasons.length > 0 ? seasons : [undefined]; + // 지금 계절에 코스가 하나도 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 — + // 빈 섹션보다 철 지난 코스가 낫다. + const shown = now?.filter((season) => seasons.includes(season)) ?? []; + const filtering = shown.length > 0; + return (
{groups.map((season) => { const top = plannerTop(parsed.items, season); if (top.length === 0) return null; return ( -
+ - + )} From 479edf94034b6d25bd718f2c4beb901ba5f2b36c Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:37:53 +0900 Subject: [PATCH 07/10] =?UTF-8?q?[feat]=20solution/backend:=20=EB=82=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=AA=A9=EB=A1=9D=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=E2=80=94=20places=20LEF?= =?UTF-8?q?T=20JOIN=20sites=20=EB=8B=A8=EC=9D=BC=20=EC=A7=88=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서 (site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다. - site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다 - protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 — 보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다 - site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다 - GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다 테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed (기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1) --- solution/backend/crud/site_crud.py | 36 ++++++- solution/backend/router/router.py | 1 + solution/backend/router/v1/site/protocol.py | 39 +++++++- solution/backend/router/v1/site/site.py | 22 ++++- solution/backend/services/site_service.py | 42 +++++++- solution/backend/tests/test_my_sites.py | 90 +++++++++++++++++ .../frontend/src/api/generated/model/index.ts | 11 +++ .../api/generated/model/listMySitesParams.ts | 18 ++++ .../src/api/generated/model/mySiteData.ts | 36 +++++++ .../generated/model/mySiteDataCreatedAt.ts | 8 ++ .../api/generated/model/mySiteDataDomain.ts | 8 ++ .../generated/model/mySiteDataPublishedAt.ts | 8 ++ .../generated/model/mySiteDataRoadAddress.ts | 8 ++ .../api/generated/model/mySiteDataSiteId.ts | 8 ++ .../api/generated/model/mySiteDataStatus.ts | 9 ++ .../generated/model/mySiteDataTemplateId.ts | 8 ++ .../src/api/generated/model/reqSiteTheme.ts | 6 +- .../src/api/generated/model/resMySites.ts | 18 ++++ .../src/api/generated/model/resMySitesMsg.ts | 8 ++ .../frontend/src/api/generated/site/site.ts | 99 ++++++++++++++++++- 20 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 solution/backend/tests/test_my_sites.py create mode 100644 solution/frontend/src/api/generated/model/listMySitesParams.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteData.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataDomain.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataSiteId.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataStatus.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts create mode 100644 solution/frontend/src/api/generated/model/resMySites.ts create mode 100644 solution/frontend/src/api/generated/model/resMySitesMsg.ts diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index 926712c..9910803 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import publish_logs, site_versions, sites +from common.database.model.models import places, publish_logs, site_versions, sites from common.enums import BuildStatus, ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -21,6 +21,10 @@ class ISiteCRUD(ABC): async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]: pass + @abstractmethod + async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]: + pass + @abstractmethod async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: pass @@ -85,6 +89,36 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: + """회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). + + 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 + (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.""" + try: + where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712 + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where)) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + query = ( + select(places, sites, site_versions.built_at) + .outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712 + .outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id) + .where(where) + .order_by(places.created_at.desc()) + .offset(skip) + .limit(limit) + ) + list_err, rows = await DB_SESSION_MNG.execute(cdb, query) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" try: diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index 462f484..1b0aaaf 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -87,5 +87,6 @@ app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.media.media.router) app.include_router(router.v1.job.job.router) app.include_router(router.v1.site.site.router) +app.include_router(router.v1.site.site.my_router) app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.weather_router) diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 6574fb1..8687ea7 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -4,8 +4,17 @@ from typing import Any, Optional from pydantic import ConfigDict -from common.enums import BuildStatus, JobStatus, PublishAction, PublishRejectReason, PublishResult, SiteStatus -from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol +from common.enums import ( + BuildStatus, + JobStatus, + PlaceCategory, + PlaceStatus, + PublishAction, + PublishRejectReason, + PublishResult, + SiteStatus, +) +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol class SiteProtocol(WebPacketProtocol): @@ -56,6 +65,32 @@ class SiteData(WebPacketProtocol): published_at: Optional[datetime] = None +class MySiteData(WebPacketProtocol): + """내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). + + ★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유). + ★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다.""" + + place_id: uuid.UUID + name: str + category: PlaceCategory + place_status: PlaceStatus + road_address: Optional[str] = None + created_at: Optional[datetime] = None + + site_id: Optional[uuid.UUID] = None + status: Optional[SiteStatus] = None + domain: Optional[str] = None + template_id: Optional[str] = None + published_at: Optional[datetime] = None + # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. + needs_rebuild: bool = False + + +class Res_MySites(Res_PageProtocol): + sites: list[MySiteData] = [] + + class PublishLogData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index d34fd59..b9cb12f 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -2,7 +2,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query -from common.models.gmodel import UserInfo +from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.site_service import SiteService from .protocol import ( @@ -11,6 +11,7 @@ from .protocol import ( Req_SiteTemplate, Req_SiteTheme, Req_StartBuild, + Res_MySites, Res_PublishLogs, Res_Site, Res_SeoAudit, @@ -23,6 +24,25 @@ from .protocol import ( # 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다. router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}}) +# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어 +# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록). +my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}}) + + +@my_router.get( + path="/list", + response_model=Res_MySites, + summary="내 사이트 목록", + description="로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 " + "site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.", +) +async def list_my_sites( + service: SiteService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + pg: PageParams = Depends(), +): + return RemoveNoneResponse(await service.list_my_sites(user_info, pg)) + @router.get( path="", diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 5b1e89e..3103797 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -16,12 +16,13 @@ from common.enums import ( SiteStatus, ) from common.logger import LOG -from common.models.gmodel import UserInfo +from common.models.gmodel import PageParams, UserInfo from common.utils.gtime import GTime from crud.job_crud import JobQueue from crud.place_crud import PlaceCRUD from crud.site_crud import ISiteCRUD, SiteCRUD from router.v1.site.protocol import ( + MySiteData, PublishLogData, RenderStatusData, Req_SiteSlug, @@ -29,6 +30,7 @@ from router.v1.site.protocol import ( Req_SiteTemplate, Req_SiteTheme, Req_StartBuild, + Res_MySites, Res_PublishLogs, Res_Site, Res_SeoAudit, @@ -417,6 +419,44 @@ class SiteService: return _THEME_REJECTED return cleaned + async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites: + """로그인한 계정(회사)이 가진 사이트 전부. + + 사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라 + '발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다.""" + res = Res_MySites(page=pg.page, size=pg.size) + cid = uuid.UUID(user_info.company_id) + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + places.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows] + res.total = total + return res + + @staticmethod + def _my_site_row(place, site, built_at) -> MySiteData: + # ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다. + changed = place.content_updated_at + return MySiteData( + place_id=place.place_id, + name=place.name, + category=place.category, + place_status=place.status, + road_address=place.road_address or place.address, + created_at=place.created_at, + site_id=getattr(site, "site_id", None), + status=getattr(site, "status", None), + domain=getattr(site, "domain", None), + template_id=getattr(site, "template_id", None), + published_at=getattr(site, "published_at", None), + needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), + ) + async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site: res = Res_Site() err_type, place = await self._load_place(user_info, place_id) diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py new file mode 100644 index 0000000..3ad532b --- /dev/null +++ b/solution/backend/tests/test_my_sites.py @@ -0,0 +1,90 @@ +"""내 사이트 목록 — 로그인한 사장님이 자기 사이트 전부를 보는 화면의 뒷단. + +이 경로가 절대 하면 안 되는 것: + - 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면 + 사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다). + - 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. + - 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른 + 답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다. +""" +import uuid + +from sqlalchemy import text + +from common.enums import ErrorType, SiteStatus + + +async def _place(client, headers, name): + r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1}) + return r.json()["place"]["place_id"] + + +async def _list(client, headers, **params): + return (await client.get("/v1/site/list", headers=headers, params=params)).json() + + +async def test_place_without_site_is_still_listed(auth_headers, client): + """검증: 사이트 행이 없는 사업장(위저드만 걸어온 것)도 목록에 나온다. + 기대결과: 줄은 있고 site_id 는 없다 — 화면이 '만드는 중'으로 그릴 근거다.""" + h = await auth_headers("my1") + await _place(client, h, "아직펜션") + + body = await _list(client, h) + assert body["result"]["code"] == ErrorType.SUCCESS.value + assert body["total"] == 1 + row = body["sites"][0] + assert row["name"] == "아직펜션" + assert row.get("site_id") is None + assert row.get("status") is None + + +async def test_site_row_is_joined_into_the_line(auth_headers, client): + """검증: 사업장과 사이트가 한 줄로 합쳐져 온다(줄마다 사이트를 다시 묻지 않는다). + 기대결과: 템플릿·주소가 목록에 그대로 보인다.""" + h = await auth_headers("my2") + pid = await _place(client, h, "합쳐진펜션") + await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "stay-quiet-margin"}) + await client.post(f"/v1/place/{pid}/site/slug", headers=h, json={"slug": "joined-stay"}) + + row = (await _list(client, h))["sites"][0] + assert row["site_id"] + assert row["template_id"] == "stay-quiet-margin" + assert row["domain"] == "joined-stay" + assert row["status"] == SiteStatus.DRAFT.value + + +async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id): + """검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다. + 기대결과: 각자 자기 것만 1건.""" + mine = await auth_headers("my3") + theirs = await auth_headers("my3b", other_company_id) + await _place(client, mine, "내펜션") + await _place(client, theirs, "남의펜션") + + assert [r["name"] for r in (await _list(client, mine))["sites"]] == ["내펜션"] + assert [r["name"] for r in (await _list(client, theirs))["sites"]] == ["남의펜션"] + + +async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client, db_engine): + """검증: 재빌드 판정이 단건 조회와 같은 답을 낸다. + 기대결과: 노출값이 바뀐 사업장은 목록에서도 needs_rebuild=true.""" + h = await auth_headers("my4") + pid = await _place(client, h, "고친펜션") + # 템플릿 저장이 사이트 행을 만든다. 그 뒤 노출값이 바뀐 것으로 표시한다. + await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "t"}) + async with db_engine.begin() as conn: + await conn.execute( + text("UPDATE places SET content_updated_at = now() WHERE place_id = :pid"), + {"pid": uuid.UUID(pid)}, + ) + + single = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + row = (await _list(client, h))["sites"][0] + assert row["needs_rebuild"] is True + assert row["needs_rebuild"] == single["needs_rebuild"] + + +async def test_list_requires_login(client): + """검증: 내 것을 보는 화면이므로 토큰 없이는 열리지 않는다. + 기대결과: 401.""" + assert (await client.get("/v1/site/list")).status_code == 401 diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index 2851bae..87864fa 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -54,6 +54,7 @@ export * from './listFactsParams'; export * from './listFaqsParams'; export * from './listLinksParams'; export * from './listMediaParams'; +export * from './listMySitesParams'; export * from './listPlacesParams'; export * from './localContentData'; export * from './localContentDataBody'; @@ -77,6 +78,14 @@ export * from './mediaDataUnitId'; export * from './mediaDataVisionConfidence'; export * from './mediaDataWidth'; export * from './mediaStatus'; +export * from './mySiteData'; +export * from './mySiteDataCreatedAt'; +export * from './mySiteDataDomain'; +export * from './mySiteDataPublishedAt'; +export * from './mySiteDataRoadAddress'; +export * from './mySiteDataSiteId'; +export * from './mySiteDataStatus'; +export * from './mySiteDataTemplateId'; export * from './placeCandidate'; export * from './placeCandidateAddress'; export * from './placeCandidateCategoryName'; @@ -215,6 +224,8 @@ export * from './resMeMsg'; export * from './resMeName'; export * from './resMediaList'; export * from './resMediaListMsg'; +export * from './resMySites'; +export * from './resMySitesMsg'; export * from './resPlace'; export * from './resPlaceList'; export * from './resPlaceListMsg'; diff --git a/solution/frontend/src/api/generated/model/listMySitesParams.ts b/solution/frontend/src/api/generated/model/listMySitesParams.ts new file mode 100644 index 0000000..9e04322 --- /dev/null +++ b/solution/frontend/src/api/generated/model/listMySitesParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ListMySitesParams = { +/** + * @minimum 1 + */ +page?: number; +/** + * @minimum 1 + * @maximum 100 + */ +size?: number; +}; diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts new file mode 100644 index 0000000..cb463b1 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -0,0 +1,36 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { PlaceCategory } from './placeCategory'; +import type { PlaceStatus } from './placeStatus'; +import type { MySiteDataRoadAddress } from './mySiteDataRoadAddress'; +import type { MySiteDataCreatedAt } from './mySiteDataCreatedAt'; +import type { MySiteDataSiteId } from './mySiteDataSiteId'; +import type { MySiteDataStatus } from './mySiteDataStatus'; +import type { MySiteDataDomain } from './mySiteDataDomain'; +import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; +import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; + +/** + * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). + +★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유). +★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다. + */ +export interface MySiteData { + place_id: string; + name: string; + category: PlaceCategory; + place_status: PlaceStatus; + road_address?: MySiteDataRoadAddress; + created_at?: MySiteDataCreatedAt; + site_id?: MySiteDataSiteId; + status?: MySiteDataStatus; + domain?: MySiteDataDomain; + template_id?: MySiteDataTemplateId; + published_at?: MySiteDataPublishedAt; + needs_rebuild?: boolean; +} diff --git a/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts b/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts new file mode 100644 index 0000000..3ae245a --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type MySiteDataCreatedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataDomain.ts b/solution/frontend/src/api/generated/model/mySiteDataDomain.ts new file mode 100644 index 0000000..f9010e9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataDomain.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type MySiteDataDomain = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts b/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts new file mode 100644 index 0000000..1b2b7b5 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type MySiteDataPublishedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts b/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts new file mode 100644 index 0000000..2f5fd1a --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type MySiteDataRoadAddress = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts b/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts new file mode 100644 index 0000000..56035c0 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type MySiteDataSiteId = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataStatus.ts b/solution/frontend/src/api/generated/model/mySiteDataStatus.ts new file mode 100644 index 0000000..66b4a78 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataStatus.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { SiteStatus } from './siteStatus'; + +export type MySiteDataStatus = SiteStatus | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts b/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts new file mode 100644 index 0000000..8bd2686 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type MySiteDataTemplateId = string | null; diff --git a/solution/frontend/src/api/generated/model/reqSiteTheme.ts b/solution/frontend/src/api/generated/model/reqSiteTheme.ts index f6fa1ae..eff465b 100644 --- a/solution/frontend/src/api/generated/model/reqSiteTheme.ts +++ b/solution/frontend/src/api/generated/model/reqSiteTheme.ts @@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme'; 떨어뜨리므로 화면은 깨지지 않는다. ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: - {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} - sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 - (별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. + {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}} + sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 + (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다. pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — 서버는 배달부지 심판이 아니다. diff --git a/solution/frontend/src/api/generated/model/resMySites.ts b/solution/frontend/src/api/generated/model/resMySites.ts new file mode 100644 index 0000000..f8c5655 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resMySites.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResMySitesMsg } from './resMySitesMsg'; +import type { MySiteData } from './mySiteData'; + +export interface ResMySites { + result?: ErrorInfo; + msg?: ResMySitesMsg; + total?: number; + page?: number; + size?: number; + sites?: MySiteData[]; +} diff --git a/solution/frontend/src/api/generated/model/resMySitesMsg.ts b/solution/frontend/src/api/generated/model/resMySitesMsg.ts new file mode 100644 index 0000000..4cb7a4c --- /dev/null +++ b/solution/frontend/src/api/generated/model/resMySitesMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResMySitesMsg = string | null; diff --git a/solution/frontend/src/api/generated/site/site.ts b/solution/frontend/src/api/generated/site/site.ts index 9cdbd7c..c44b8ee 100644 --- a/solution/frontend/src/api/generated/site/site.ts +++ b/solution/frontend/src/api/generated/site/site.ts @@ -26,11 +26,13 @@ import type { import type { CheckSlugParams, HTTPValidationError, + ListMySitesParams, ReqSiteSlug, ReqSiteStatus, ReqSiteTemplate, ReqSiteTheme, ReqStartBuild, + ResMySites, ResPublishLogs, ResSeoAudit, ResSite, @@ -467,7 +469,7 @@ export const useSetTemplate = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/site/list`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getListMySitesQueryKey = (params?: ListMySitesParams,) => { + return [ + `/v1/site/list`, ...(params ? [params]: []) + ] as const; + } + + +export const getListMySitesQueryOptions = >, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listMySites(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListMySitesQueryResult = NonNullable>> +export type ListMySitesQueryError = void | HTTPValidationError + + +export function useListMySites>, TError = void | HTTPValidationError>( + params: undefined | ListMySitesParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 내 사이트 목록 + */ + +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListMySitesQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + From 282427e10b1e4f786bedaad8114576b17db864ac Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:43:10 +0900 Subject: [PATCH 08/10] =?UTF-8?q?[feat]=20solution/frontend:=20=EB=82=B4?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20=C2=B7=20=EB=82=B4=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9B=84?= =?UTF-8?q?=EC=97=90=20=EA=B0=88=20=EA=B3=B3=EC=9D=B4=20=EC=83=9D=EA=B2=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(admin)으로 나갔고 사장님 앱에는 그 경로가 없다. 아임웹도 같은 자리를 계정 레벨(내사이트 · 마이페이지)로 두고, 사이트 레벨(관리자 페이지)과 가른다 — 우리는 그 사이트 레벨이 에디터다. - pages/SitesPage: 줄을 누르면 에디터로 간다(목록에 온 용건은 열에 아홉 "내 사이트 고치기"). [사이트 열기] 는 PUBLISHED 일 때만 — 주소는 발행 전에 예약돼서, 주소만 보고 열면 404 다. ⋯ 메뉴에는 [발행 내리기] 하나. ★ 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 다시 OTA 가 가져가고 되돌릴 방법이 사장님에게 없다(sites.status 주석) - pages/AccountPage: PATCH /v1/auth/me 가 받는 것만 그린다. 구글 계정은 비밀번호 칸을 접는다 (서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다). 상호는 읽기 전용 — Req_UpdateMe 에 없다 - router: `/` 가 로그인 여부로 갈린다. 복구(isRestoring) 전에는 판단하지 않는다 — 아니면 새로고침마다 위저드가 번쩍이고 목록으로 튄다 - AppShell: 메뉴에 [내 사이트], 계정 이름 자리가 [내 정보] 입구 검증 — tsc·eslint·vite build 통과(frontend·admin) --- solution/frontend/src/app/router.tsx | 47 +++- .../src/components/layout/AppShell.tsx | 25 +- solution/frontend/src/pages/AccountPage.tsx | 144 ++++++++++++ solution/frontend/src/pages/SitesPage.tsx | 213 ++++++++++++++++++ 4 files changed, 417 insertions(+), 12 deletions(-) create mode 100644 solution/frontend/src/pages/AccountPage.tsx create mode 100644 solution/frontend/src/pages/SitesPage.tsx diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index 4208797..ccd6b79 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -1,20 +1,63 @@ import {createBrowserRouter, Navigate} from 'react-router'; +import {Loader2} from 'lucide-react'; +import {AccountPage} from '@/pages/AccountPage'; import {BuilderPage} from '@/pages/BuilderPage'; import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {LoginPage} from '@/pages/LoginPage'; import {NotFoundPage} from '@/pages/NotFoundPage'; import {SignupPage} from '@/pages/SignupPage'; +import {SitesPage} from '@/pages/SitesPage'; +import {RequireAuth} from '@/components/layout/RequireAuth'; +import {useAuthStore} from '@/stores/auth'; + +/** + * 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라 + * "내 것 고치기"다. 비로그인은 그대로 위저드로 보낸다(관문은 에디터 진입이다). + * + * ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 위저드가 한 번 번쩍이고 목록으로 튄다. + */ +function Home() { + const isRestoring = useAuthStore((s) => s.isRestoring); + const user = useAuthStore((s) => s.user); + + if (isRestoring) { + return ( +
+ +
+ ); + } + return ; +} export const router = createBrowserRouter([ {path: '/login', element: }, // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. {path: '/signup', element: }, - // ★ 첫 화면은 업종 선택(위저드 1단계)이다. + // ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다. // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), // 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는 // 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다. - {path: '/', element: }, + {path: '/', element: }, + + // 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다. + { + path: '/sites', + element: ( + + + + ), + }, + { + path: '/account', + element: ( + + + + ), + }, /** * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index 61a1fe7..55f4384 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,6 @@ import type {ComponentType, ReactNode} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router'; -import {LayoutGrid, LogIn, LogOut, Search, Wand2} from 'lucide-react'; +import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react'; import {cn} from '@/lib/utils'; import {userLabel, useAuthStore} from '@/stores/auth'; @@ -23,6 +23,7 @@ export type NavItem = { * 남의 화면이다. */ const OWNER_NAV: NavItem[] = [ + {to: '/sites', match: '/sites', label: '내 사이트', icon: Store}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, ]; @@ -63,16 +64,20 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? **비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만 남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
-
- {user ? ( - <> - {userLabel(user)} - {user.companyName ? ` · ${user.companyName}` : ''} - - ) : ( + {/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */} + {user ? ( + + {userLabel(user)} + {user.companyName ? ` · ${user.companyName}` : ''} + + ) : ( +
로그인하지 않았습니다 - )} -
+
+ )} {user ? ( + + )} + + + ); +} + +function Field({label, children}: {label: string; children: React.ReactNode}) { + return ( + + ); +} diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx new file mode 100644 index 0000000..f6f10e7 --- /dev/null +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -0,0 +1,213 @@ +import {useState} from 'react'; +import {Link, useNavigate} from 'react-router'; +import { + Building2, + Coffee, + ExternalLink, + Loader2, + MoreHorizontal, + Pencil, + Plus, + Stethoscope, + UtensilsCrossed, + Wand2, +} from 'lucide-react'; +import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; +import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api'; +import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {notify, notifyApiError} from '@/lib/notify'; + +// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. +const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; + +const CATEGORY_ICON: Record = { + [PlaceCategory.LODGING]: Building2, + [PlaceCategory.CAFE]: Coffee, + [PlaceCategory.RESTAURANT]: UtensilsCrossed, + [PlaceCategory.CLINIC]: Stethoscope, +}; + +/** + * 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는 + * 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다. + */ +function statusBadge(row: MySiteData) { + if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const}; + switch (row.status) { + case SiteStatus.PUBLISHED: + return row.needs_rebuild + ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} + : {label: '발행됨', variant: 'success' as const}; + case SiteStatus.SUSPENDED: + return {label: '중지', variant: 'outline' as const}; + case SiteStatus.UNPUBLISHED: + return {label: '내림', variant: 'outline' as const}; + default: + return {label: '발행 전', variant: 'default' as const}; + } +} + +/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */ +function publishedUrl(row: MySiteData): string | null { + if (row.status !== SiteStatus.PUBLISHED || !row.domain) return null; + return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST); +} + +/** + * 내 사이트 — 로그인한 사장님의 홈이다. + * + * 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다. + * ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다. + */ +export function SitesPage() { + const navigate = useNavigate(); + const {data, isLoading, isError, error, refetch} = useListMySites({size: 50}); + const [busyId, setBusyId] = useState(null); + const [menuId, setMenuId] = useState(null); + + const rows = data?.sites ?? []; + + // 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면 + // 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석). + const handleUnpublish = async (row: MySiteData) => { + if (!window.confirm(`'${row.name}' 사이트를 검색에서 내릴까요?\n주소는 그대로 두고 페이지만 내려갑니다.`)) return; + setMenuId(null); + setBusyId(row.place_id); + try { + const res = await changeStatus(row.place_id, {action: PublishAction.UNPUBLISH}); + if (!res.result?.success) { + notifyApiError({data: res}, '사이트를 내리지 못했습니다.'); + return; + } + notify.success('사이트를 내렸습니다.'); + await refetch(); + } catch (unpublishError) { + notifyApiError(unpublishError, '사이트를 내리지 못했습니다.'); + } finally { + setBusyId(null); + } + }; + + return ( + + navigate('/builder?new=1')}> + 새 사이트 + + } + > + {isLoading && ( +
+ +
+ )} + + {isError && ( + refetch()}> + 다시 시도 + + } + /> + )} + + {!isLoading && !isError && rows.length === 0 && ( + navigate('/builder?new=1')}> + 첫 사이트 만들기 + + } + /> + )} + + {rows.length > 0 && ( +
    + {rows.map((row) => { + const Icon = CATEGORY_ICON[row.category] ?? Building2; + const badge = statusBadge(row); + const url = publishedUrl(row); + const editHref = `/builder?placeId=${row.place_id}`; + + return ( +
  • + + + +
    + {row.name} + {badge.label} +
    +

    + {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} +

    + + +
    + {url && ( + + + 사이트 열기 + + )} + + +
    + + {menuId === row.place_id && ( + <> + {/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */} + +
+ + )} + + ); + })} + + )} + + + ); +} From b07ade25b2c64e2fedc405f33bb7b8e665611070 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:43:38 +0900 Subject: [PATCH 09/10] =?UTF-8?q?[fix]=20solution/frontend,docs:=20?= =?UTF-8?q?=EC=98=A8=EB=B3=B4=EB=94=A9=20=EC=9C=84=EC=A0=80=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EB=90=98=EA=B8=B0=20=EC=A0=84=EC=97=94=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=A9=94=EB=89=B4=EA=B0=80=20=EC=97=86?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 그걸 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 아임웹도 사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다. - BuilderPage: 위저드를 AppShell 대신 얇은 상단 바(로고 + 나가는 길)로. 진행은 WizardSteps 가 이미 보여준다. 비로그인은 돌아갈 목록이 없어 그 자리에 [로그인] 을 둔다 - BuilderPage: 에디터 헤더에 [← 내 사이트] — "내 사이트 관리가 생기면 그때 잇는다"고 비워 뒀던 자리다 - DEVLOG: 계정 레벨/사이트 레벨을 가른 근거 검증 — tsc·eslint·vite build 통과. 위저드에 사이드바가 사라진 것은 브라우저에서 확인 --- docs/DEVLOG.md | 31 +++++++++++++ solution/frontend/src/pages/BuilderPage.tsx | 49 ++++++++++++++++----- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index a550884..7672405 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,37 @@ --- +## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거 + +**왜** +로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로 +나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이 +`/builder?placeId=` 를 기억하는 것이었다. + +아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과 +**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로 +비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지 +않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다. + +**한 일** +- `GET /v1/site/list` — places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사업장 목록으로 + 그리면 줄마다 사이트를 다시 물어 N+1 이다. 사이트가 아직 없는 사업장도 내려간다 — + 빠지면 위저드를 걸어오다 만 가게를 다시 찾을 길이 없다. + `render`(정적 파일이 실제로 있는지)는 넣지 않았다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 + 파일 IO 가 된다. 단건(`Res_Site`)이 계속 소유한다. +- `/sites` 내 사이트 · `/account` 내 정보. `/` 는 로그인 여부로 갈린다(비로그인은 그대로 위저드). +- ⋯ 메뉴는 **[발행 내리기] 하나**다. 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 + 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다. +- 위저드에서 `AppShell`(사이드바)을 걷어내고 얇은 상단 바로 바꿨다. 사이드바는 계정 메뉴라, + 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 진행은 `WizardSteps` 가 + 이미 보여주므로 거기 필요한 건 로고와 나가는 길 하나다. +- 에디터 헤더에 [← 내 사이트]. `BuilderPage` 가 "내 사이트 관리가 생기면 그때 잇는다"고 + 비워 뒀던 자리다. + +**검증** — 백엔드 테스트 5건 추가(사이트 없는 사업장 · 조인 · 회사 격리 · 재빌드 판정이 단건과 +일치 · 비로그인 401), 539 passed. `tsc·eslint·vite build` 통과(frontend·admin). +위저드에 사이드바가 사라진 것은 브라우저에서 확인. + ## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절 **왜** diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 94c8551..621b04b 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -3,7 +3,6 @@ import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-re import {Link, useSearchParams} from 'react-router'; import {SiteStatus} from '@o2o/shared'; import {getAccessToken} from '@/api'; -import {AppShell} from '@/components/layout/AppShell'; import {EditorSignInGate} from '@/features/auth/EditorSignInGate'; import { Step1Industry, @@ -146,10 +145,14 @@ export function BuilderPage() { 실사업장 · {storeName} - {/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로 - 나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를 - 새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다. - 사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */} + {/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */} + + + 내 사이트 + ) : ( @@ -212,18 +215,44 @@ export function BuilderPage() { ); } - // 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다. - // 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다. + /** + * 위저드는 **사이드바를 쓰지 않는다.** + * + * ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를 + * 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. + * 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다. + */ return ( - -
+
+
+ Web4Ai + {/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */} + {isSignedIn ? ( + + + 내 사이트 + + ) : ( + + 로그인 + + )} +
+ +
{step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && } {step === 5 && }
- +
); } From e2d15955b086b942d0aadb14d7c7f28285e9c5c2 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:56:22 +0900 Subject: [PATCH 10/10] =?UTF-8?q?[fix]=20solution/frontend,deploy:=20:80?= =?UTF-8?q?=20=EC=97=90=EC=84=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=B4=20?= =?UTF-8?q?CORS=20=EB=A1=9C=20=EB=A7=89=ED=9E=88=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?=E2=80=94=20API=20=EB=A5=BC=20=EA=B0=99=EC=9D=80=20=EC=98=A4?= =?UTF-8?q?=EB=A6=AC=EC=A7=84=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면은 http://localhost(:80) 인데 번들이 http://localhost:9800 을 직접 불렀다. 백엔드 허용 오리진 기본값은 :3000~3005 뿐이라 브라우저가 막았고, 화면에는 '로그인에 실패했습니다' (네트워크 예외 문구)만 떴다 — 아이디·비번 문제로 보인다. nginx 가 이미 /v1 을 프록시한다(site.conf). 그쪽으로 부르면 CORS 를 아예 안 탄다. - .env: PUBLIC_API_BASE_URL=http://localhost — 번들이 같은 오리진을 보게 한다 - LoginPage: 개발 편의로 admin/1234 기본값. ★ 운영 전에 빈 문자열로 되돌릴 것 브라우저 확인(localhost:80): 로그인 → /builder, 사이드바 '관리자 · 데모대행사'. --- solution/frontend/src/pages/LoginPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index ed04e24..b4c69ce 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -22,8 +22,9 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) { const location = useLocation(); const user = useAuthStore((s) => s.user); - const [id, setId] = useState(''); - const [password, setPassword] = useState(''); + // 개발 편의로 기본값을 채워 둔다. 운영에 열기 전에 반드시 빈 문자열로 되돌린다. + const [id, setId] = useState('admin'); + const [password, setPassword] = useState('1234'); const [isSubmitting, setIsSubmitting] = useState(false); // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)