Merge branch 'main' into 홈페이지-레이아웃-구상

# Conflicts:
#	docs/DEVLOG.md
#	solution/backend/crud/site_crud.py
#	solution/backend/router/router.py
#	solution/backend/router/v1/site/protocol.py
This commit is contained in:
Mina Choi 2026-09-03 09:44:51 +09:00
commit 41e49c693b
77 changed files with 4828 additions and 658 deletions

View File

@ -144,8 +144,11 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다. `UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.** 라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다. ★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다"). 2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다. ("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도
다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고
role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 / → 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
내부: 실패가 곧 차단). 내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
@ -184,7 +187,9 @@ OWNER role=2 → 403
``` ```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
@ -229,8 +234,11 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것 ### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 — - 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 로그인 후 도착지가 아직
그때 `solution/frontend` 의 인증 정책을 다시 본다. `/builder?new=1`(새로 만들기) 하나뿐이다.
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.** `127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로 - **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로

View File

@ -64,6 +64,181 @@
유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다. 유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다.
**검증** — `pytest` 전체 552 passed. **검증** — `pytest` 전체 552 passed.
## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거
**왜**
로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로
나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이
`/builder?placeId=<uuid>` 를 기억하는 것이었다.
아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과
**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로
비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지
않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다.
**한 일**
- `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 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절
**왜**
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
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 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거
**왜**
아이템 열 개가 전부 갱지색·주(朱)잉크·간판체를 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): `<head>` 에 `--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)는 **아이템을 실제로 쓰는 사이트에만** `<head>` 로 내려보낸다. 서체 하나가
모든 발행 사이트의 첫 렌더를 늦출 이유가 없다.
**안 한 것**
레트로 템플릿 시드(`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 — 회원가입과 구글 로그인
**한 일**
- `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과
가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL
허용(소셜 계정), `id` 는 20 → 64자(`google_<sub>` 가 20자를 넘는다).
- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가
들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.**
**왜 가입부터 만들었나**
계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
**로그인 관문은 에디터 진입 그대로다**
한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이
`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다.
관문은 `EditorSignInGate`(969fb67) 한 자리다.
**밟기 쉬운 자리**
- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
- **같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.** 이으면 계정 선점이
된다 → [DECISIONS.md 1-5](DECISIONS.md)
- 소셜 계정은 `password` 가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이
None 을 만나 500 이 난다.
- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라,
기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다).
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다.
**아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다.
버튼 렌더까지는 확인했다(빌려온 client_id 로).
**검증** — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과.
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍

View File

@ -359,8 +359,6 @@ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE 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 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; CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE;
@ -442,3 +440,7 @@ ALTER TABLE company.users
ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; 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 id TYPE VARCHAR(64);
ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; 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;

View File

@ -196,6 +196,15 @@ def fake_renderer(monkeypatch, tmp_path_factory):
# 고유 콘텐츠 계수 규칙은 렌더러(prerender.ts countUniqueContent)와 같아야 한다. # 고유 콘텐츠 계수 규칙은 렌더러(prerender.ts countUniqueContent)와 같아야 한다.
MIN_UNIQUE_TEXT = 8 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 _count(payload: dict) -> int:
def long(value) -> bool: def long(value) -> bool:
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT 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")): if intro and intro.get("enabled") and long(intro.get("body")):
count += 1 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 []) facts = list(payload.get("facts") or [])
for unit in payload.get("units") or []: for unit in payload.get("units") or []:
facts.extend(unit.get("facts") or []) facts.extend(unit.get("facts") or [])

View File

@ -21,6 +21,10 @@ class ISiteCRUD(ABC):
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]: async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
pass pass
@abstractmethod
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod @abstractmethod
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
pass pass
@ -89,6 +93,36 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None 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]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
try: try:

View File

@ -88,6 +88,7 @@ app.include_router(router.v1.faq.faq.router)
app.include_router(router.v1.media.media.router) app.include_router(router.v1.media.media.router)
app.include_router(router.v1.job.job.router) app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.router) app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router)
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다. # ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
app.include_router(router.v1.site.showcase.router) app.include_router(router.v1.site.showcase.router)
app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.router)

View File

@ -8,12 +8,13 @@ from common.enums import (
BuildStatus, BuildStatus,
JobStatus, JobStatus,
PlaceCategory, PlaceCategory,
PlaceStatus,
PublishAction, PublishAction,
PublishRejectReason, PublishRejectReason,
PublishResult, PublishResult,
SiteStatus, SiteStatus,
) )
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class SiteProtocol(WebPacketProtocol): class SiteProtocol(WebPacketProtocol):
@ -66,6 +67,32 @@ class SiteData(WebPacketProtocol):
thumbnail_url: Optional[str] = None thumbnail_url: Optional[str] = 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): class PublishLogData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -97,9 +124,9 @@ class Req_SiteTheme(SiteProtocol):
떨어뜨리므로 화면은 깨지지 않는다. 떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다. (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다. 서버는 배달부지 심판이 아니다.

View File

@ -2,7 +2,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query 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 router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.site_service import SiteService from services.site_service import SiteService
from .protocol import ( from .protocol import (
@ -11,6 +11,7 @@ from .protocol import (
Req_SiteTemplate, Req_SiteTemplate,
Req_SiteTheme, Req_SiteTheme,
Req_StartBuild, Req_StartBuild,
Res_MySites,
Res_PublishLogs, Res_PublishLogs,
Res_Site, Res_Site,
Res_SeoAudit, Res_SeoAudit,
@ -23,6 +24,25 @@ from .protocol import (
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다. # 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}}) 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( @router.get(
path="", path="",
@ -105,8 +125,8 @@ async def set_template(
summary="디자인(색·서체·섹션) 저장", summary="디자인(색·서체·섹션) 저장",
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다" description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: " "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\"," "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\","
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. " "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. "
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). " "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. " "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
"직렬화 크기(64KB)만 막는다. " "직렬화 크기(64KB)만 막는다. "

View File

@ -295,7 +295,7 @@ def _theme(site, theme_spec: dict) -> dict:
# ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 — # ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 —
# 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다. # 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다.
return { out = {
# 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다. # 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다.
# 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다). # 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다).
"templateId": _text(_get(site, "template_id")) or theme_spec["templateId"], "templateId": _text(_get(site, "template_id")) or theme_spec["templateId"],
@ -303,6 +303,13 @@ def _theme(site, theme_spec: dict) -> dict:
"fontStyle": font_style, "fontStyle": font_style,
"sections": _sections(saved.get("sections"), theme_spec["sections"]), "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: def _sections(saved_sections, default_spec) -> list:
@ -372,6 +379,12 @@ def _sections(saved_sections, default_spec) -> list:
body = _text(item.get("body")) body = _text(item.get("body"))
if body: if body:
entry["body"] = 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) out.append(entry)
for sid, label, locked in default_spec: for sid, label, locked in default_spec:

View File

@ -16,12 +16,13 @@ from common.enums import (
SiteStatus, SiteStatus,
) )
from common.logger import LOG 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 common.utils.gtime import GTime
from crud.job_crud import JobQueue from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.site_crud import ISiteCRUD, SiteCRUD from crud.site_crud import ISiteCRUD, SiteCRUD
from router.v1.site.protocol import ( from router.v1.site.protocol import (
MySiteData,
PublishLogData, PublishLogData,
RenderStatusData, RenderStatusData,
Req_SiteSlug, Req_SiteSlug,
@ -29,6 +30,7 @@ from router.v1.site.protocol import (
Req_SiteTemplate, Req_SiteTemplate,
Req_SiteTheme, Req_SiteTheme,
Req_StartBuild, Req_StartBuild,
Res_MySites,
Res_PublishLogs, Res_PublishLogs,
Res_Site, Res_Site,
Res_SeoAudit, Res_SeoAudit,
@ -417,6 +419,44 @@ class SiteService:
return _THEME_REJECTED return _THEME_REJECTED
return cleaned 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: async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site:
res = Res_Site() res = Res_Site()
err_type, place = await self._load_place(user_info, place_id) err_type, place = await self._load_place(user_info, place_id)

View File

@ -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

View File

@ -54,6 +54,7 @@ export * from './listFactsParams';
export * from './listFaqsParams'; export * from './listFaqsParams';
export * from './listLinksParams'; export * from './listLinksParams';
export * from './listMediaParams'; export * from './listMediaParams';
export * from './listMySitesParams';
export * from './listPlacesParams'; export * from './listPlacesParams';
export * from './localContentData'; export * from './localContentData';
export * from './localContentDataBody'; export * from './localContentDataBody';
@ -77,6 +78,14 @@ export * from './mediaDataUnitId';
export * from './mediaDataVisionConfidence'; export * from './mediaDataVisionConfidence';
export * from './mediaDataWidth'; export * from './mediaDataWidth';
export * from './mediaStatus'; 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 './placeCandidate';
export * from './placeCandidateAddress'; export * from './placeCandidateAddress';
export * from './placeCandidateCategoryName'; export * from './placeCandidateCategoryName';
@ -215,6 +224,8 @@ export * from './resMeMsg';
export * from './resMeName'; export * from './resMeName';
export * from './resMediaList'; export * from './resMediaList';
export * from './resMediaListMsg'; export * from './resMediaListMsg';
export * from './resMySites';
export * from './resMySitesMsg';
export * from './resPlace'; export * from './resPlace';
export * from './resPlaceList'; export * from './resPlaceList';
export * from './resPlaceListMsg'; export * from './resPlaceListMsg';

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme';
떨어뜨리므로 화면은 깨지지 않는다. 떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다. 서버는 배달부지 심판이 아니다.

View File

@ -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[];
}

View File

@ -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;

View File

@ -26,11 +26,13 @@ import type {
import type { import type {
CheckSlugParams, CheckSlugParams,
HTTPValidationError, HTTPValidationError,
ListMySitesParams,
ReqSiteSlug, ReqSiteSlug,
ReqSiteStatus, ReqSiteStatus,
ReqSiteTemplate, ReqSiteTemplate,
ReqSiteTheme, ReqSiteTheme,
ReqStartBuild, ReqStartBuild,
ResMySites,
ResPublishLogs, ResPublishLogs,
ResSeoAudit, ResSeoAudit,
ResSite, ResSite,
@ -467,7 +469,7 @@ export const useSetTemplate = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/** /**
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). * 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId","body","data"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).
* @summary 디자인(색·서체·섹션) 저장 * @summary 디자인(색·서체·섹션) 저장
*/ */
export const setTheme = ( export const setTheme = (
@ -849,4 +851,97 @@ export const useChangeStatus = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/**
* 로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.
* @summary 내 사이트 목록
*/
export const listMySites = (
params?: ListMySitesParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResMySites>(
{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 = <TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMySites>>> = ({ signal }) => listMySites(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListMySitesQueryResult = NonNullable<Awaited<ReturnType<typeof listMySites>>>
export type ListMySitesQueryError = void | HTTPValidationError
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params: undefined | ListMySitesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 내 사이트 목록
*/
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListMySitesQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -1,17 +1,63 @@
import {createBrowserRouter, Navigate} from 'react-router'; import {createBrowserRouter, Navigate} from 'react-router';
import {Loader2} from 'lucide-react';
import {AccountPage} from '@/pages/AccountPage';
import {BuilderPage} from '@/pages/BuilderPage'; import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LoginPage} from '@/pages/LoginPage'; import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage'; 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 (
<div className="flex h-screen items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
return <Navigate to={user ? '/sites' : '/builder?new=1'} replace />;
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />}, {path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다. // ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
// 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는 // 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는
// 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다. // 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다.
{path: '/', element: <Navigate to="/builder?new=1" replace />}, {path: '/', element: <Home />},
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
{
path: '/sites',
element: (
<RequireAuth>
<SitesPage />
</RequireAuth>
),
},
{
path: '/account',
element: (
<RequireAuth>
<AccountPage />
</RequireAuth>
),
},
/** /**
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면

View File

@ -11,7 +11,7 @@ import {GOOGLE_CLIENT_ID, isGoogleLoginEnabled, loadGoogleIdentity} from '@/lib/
*/ */
export function GoogleSignInButton({ export function GoogleSignInButton({
onCredential, onCredential,
text = 'continue_with', text = 'signin_with',
}: { }: {
onCredential: (credential: string) => void; onCredential: (credential: string) => void;
text?: 'continue_with' | 'signin_with' | 'signup_with'; text?: 'continue_with' | 'signin_with' | 'signup_with';
@ -46,7 +46,9 @@ export function GoogleSignInButton({
size: 'large', size: 'large',
shape: 'rectangular', shape: 'rectangular',
text, text,
locale: 'ko', // ★ 'ko' 로는 영문('Continue with Google')이 그대로 나왔다. 지역까지 줘야 한국어다.
// 문구는 우리가 못 정한다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다.
locale: 'ko_KR',
logo_alignment: 'center', logo_alignment: 'center',
// GIS 는 숫자 px 만 받는다(최대 400). 컨테이너 폭이 잡히기 전이면 최소값으로 그린다. // GIS 는 숫자 px 만 받는다(최대 400). 컨테이너 폭이 잡히기 전이면 최소값으로 그린다.
width: holder.current.offsetWidth || 320, width: holder.current.offsetWidth || 320,

View File

@ -1,8 +1,8 @@
import type {ComponentType, ReactNode} from 'react'; import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation} from 'react-router'; import {Link, NavLink, useLocation, useNavigate} from 'react-router';
import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react'; import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
export type NavItem = { export type NavItem = {
to: string; to: string;
@ -23,6 +23,7 @@ export type NavItem = {
* 남의 화면이다. * 남의 화면이다.
*/ */
const OWNER_NAV: NavItem[] = [ const OWNER_NAV: NavItem[] = [
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
]; ];
@ -30,6 +31,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
@ -58,19 +60,47 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
))} ))}
</nav> </nav>
{/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<div className="mb-2 truncate text-[11px] text-sidebar-foreground"> {/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
{user?.name ?? user?.id} {user ? (
{user?.companyName ? ` · ${user.companyName}` : ''} <Link
</div> to="/account"
<button className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
type="button" >
onClick={signOut} {userLabel(user)}
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" {user.companyName ? ` · ${user.companyName}` : ''}
> </Link>
<LogOut className="size-3.5" /> ) : (
<span>로그아웃</span> <div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
</button> <span className="opacity-60">로그인하지 않았습니다</span>
</div>
)}
{user ? (
<button
type="button"
// 로그아웃은 **눈에 보이는 결과**가 있어야 한다. 스토어만 비우면 화면은 그대로라
// 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다.
onClick={() => {
signOut();
navigate('/login');
}}
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogOut className="size-3.5" />
<span>로그아웃</span>
</button>
) : (
<Link
to="/login"
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogIn className="size-3.5" />
<span>로그인</span>
</Link>
)}
</div> </div>
</aside> </aside>

View File

@ -6,11 +6,13 @@
*/ */
import {useState, type FormEvent, type ReactNode} from 'react'; import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react'; import {LogIn} from 'lucide-react';
import {login, me, UserRole} from '@/api'; import {googleLogin, login} from '@/api';
import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify'; import {notifyApiError} from '@/lib/notify';
import {toAuthUser, useAuthStore} from '@/stores/auth'; import {isGoogleLoginEnabled} from '@/lib/googleIdentity';
import {establishSession} from '@/lib/session';
interface SignInFormProps { interface SignInFormProps {
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */ /** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
@ -22,11 +24,31 @@ interface SignInFormProps {
} }
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) { export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
const signIn = useAuthStore((s) => s.signIn);
const [id, setId] = useState(''); const [id, setId] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// 구글은 가입과 로그인이 같은 동작이다 — 처음 온 계정이면 백엔드가 그 자리에서 만든다.
const handleGoogle = async (credential: string) => {
setIsSubmitting(true);
try {
const res = await googleLogin({credential});
if (res.result?.success === false) {
notifyApiError({data: res}, '구글 로그인에 실패했습니다.');
return;
}
if (!(await establishSession(res, ''))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
onSignedIn?.();
} catch (error) {
notifyApiError(error, '구글 로그인에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
@ -36,20 +58,12 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
return; return;
} }
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. // 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. // 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다.
if (!res.access_token || !res.refresh_token) { if (!(await establishSession(res, id))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return; return;
} }
const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token};
// 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다.
signIn(tokens, {userId: '', id, role: UserRole.USER});
const meRes = await me();
// 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다.
if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes));
onSignedIn?.(); onSignedIn?.();
} catch (error) { } catch (error) {
notifyApiError(error, '로그인에 실패했습니다.'); notifyApiError(error, '로그인에 실패했습니다.');
@ -98,6 +112,19 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
<span>{submitLabel}</span> <span>{submitLabel}</span>
</Button> </Button>
{/* ★ 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다.
구글 버튼을 LoginPage 에만 붙여 두면 정작 사장님이 만나는 자리엔 없다. */}
{isGoogleLoginEnabled() && (
<>
<div className="flex items-center gap-2">
<span className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" />
</div>
<GoogleSignInButton onCredential={handleGoogle} text="signin_with" />
</>
)}
{footer} {footer}
</form> </form>
); );

View File

@ -16,7 +16,7 @@ import {
Wand2, Wand2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import type {InfoField} from '@o2o/shared'; import {parseSectionData, type InfoField} from '@o2o/shared';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
@ -30,7 +30,6 @@ import {resolveVariant} from './canvas/registry';
import { import {
buildPrompt, buildPrompt,
dataSpecFor, dataSpecFor,
parseSectionData,
SECTION_DATA_MAX_CHARS, SECTION_DATA_MAX_CHARS,
} from './canvas/dataSpec'; } from './canvas/dataSpec';

View File

@ -1,86 +1,17 @@
/** /**
* 붙여넣기 아이템 계약 — "이 섹션 타입은 어떤 JSON 을 먹나"의 단일 출처. * 붙여넣기 아이템의 **쓰는 쪽** — "이 섹션 타입의 JSON 을 어떻게 받아 오나".
*
* 프롬프트·예시·라벨이 여기 있고, **읽는 쪽 계약**(항목 타입 · 파서)은 `@o2o/shared` 에 있다 —
* 같은 JSON 을 빌더 캔버스와 발행 사이트가 함께 읽기 때문이다(`shared/lib/section-data.ts`).
* *
* 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. * 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다.
* 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다. * 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 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 { export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string; kind: string;
label: string; label: string;
/** 없으면 그 줄을 통째로 버리는 키. 빈 껍데기가 화면에 줄만 남기는 걸 막는다. */
requiredKey: string;
/** [예시 넣기] 가 그대로 넣는 값. */ /** [예시 넣기] 가 그대로 넣는 값. */
sample: string; sample: string;
/** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */ /** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */
@ -153,7 +84,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
songs: { songs: {
kind: 'songs', kind: 'songs',
label: '가요 다방', label: '가요 다방',
requiredKey: 'title',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'songs', kind: 'songs',
@ -217,7 +147,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
daily: { daily: {
kind: 'daily', kind: 'daily',
label: '오늘의 한 장', label: '오늘의 한 장',
requiredKey: 'title',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'daily', kind: 'daily',
@ -279,7 +208,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
course: { course: {
kind: 'course', kind: 'course',
label: '반나절 산책', label: '반나절 산책',
requiredKey: 'name',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'course', kind: 'course',
@ -326,7 +254,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
schedule: { schedule: {
kind: 'schedule', kind: 'schedule',
label: '여행 스케줄', label: '여행 스케줄',
requiredKey: 'name',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'schedule', kind: 'schedule',
@ -372,118 +299,396 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다. · 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`, · 예약이 필요한 곳은 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 { export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {
return SECTION_DATA_SPEC[sectionType]; return SECTION_DATA_SPEC[sectionType];
} }
export interface ParsedSectionData<T> {
items: T[];
title?: string;
subtitle?: string;
/** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */
error?: string;
/** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */
kindMismatch?: string;
/** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */
unverified: number;
/** source 가 붙은 항목 수. */
sourced: number;
}
const EMPTY: ParsedSectionData<never> = {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<T extends object>(
sectionType: string,
raw: string | undefined,
): ParsedSectionData<T> {
const spec = SECTION_DATA_SPEC[sectionType];
const text = (raw ?? '').trim();
if (!spec || !text) return EMPTY as ParsedSectionData<T>;
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<string, unknown>;
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<string, unknown>)[spec.requiredKey] === 'string' &&
((item as Record<string, unknown>)[spec.requiredKey] as string).trim().length > 0,
);
let unverified = 0;
let sourced = 0;
for (const item of items) {
const row = item as Record<string, unknown>;
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,
};
}

View File

@ -68,6 +68,12 @@ import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar'; import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets'; import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable'; 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<string, SectionVariant[]> = { export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [ hero: [
@ -447,6 +453,72 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
}, },
], ],
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: [ exhibition: [
{ {
id: 'exhibition.gallery', id: 'exhibition.gallery',

View File

@ -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 (
<div className="w-[228px] shrink-0 snap-center">
<p
className="leading-none"
style={{fontFamily: ITEM_HEADING, fontSize: 34, color: turning ? ITEM_ACCENT : ITEM_INK}}
>
{item.year ?? '연도 미상'}
</p>
{/* 레일 — 점이 선 위에 놓여야 '흐르는 시간 위의 한 해'로 읽힌다 */}
<div className="relative my-3 h-3">
<i
aria-hidden
className="absolute top-1/2 left-0 border-t"
// 마지막 해에서 선이 끊긴다 — 레일이 화면 밖으로 이어지는 것처럼 보이면 뒤가 더 있는 줄 안다.
style={{borderColor: ITEM_BORDER, right: isLast ? 'calc(100% - 12px)' : 0}}
/>
<i
aria-hidden
className="absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2"
style={{
backgroundColor: turning ? ITEM_ACCENT : ITEM_CARD,
borderColor: turning ? ITEM_ACCENT : ITEM_BORDER,
}}
/>
</div>
<div className="space-y-1.5 pr-5">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{item.title}
</h4>
{item.summary && (
<p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{item.summary}
</p>
)}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
);
}
export function ChronicleRail(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ChronicleItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다.
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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : items.length === 0 ? (
<PasteHint label="시간의 골목" />
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
<i
aria-hidden
className="size-2.5 rounded-full"
style={{backgroundColor: ITEM_ACCENT}}
/>
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
</p>
{items.length > 3 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="연표" />
)}
</div>
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
{items.map((item, index) => (
<Milestone
key={`${item.year ?? 'x'}-${item.title}-${index}`}
item={item}
isLast={index === items.length - 1}
/>
))}
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -6,21 +6,22 @@
*/ */
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type CourseItem, type CourseStop} from '../../dataSpec'; import {parseSectionData, type CourseItem, type CourseStop} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_SURFACE,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_LINE,
RETRO_PAPER,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine, SourceLine,
useCarousel, useCarousel,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
function Ticket({ function Ticket({
stop, stop,
@ -38,31 +39,31 @@ function Ticket({
return ( return (
<div <div
className="relative w-[236px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(27,26,21,.13)]" className="relative w-[236px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: '#1b1a15'}} style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
> >
<div <div
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] text-stone-600" className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] opacity-75"
style={{borderColor: '#1b1a15'}} style={{borderColor: ITEM_INK}}
> >
<span className="truncate">{courseName}</span> <span className="truncate">{courseName}</span>
<span className="shrink-0">NO.{no}</span> <span className="shrink-0">NO.{no}</span>
</div> </div>
<p className="px-4 pt-4 leading-none" style={{fontFamily: RETRO_SIGN, fontSize: 32, color: RETRO_RED}}> <p className="px-4 pt-4 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 32, color: ITEM_ACCENT}}>
{no} {no}
</p> </p>
<h4 className="px-4 pt-1.5 text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}> <h4 className="px-4 pt-1.5 text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{stop.name} {stop.name}
</h4> </h4>
{stop.note && ( {stop.note && (
<p className="px-4 pt-2 text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="px-4 pt-2 text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{stop.note} {stop.note}
</p> </p>
)} )}
<div <div
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] text-stone-500" className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] opacity-60"
style={{borderColor: RETRO_LINE}} style={{borderColor: ITEM_BORDER}}
> >
<span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span> <span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span>
{stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>} {stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>}
@ -71,17 +72,17 @@ function Ticket({
{/* 펀치 구멍 — 표를 표로 만드는 자리 */} {/* 펀치 구멍 — 표를 표로 만드는 자리 */}
<i <i
className="absolute -left-[7px] top-1/2 size-3 rounded-full border" className="absolute -left-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: '#1b1a15'}} style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/> />
<i <i
className="absolute -right-[7px] top-1/2 size-3 rounded-full border" className="absolute -right-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: '#1b1a15'}} style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/> />
{isLast && ( {isLast && (
<span <span
className="absolute bottom-3 right-3 grid size-14 -rotate-12 place-items-center rounded-full border-2 text-center text-[11px] leading-tight opacity-75" className="absolute bottom-3 right-3 grid size-14 -rotate-12 place-items-center rounded-full border-2 text-center text-[11px] leading-tight opacity-75"
style={{fontFamily: RETRO_SIGN, borderColor: RETRO_RED, color: RETRO_RED}} style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
> >
완주 완주
<br /> <br />
@ -100,10 +101,10 @@ function CourseRow({course}: {course: CourseItem}) {
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5"> <div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}> <h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{course.name} {course.name}
</h3> </h3>
<span className="text-[11px] text-stone-500"> <span className="text-[11px] opacity-60">
{[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`] {[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`]
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
@ -115,7 +116,7 @@ function CourseRow({course}: {course: CourseItem}) {
</div> </div>
{stops.length === 0 ? ( {stops.length === 0 ? (
<p className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500" style={{borderColor: RETRO_LINE}}> <p className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60" style={{borderColor: ITEM_BORDER}}>
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요. 정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p> </p>
) : ( ) : (
@ -145,11 +146,11 @@ export function CourseTickets(props: SectionRenderProps) {
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide"> <SectionBody width="wide">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}

View File

@ -7,20 +7,20 @@
import {useMemo, useState} from 'react'; import {useMemo, useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type DailyItem} from '../../dataSpec'; import {parseSectionData, type DailyItem} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_SURFACE,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_LINE,
RETRO_PAPER,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine, SourceLine,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
const DOW = ['일', '월', '화', '수', '목', '금', '토']; 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 ${ 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' : '' muted ? 'scale-[.93] opacity-45' : ''
}`} }`}
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_LINE}} style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
> >
{/* 뜯어낸 자국 */} {/* 뜯어낸 자국 */}
<div <div
className="w4-perf h-3.5 border-b border-dashed" className="w4-perf h-3.5 border-b border-dashed"
style={{['--tear' as string]: RETRO_PAPER, borderColor: RETRO_LINE}} style={{['--tear' as string]: ITEM_SURFACE, borderColor: ITEM_BORDER}}
/> />
<div className="absolute inset-x-0 top-0 flex justify-around px-10"> <div className="absolute inset-x-0 top-0 flex justify-around px-10">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<i <i
key={i} key={i}
className="-mt-1 size-2 rounded-full border" className="-mt-1 size-2 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: RETRO_LINE}} style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_BORDER}}
/> />
))} ))}
</div> </div>
<div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: RETRO_LINE}}> <div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: ITEM_BORDER}}>
<p className="text-[11px] tracking-[0.2em] text-stone-500">{month}月</p> <p className="text-[11px] tracking-[0.2em] opacity-60">{month}月</p>
<p className="mt-1 leading-none" style={{fontFamily: RETRO_SIGN, fontSize: 72, color: RETRO_RED}}> <p className="mt-1 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 72, color: ITEM_ACCENT}}>
{String(Number(day) || day)} {String(Number(day) || day)}
</p> </p>
{dow && <p className="mt-1.5 text-[11px] tracking-[0.3em] text-stone-600">{dow}曜</p>} {dow && <p className="mt-1.5 text-[11px] tracking-[0.3em] opacity-75">{dow}曜</p>}
</div> </div>
<div className="space-y-2 px-5 pb-5 pt-4"> <div className="space-y-2 px-5 pb-5 pt-4">
{item.category && ( {item.category && (
<p className="text-[10px] tracking-[0.16em] text-emerald-800">{item.category}</p> <p className="text-[10px] tracking-[0.16em]" style={{color: ITEM_ACCENT}}>
{item.category}
</p>
)} )}
<h3 className="text-base font-bold leading-snug text-stone-900" style={{fontFamily: RETRO_BODY}}> <h3 className="text-base font-bold leading-snug" style={{fontFamily: ITEM_BODY}}>
{item.title} {item.title}
</h3> </h3>
{item.body && ( {item.body && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{item.body} {item.body}
</p> </p>
)} )}
{item.tags && item.tags.length > 0 && ( {item.tags && item.tags.length > 0 && (
<p className="text-[10px] text-stone-500">{item.tags.join(' ')}</p> <p className="text-[10px] opacity-60">{item.tags.join(' ')}</p>
)} )}
<div className="border-t border-dashed pt-2.5" style={{borderColor: RETRO_LINE}}> <div className="border-t border-dashed pt-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={item.source} verified={item.verified} /> <SourceLine source={item.source} verified={item.verified} />
</div> </div>
</div> </div>
@ -122,11 +124,11 @@ export function DailyCalendar(props: SectionRenderProps) {
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide"> <SectionBody width="wide">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}
@ -150,7 +152,7 @@ export function DailyCalendar(props: SectionRenderProps) {
label="날짜" label="날짜"
/> />
)} )}
<p className="text-center text-[11px] text-stone-500"> <p className="text-center text-[11px] opacity-60">
오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장 오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`} {parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`}
</p> </p>

View File

@ -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 (
<p
className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]', tone === 'dark' ? 'opacity-70' : 'opacity-65')}
>
{verified && (
<span
className="border px-1.5 py-px"
// ★ 신호색(초록/호박)은 유지하되 둘레 글자색을 섞는다 — 어두운 면에서는 밝아지고
// 밝은 면에서는 진해져, 어떤 팔레트에서도 배지가 읽힌다.
style={{
color: `color-mix(in oklab, ${verified === '확인' ? '#2a6053' : '#b07d10'} 62%, currentColor)`,
borderColor: 'currentColor',
}}
>
{verified}
</span>
)}
{source?.name && (
<span>
출처 ·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
</p>
);
}
/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */
export function useCarousel<T extends HTMLElement>() {
const ref = useRef<T>(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 (
<div className="flex justify-end gap-1.5">
<button
type="button"
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70"
style={style}
onClick={stop(onPrev)}
aria-label={`${label} 이전`}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70"
style={style}
onClick={stop(onNext)}
aria-label={`${label} 다음`}
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
/**
* 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
*
* ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
* ★ 이건 발행되지 않는 **에디터 안내**다. 그래서 템플릿 색이 아니라 관리자 색을 그대로 쓴다.
*/
export function PasteHint({label}: {label: string}) {
return (
<div className="flex flex-col items-center gap-2 border border-dashed border-stone-400/70 bg-white/50 px-4 py-8 text-center">
<MousePointerClick className="size-4 text-stone-400" />
<p className="text-xs font-semibold text-stone-600">{label} 내용이 아직 없습니다</p>
<p className="max-w-[34ch] text-[11px] leading-relaxed text-stone-500">
오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
</p>
</div>
);
}
/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다(에디터 안내라 관리자 색). */
export function ParseError({message}: {message: string}) {
return (
<div className="border border-dashed border-red-400/70 bg-red-50/70 px-4 py-5 text-center">
<p className="text-xs font-semibold text-red-700">붙여넣은 JSON 을 읽지 못했습니다</p>
<p className="mt-1.5 text-[11px] leading-relaxed text-red-600">{message}</p>
</div>
);
}

View File

@ -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;
}
}

View File

@ -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<LiteratureItem>(section.type, section.data);
const [opened, setOpened] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 책으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[opened] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="문학 서가" />
) : (
<div className="space-y-5">
{/* 서가 — 아래 선반이 있어야 책이 '꽂혀 있다'로 읽힌다 */}
<div className="space-y-2">
<div
ref={ref}
className="w4-scroll flex items-end gap-1.5 overflow-x-auto border-b-4 px-2 pt-4"
// 선반 널. 글자색을 섞어 만들면 어떤 팔레트에서도 '받치는 판'으로 읽힌다.
style={{borderColor: 'color-mix(in oklab, currentColor 45%, transparent)'}}
>
{parsed.items.map((book, index) => (
<button
key={`${book.workTitle}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setOpened(index);
}}
aria-current={index === opened}
className="h-[184px] w-[40px] shrink-0 snap-center px-1 py-3 text-center transition-all"
style={{
backgroundColor: book.spineColor || FALLBACK_SPINE,
// 책등 색은 사장님이 정하므로 그 위 글자는 밝은 면 색으로 고정한다.
color: ITEM_INVERSE_INK,
// 고른 책만 한 칸 튀어나온다 — 뽑아 든 자리
transform: index === opened ? 'translateY(-12px)' : undefined,
boxShadow: index === opened ? '0 6px 14px rgba(27,26,21,.35)' : undefined,
}}
>
<span
className="mx-auto block h-full overflow-hidden text-[13px] leading-tight"
style={{fontFamily: ITEM_BODY, writingMode: 'vertical-rl'}}
>
{book.workTitle}
{book.author ? ` · ${book.author}` : ''}
</span>
</button>
))}
</div>
{parsed.items.length > 6 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="책" />
)}
</div>
{/* 펼친 면 */}
<div
className="w4-paper grid gap-5 border p-5 sm:p-6 md:grid-cols-[auto_minmax(0,1fr)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<p
className="mx-auto h-fit w-fit max-h-[240px] overflow-hidden border-r pr-4 leading-tight md:mx-0"
style={{
fontFamily: ITEM_HEADING,
fontSize: 30,
writingMode: 'vertical-rl',
borderColor: ITEM_BORDER,
}}
>
{current.workTitle}
</p>
<div className="min-w-0 space-y-3">
<p className="text-xs opacity-60">
{[current.author, current.year, current.genre].filter(Boolean).join(' · ')}
</p>
{current.background && (
<p
className="text-sm leading-relaxed opacity-85"
style={{fontFamily: ITEM_BODY}}
>
{current.background}
</p>
)}
{current.whyHere && (
<p
className="border-l-2 pl-3 text-sm leading-relaxed opacity-75"
style={{fontFamily: ITEM_BODY, borderColor: ITEM_BORDER}}
>
{current.whyHere}
</p>
)}
<span className="inline-block border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 원문 대신 배경 — 작품 문장은 싣지 않습니다
</span>
<SourceLine source={current.source} verified={current.verified} />
</div>
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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<PeopleItem>(section.type, section.data);
const [picked, setPicked] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[picked] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
{/* ★ 어두운 면 위의 글자색을 여기서 한 번만. 필름 구멍(w4-film-perf)도 이 색을 따라간다. */}
<SectionBody width="wide" className="text-[color:var(--tpl-bg,#fff)]">
<div className="space-y-1.5">
<p className="text-[11px] tracking-[0.28em]" style={{color: ITEM_ACCENT}}>
PORTRAIT ROLL
</p>
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-55" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="인물 열전" />
) : (
<div className="space-y-4">
<div
className="border"
style={{
backgroundColor: ITEM_INVERSE,
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}
>
<div className="w4-film-perf h-2.5 opacity-70" />
<div
ref={ref}
className="w4-scroll flex snap-x snap-mandatory gap-3 overflow-x-auto px-3 py-3"
>
{parsed.items.map((person, index) => (
<button
key={`${person.name}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(index);
}}
aria-current={index === picked}
className={cn(
'w-[126px] shrink-0 snap-center text-left transition-all',
// 고른 프레임만 색이 돌아온다 — 필름을 손으로 짚은 자리.
index === picked ? 'opacity-100' : 'opacity-45 grayscale',
)}
>
<span
className="grid aspect-3/4 w-full place-items-center border"
style={{
// 프레임 안쪽은 필름보다 한 단 밝게 — 색을 박지 않고 글자색을 섞어 만든다.
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor:
index === picked
? ITEM_ACCENT
: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
>
<span className="leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 46}}>
{initialOf(person.name)}
</span>
</span>
<span className="mt-2 block truncate text-xs opacity-95">{person.name}</span>
<span className="block truncate text-[10px] opacity-60">
{[person.role, person.years].filter(Boolean).join(' · ') || '역할 미상'}
</span>
</button>
))}
</div>
<div className="w4-film-perf h-2.5 opacity-70" />
</div>
{parsed.items.length > 4 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} tone="dark" label="인물" />
)}
{/* 고른 프레임의 자막 — 프레임 안에 넣으면 이름조차 안 읽힌다 */}
<div className="space-y-2 border-l-2 pl-4" style={{borderColor: ITEM_ACCENT}}>
<h3 className="text-xl" style={{fontFamily: ITEM_HEADING}}>
{current.name}
{current.aka && <span className="ml-2 text-sm opacity-55">호 {current.aka}</span>}
</h3>
<p className="text-xs opacity-55">
{[current.role, current.years].filter(Boolean).join(' · ')}
</p>
{current.oneLine && (
<p
className="max-w-[52ch] text-sm leading-relaxed opacity-80"
style={{fontFamily: ITEM_BODY}}
>
{current.oneLine}
</p>
)}
{current.imageQuery && (
<p className="text-[10px] opacity-60">사진 검색어 · {current.imageQuery}</p>
)}
<SourceLine source={current.source} verified={current.verified} tone="dark" />
</div>
<p className="text-[11px] opacity-60">
사진이 없는 인물은 이름 활자로 대신합니다 · 총 {parsed.items.length}명
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}명`}
</p>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,218 @@
/**
* 계절별 추천 하루 — 계절 탭 + 순위 카드.
*
* ★ 여행 스케줄(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 {
currentSeasons,
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 (
<article
className="w4-paper w-[320px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(0,0,0,.08)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면
팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */}
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={
rank === 0
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{RANK_LABEL[rank] ?? `${rank + 1}위`}
</span>
<span className="text-[11px] opacity-60">
{day.from}–{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
{item.why && (
<p className="text-[13px] leading-relaxed opacity-80" style={{fontFamily: ITEM_BODY}}>
{item.why}
</p>
)}
</div>
{day.stops.length === 0 ? (
<p className="px-4 py-5 text-center text-[11px] opacity-60">
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p>
) : (
<ol className="mt-3 px-4 pb-3">
{day.stops.map((planned, index) => (
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */}
<span className="pt-2 text-[12px] tabular-nums opacity-75" style={{fontFamily: ITEM_HEADING}}>
{planned.time}
</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && (
<p className="pt-1 text-[10px] opacity-50">↓ {planned.move}분 이동</p>
)}
<p className="pt-1 text-sm font-bold" style={{fontFamily: ITEM_BODY}}>
{planned.stop.name}
</p>
{planned.stop.note && (
<p className="mt-0.5 text-[12px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{planned.stop.note}
</p>
)}
<p className="mt-0.5 text-[10px] opacity-50">
{planned.time}–{planned.until}
{planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
)}
<div className="space-y-1 border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
{/* ★ 상한에 걸려 뺀 칸을 숨기지 않는다. 숨기면 사장님은 자기가 적은 곳이 왜 없는지 모른다. */}
{day.dropped > 0 && (
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
밤 9시를 넘겨 {day.dropped}곳을 뺐습니다 — 머무는 시간을 줄이거나 출발을 당겨 보세요.
</p>
)}
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
}
export function PlannerPodium(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PlannerItem>(section.type, section.data);
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
// 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다.
const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]);
const [picked, setPicked] = useState<number>();
const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? ''));
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
const season = seasons[index] ?? seasons[0];
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="계절별 추천 하루" />
) : (
<div className="space-y-4">
{seasons.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{seasons.map((name, tabIndex) => (
<button
key={name}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(tabIndex);
}}
aria-current={tabIndex === index}
className={cn(
'border px-3 py-1 text-xs transition-opacity',
tabIndex !== index && 'opacity-55',
)}
style={
tabIndex === index
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{name}
{/* 손님 화면에 지금 나가는 계절. 사장님이 '어느 게 실제로 보이나'를 눈으로 안다. */}
{live.includes(name) && <span className="ml-1 opacity-70">·지금</span>}
</button>
))}
</div>
)}
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
{top.map((item, rank) => (
<PlanCard key={`${item.name}-${rank}`} item={item} rank={rank} />
))}
</div>
<p className="text-[11px] opacity-60">
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
{top.length}개 / 전체 {parsed.items.length}개
</p>
{live.length > 0 && (
<p className="text-[11px] opacity-60">
손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.
</p>
)}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<article
className="w4-paper w-[304px] shrink-0 snap-center border p-4 shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>
<p className="text-[19px] leading-snug" style={{fontFamily: ITEM_BODY}}>
“{item.line}”
</p>
{item.hashtags && item.hashtags.length > 0 && (
<p className="mt-2 text-[11px] opacity-60">{item.hashtags.join(' ')}</p>
)}
</div>
<div className="flex w-[62px] flex-col items-center gap-3">
<span
className="grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60"
style={{borderColor: ITEM_BORDER}}
>
郵票
<br />
10원
</span>
<span
className="grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight"
style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
{item.postmark || item.place || '소인'}
</span>
</div>
</div>
<div
className="mt-3 flex items-center justify-between gap-2 border-t border-dashed pt-2.5"
style={{borderColor: ITEM_BORDER}}
>
<SourceLine source={item.source} verified={item.verified} />
<button
type="button"
onClick={copy}
className="flex shrink-0 items-center gap-1 border px-2 py-1 text-[10px] opacity-75 transition-opacity hover:opacity-100"
style={{borderColor: ITEM_BORDER}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? '복사됨' : '복사'}
</button>
</div>
</article>
);
}
export function PostcardStack(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PostcardItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="오늘의 엽서" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{parsed.items.map((item, index) => (
<Postcard key={`${item.line}-${index}`} item={item} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="엽서" />
)}
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<div
role="button"
tabIndex={0}
aria-pressed={flipped}
onClick={(event) => {
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')}
>
<div className="w4-flip-inner relative size-full">
<div className={cn(face, 'w4-paper')} style={faceStyle}>
<p className="text-[10px] tracking-[0.16em] opacity-60">
문제 {no}
{item.level ? ` · ${item.level}` : ''}
</p>
<p className="text-[17px] leading-snug" style={{fontFamily: ITEM_BODY}}>
{item.question}
</p>
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
뒤집기 →
</p>
</div>
<div className={cn(face, 'w4-flip-back w4-paper')} style={faceStyle}>
<p className="text-[10px] tracking-[0.16em]" style={{color: ITEM_ACCENT}}>
힌트
</p>
{item.hint ? (
<p className="text-[13px] leading-relaxed opacity-85" style={{fontFamily: ITEM_BODY}}>
{item.hint}
</p>
) : (
<p className="text-[13px] opacity-60">힌트가 아직 없습니다.</p>
)}
<div className="space-y-1 border-t border-dashed pt-2" style={{borderColor: ITEM_BORDER}}>
{item.topic && <p className="text-[10px] opacity-60">{item.topic}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</div>
</div>
);
}
export function QuizFlip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<QuizItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="뒤집어 보는 질문" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{parsed.items.map((item, index) => (
<QuizCard key={`${item.question}-${index}`} item={item} no={index + 1} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="질문" />
)}
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<p
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]',
dark ? 'text-stone-400' : 'text-stone-500',
)}
>
{verified && (
<span
className="border px-1.5 py-px"
style={{
color: verified === '확인' ? '#2a6053' : '#b07d10',
borderColor: 'currentColor',
}}
>
{verified}
</span>
)}
{source?.name && (
<span>
출처 ·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
</p>
);
}
/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */
export function useCarousel<T extends HTMLElement>() {
const ref = useRef<T>(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 (
<div className="flex justify-end gap-1.5">
<button type="button" className={base} onClick={stop(onPrev)} aria-label={`${label} 이전`}>
<ChevronLeft className="size-4" />
</button>
<button type="button" className={base} onClick={stop(onNext)} aria-label={`${label} 다음`}>
<ChevronRight className="size-4" />
</button>
</div>
);
}
/**
* 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
*
* ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
*/
export function PasteHint({label}: {label: string}) {
return (
<div className="flex flex-col items-center gap-2 border border-dashed border-stone-400/70 bg-white/50 px-4 py-8 text-center">
<MousePointerClick className="size-4 text-stone-400" />
<p className="text-xs font-semibold text-stone-600">{label} 내용이 아직 없습니다</p>
<p className="max-w-[34ch] text-[11px] leading-relaxed text-stone-500">
오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
</p>
</div>
);
}
/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다. */
export function ParseError({message}: {message: string}) {
return (
<div className="border border-dashed border-red-400/70 bg-red-50/70 px-4 py-5 text-center">
<p className="text-xs font-semibold text-red-700">붙여넣은 JSON 을 읽지 못했습니다</p>
<p className="mt-1.5 text-[11px] leading-relaxed text-red-600">{message}</p>
</div>
);
}

View File

@ -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;
}
}

View File

@ -8,21 +8,23 @@
*/ */
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec'; import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_INVERSE,
ITEM_INVERSE_INK,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_INK,
RETRO_LINE,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine, SourceLine,
useCarousel, useCarousel,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */ /** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
function splitTime(time: string): {head: string; tail?: string} { function splitTime(time: string): {head: string; tail?: string} {
@ -38,43 +40,47 @@ function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
<div className="flex shrink-0 snap-center items-stretch"> <div className="flex shrink-0 snap-center items-stretch">
<div <div
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]" className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_INK}} style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
> >
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */} {/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
<div className="relative px-4 py-3 text-center" style={{backgroundColor: RETRO_INK}}> {/* 플립보드는 템플릿의 어두운 면이다. 위의 글자는 밝은 면 색을 그대로 쓴다. */}
<div
className="relative px-4 py-3 text-center"
style={{backgroundColor: ITEM_INVERSE, color: ITEM_INVERSE_INK}}
>
<span <span
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]" className="inline-flex items-baseline gap-1 leading-none"
style={{fontFamily: RETRO_SIGN, fontSize: 30}} style={{fontFamily: ITEM_HEADING, fontSize: 30}}
> >
{head} {head}
{tail && <span className="text-[#c0b493]">:{tail}</span>} {tail && <span className="opacity-60">:{tail}</span>}
</span> </span>
<i <i
aria-hidden aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t" className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'rgba(242,235,217,.22)'}} style={{borderColor: 'color-mix(in oklab, currentColor 25%, transparent)'}}
/> />
</div> </div>
<div className="space-y-2 px-4 pb-3 pt-3.5"> <div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}> <h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{slot.title} {slot.title}
</h4> </h4>
{slot.place && ( {slot.place && (
<p className="text-[12px] font-semibold" style={{fontFamily: RETRO_BODY, color: RETRO_RED}}> <p className="text-[12px] font-semibold" style={{fontFamily: ITEM_BODY, color: ITEM_ACCENT}}>
{slot.place} {slot.place}
</p> </p>
)} )}
{slot.note && ( {slot.note && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{slot.note} {slot.note}
</p> </p>
)} )}
</div> </div>
<div <div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500" className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] opacity-60"
style={{borderColor: RETRO_LINE}} style={{borderColor: ITEM_BORDER}}
> >
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span> <span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>} {slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
@ -84,7 +90,7 @@ function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */} {/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
{!isLast && ( {!isLast && (
<div aria-hidden className="flex w-8 items-center justify-center"> <div aria-hidden className="flex w-8 items-center justify-center">
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} /> <i className="block h-px w-full border-t border-dashed" style={{borderColor: ITEM_BORDER}} />
</div> </div>
)} )}
</div> </div>
@ -101,10 +107,10 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5"> <div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}> <h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{schedule.name} {schedule.name}
</h3> </h3>
<span className="text-[11px] text-stone-500"> <span className="text-[11px] opacity-60">
{[schedule.audience, schedule.season, span, `${slots.length}칸`] {[schedule.audience, schedule.season, span, `${slots.length}칸`]
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
@ -117,8 +123,8 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
{slots.length === 0 ? ( {slots.length === 0 ? (
<p <p
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500" className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60"
style={{borderColor: RETRO_LINE}} style={{borderColor: ITEM_BORDER}}
> >
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요. 시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
</p> </p>
@ -147,11 +153,11 @@ export function ScheduleTimetable(props: SectionRenderProps) {
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide"> <SectionBody width="wide">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}

View File

@ -7,19 +7,22 @@
import {useState} from 'react'; import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type SongItem} from '../../dataSpec'; import {parseSectionData, type SongItem} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_HEADING,
ITEM_INVERSE,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_SIGN,
SourceLine, SourceLine,
useCarousel, useCarousel,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
const FALLBACK_LABEL = '#d4551f'; /** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다 — hex 를 박으면 팔레트를 바꿔도 판만 남는다. */
const FALLBACK_LABEL = ITEM_ACCENT;
export function SongsTurntable(props: SectionRenderProps) { export function SongsTurntable(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props; const {section, isSelected, onSelect, template} = props;
@ -32,14 +35,18 @@ export function SongsTurntable(props: SectionRenderProps) {
return ( return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
<SectionBody width="wide"> {/* ★ 어두운 면 위의 글자색을 여기서 한 번만 정한다. 아래는 전부 currentColor·opacity 로 단을 만든다 —
자식마다 색을 박으면 템플릿을 바꿨을 때 한두 곳이 옛 색으로 남는다. */}
<SectionBody width="wide" className="text-[color:var(--tpl-bg,#fff)]">
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-[11px] tracking-[0.28em] text-amber-500/90">33⅓ RPM</p> <p className="text-[11px] tracking-[0.28em]" style={{color: ITEM_ACCENT}}>
<h2 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> 33⅓ RPM
</p>
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-400" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-55" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}
@ -51,34 +58,57 @@ export function SongsTurntable(props: SectionRenderProps) {
<PasteHint label="가요 다방" /> <PasteHint label="가요 다방" />
) : ( ) : (
<> <>
<div className="grid items-center gap-8 border border-stone-700/60 bg-linear-160 from-stone-800 to-stone-900 p-6 sm:p-8 md:grid-cols-[260px_minmax(0,1fr)]"> <div className="grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[260px_minmax(0,1fr)]"
style={{
backgroundColor: ITEM_INVERSE,
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}>
{/* 턴테이블 */} {/* 턴테이블 */}
<div className="relative mx-auto size-[240px]"> <div className="relative mx-auto size-[240px]">
<div className="absolute inset-0 rounded-full bg-radial from-stone-700 from-32% to-stone-800 shadow-[0_10px_26px_rgba(0,0,0,.5)]" /> {/* 플래터 — 판 아래 깔리는 원반. 어두운 면 위에 글자색을 옅게 얹어 단을 만든다. */}
<div
className="absolute inset-0 rounded-full shadow-[0_10px_26px_rgba(0,0,0,.45)]"
style={{backgroundColor: 'color-mix(in oklab, currentColor 16%, transparent)'}}
/>
<div <div
className="w4-disc w4-spin absolute inset-2 rounded-full" className="w4-disc w4-spin absolute inset-2 rounded-full"
style={{['--lbl' as string]: current.labelColor || FALLBACK_LABEL}} style={{
['--lbl' as string]: current.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
> >
<div className="w4-disc-sheen absolute inset-0 rounded-full" /> <div className="w4-disc-sheen absolute inset-0 rounded-full" />
<div className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-stone-700" /> <div
className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 30%, transparent)'}}
/>
</div> </div>
{/* 톤암 — 곡이 얹혀 있으니 항상 내려와 있다. */} {/* 톤암 — 곡이 얹혀 있으니 항상 내려와 있다. */}
<div className="absolute -right-1 top-3 h-2 w-[120px] origin-right rotate-6"> <div className="absolute -right-1 top-3 h-2 w-[120px] origin-right rotate-6">
<div className="absolute inset-y-[3px] right-4 left-0 rounded-sm bg-linear-to-b from-stone-300 to-stone-500" /> <div
<div className="absolute left-0 -top-1 h-4 w-4 rounded-sm bg-stone-700" /> className="absolute inset-y-[3px] right-4 left-0 rounded-sm"
<div className="absolute -top-2 right-0 size-6 rounded-full bg-radial from-stone-300 to-stone-600" /> style={{backgroundColor: 'color-mix(in oklab, currentColor 65%, transparent)'}}
/>
<div
className="absolute left-0 -top-1 h-4 w-4 rounded-sm"
style={{backgroundColor: 'color-mix(in oklab, currentColor 40%, transparent)'}}
/>
<div
className="absolute -top-2 right-0 size-6 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 55%, transparent)'}}
/>
</div> </div>
</div> </div>
{/* 지금 도는 곡 */} {/* 지금 도는 곡 */}
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
<p className="text-[10px] tracking-[0.24em] text-amber-500/90"> <p className="text-[10px] tracking-[0.24em]" style={{color: ITEM_ACCENT}}>
A면 · {playing + 1} / {parsed.items.length} A면 · {playing + 1} / {parsed.items.length}
</p> </p>
<h3 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h3 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{current.title} {current.title}
</h3> </h3>
<p className="text-xs text-stone-400"> <p className="text-xs opacity-55">
{[ {[
current.artist, current.artist,
current.year ? String(current.year) : undefined, current.year ? String(current.year) : undefined,
@ -92,8 +122,8 @@ export function SongsTurntable(props: SectionRenderProps) {
</p> </p>
{current.story && ( {current.story && (
<p <p
className="max-w-[46ch] text-sm leading-relaxed text-stone-300" className="max-w-[46ch] text-sm leading-relaxed opacity-80"
style={{fontFamily: RETRO_BODY}} style={{fontFamily: ITEM_BODY}}
> >
{current.story} {current.story}
</p> </p>
@ -101,12 +131,13 @@ export function SongsTurntable(props: SectionRenderProps) {
{current.connection && ( {current.connection && (
<p <p
className="max-w-[46ch] text-sm leading-relaxed" className="max-w-[46ch] text-sm leading-relaxed"
style={{fontFamily: RETRO_BODY, color: template.colors.accent}} style={{fontFamily: ITEM_BODY, color: template.colors.accent}}
> >
{current.connection} {current.connection}
</p> </p>
)} )}
<span className="inline-block border border-dashed border-stone-600 px-2 py-1 text-[10px] tracking-[0.1em] text-stone-400"> <span className="inline-block border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60"
style={{borderColor: 'color-mix(in oklab, currentColor 35%, transparent)'}}>
◎ 가사 대신 이야기 — 원문은 싣지 않습니다 ◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</span> </span>
<SourceLine source={current.source} verified={current.verified} tone="dark" /> <SourceLine source={current.source} verified={current.verified} tone="dark" />
@ -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" className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105"
style={{ style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL, ['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
// 고른 판만 강조색 링 — 금색을 박으면 팔레트를 바꿔도 이 링만 남는다.
boxShadow: boxShadow:
index === playing 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, : undefined,
}} }}
/> />
<span className="mt-2 block truncate text-[10px] leading-tight text-stone-400"> <span className="mt-2 block truncate text-[10px] leading-tight opacity-55">
{song.title} {song.title}
</span> </span>
</button> </button>

View File

@ -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 {ApiError} from '@/api';
import {notifyApiError} from '@/lib/notify'; import {notifyApiError} from '@/lib/notify';
import {callSiteApi, type ResultEnvelope} from './siteApi'; import {callSiteApi, type ResultEnvelope} from './siteApi';
@ -40,6 +40,13 @@ export interface SiteThemePayload {
* 서버는 어느 쪽도 해석하지 않는다. * 서버는 어느 쪽도 해석하지 않는다.
*/ */
colorPaletteId?: string | null; colorPaletteId?: string | null;
/**
* 템플릿의 생김새(서체·모서리·테두리·그림자·여백).
*
* ★ 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다 —
* 사장님이 레트로를 골라도 발행 페이지만 고딕이었다. 서버는 해석하지 않고 보관만 한다.
*/
look?: TemplateLook;
sections: { sections: {
id: string; id: string;
/** /**
@ -81,6 +88,7 @@ export function toThemePayload(
return { return {
colors: template.colors, colors: template.colors,
fontStyle: template.fontStyle, fontStyle: template.fontStyle,
look: template.look,
colorPaletteId, colorPaletteId,
sections: sections.map((s) => ({ sections: sections.map((s) => ({
id: s.id, id: s.id,

View File

@ -1,37 +1,7 @@
/** /**
* 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다. * 색 계산은 계약 패키지가 소유한다(`@o2o/shared/lib/color.ts`).
* *
* ★ 쇼케이스(개발 도구)와 실제 캔버스가 **같은 식**을 써야 한다. * ★ 발행 사이트도 같은 식으로 면 토큰을 만든다. 두 벌로 두면 캔버스와 발행본의 바탕색이 갈린다.
* 미리보기에서 예쁜 조합이 발행 화면에서 달라지면 미리보기가 거짓말이 된다. * 여기는 기존 import 경로(`@/lib/color`)를 지키는 재수출만 남긴다.
*/ */
export {mix, deriveSurfaces} from '@o2o/shared';
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,
};
}

View File

@ -8,7 +8,9 @@
* ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로" * ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로"
* 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 aud 대조를 통과해야 한다. * 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 aud 대조를 통과해야 한다.
*/ */
const SCRIPT_URL = 'https://accounts.google.com/gsi/client'; // ★ 언어는 **스크립트 URL 의 hl** 로 잡는다. renderButton 의 locale 옵션은 안 먹었다
// (locale:'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다 — 실측).
const SCRIPT_URL = 'https://accounts.google.com/gsi/client?hl=ko';
const SCRIPT_ID = 'google-identity-services'; const SCRIPT_ID = 'google-identity-services';
export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? ''; export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '';

View File

@ -0,0 +1,144 @@
import {useEffect, useState, type FormEvent} from 'react';
import {KeyRound, Loader2} from 'lucide-react';
import {AuthProvider, updateMe, useMe} from '@/api';
import {AppShell, PageContainer} from '@/components/layout/AppShell';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify';
import {toAuthUser, useAuthStore} from '@/stores/auth';
/**
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
*
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
* 입력칸 자체를 그리지 않는다.
*/
export function AccountPage() {
const {data, isLoading, refetch} = useMe();
const setUser = useAuthStore((s) => s.setUser);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [contact, setContact] = useState('');
const [password, setPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
// 서버 값이 도착하면 한 번 채운다. 타이핑 중에 덮어쓰지 않게 응답이 바뀔 때만 돈다.
useEffect(() => {
if (!data) return;
setName(data.name ?? '');
setEmail(data.email ?? '');
setContact(data.contact_number ?? '');
}, [data]);
const isGoogle = data?.provider === AuthProvider.GOOGLE;
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setIsSaving(true);
try {
const res = await updateMe({
name,
email,
contact_number: contact,
...(password ? {password} : {}),
});
if (!res.result?.success) {
notifyApiError({data: res}, '저장하지 못했습니다.');
return;
}
// 사이드바가 이름을 들고 있다 — 저장하고 스토어를 안 갱신하면 새로고침 전까지 옛 이름이다.
if (res.user_id && res.id) setUser(toAuthUser(res));
setPassword('');
notify.success('저장했습니다.');
await refetch();
} catch (error) {
notifyApiError(error, '저장하지 못했습니다.');
} finally {
setIsSaving(false);
}
};
return (
<AppShell>
<PageContainer title="내 정보" description="이름·연락처와 로그인 정보를 관리합니다.">
{isLoading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : (
<form onSubmit={handleSubmit} className="max-w-lg space-y-5">
<section className="space-y-3 rounded-xl border border-border bg-card p-5">
<Field label="로그인 아이디">
<p className="text-sm">{isGoogle ? (data?.email ?? '구글 계정') : (data?.id ?? '')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
</p>
</Field>
<Field label="상호">
<p className="text-sm">{data?.company?.name ?? '-'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
상호 변경은 고객센터로 문의해 주세요.
</p>
</Field>
</section>
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<Field label="이름">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="홍길동" />
</Field>
<Field label="이메일">
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@example.com"
/>
</Field>
<Field label="연락처">
<Input value={contact} onChange={(e) => setContact(e.target.value)} placeholder="010-0000-0000" />
</Field>
</section>
{!isGoogle && (
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<Field label="새 비밀번호">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비우면 지금 비밀번호를 그대로 씁니다"
autoComplete="new-password"
/>
</Field>
</section>
)}
{isGoogle && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KeyRound className="size-3.5" />
구글 계정이라 비밀번호가 없습니다 — 비밀번호는 구글에서 관리합니다.
</p>
)}
<Button type="submit" variant="primary" isLoading={isSaving}>
저장
</Button>
</form>
)}
</PageContainer>
</AppShell>
);
}
function Field({label, children}: {label: string; children: React.ReactNode}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
}

View File

@ -1,9 +1,8 @@
import {useEffect, useRef} from 'react'; import {useEffect, useRef} from 'react';
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react'; import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router'; import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared'; import {SiteStatus} from '@o2o/shared';
import {getAccessToken} from '@/api'; import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate'; import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import { import {
Step1Industry, Step1Industry,
@ -15,7 +14,7 @@ import {
import {EditorLayout} from '@/features/builder'; import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin'; import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
@ -86,9 +85,11 @@ export function BuilderPage() {
const step = useBuilderStore((s) => s.step); const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep); const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다. // ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const authUser = useAuthStore((s) => s.user); const isSignedIn = Boolean(user) || Boolean(getAccessToken());
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId); const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -144,10 +145,14 @@ export function BuilderPage() {
<span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success"> <span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success">
실사업장 · {storeName} 실사업장 · {storeName}
</span> </span>
{/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로 {/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */}
나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를 <Link
새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다. to="/sites"
사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */} className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<ArrowLeft className="size-3" />
<span>내 사이트</span>
</Link>
</> </>
) : ( ) : (
<span className="truncate opacity-80"> <span className="truncate opacity-80">
@ -158,26 +163,49 @@ export function BuilderPage() {
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 — {/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다. 같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */} ★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
{publishedUrl ? ( <div className="flex shrink-0 items-center gap-2">
<a {publishedUrl ? (
href={publishedUrl} <a
target="_blank" href={publishedUrl}
rel="noopener noreferrer" target="_blank"
className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90" rel="noopener noreferrer"
> className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90"
<ExternalLink className="size-3" /> >
<span>발행본 사이트 열기</span> <ExternalLink className="size-3" />
</a> <span>발행본 사이트 열기</span>
) : ( </a>
<span ) : (
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다." <span
aria-disabled="true" title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50" aria-disabled="true"
> className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50"
<ExternalLink className="size-3" /> >
<span>발행본 사이트 열기</span> <ExternalLink className="size-3" />
</span> <span>발행본 사이트 열기</span>
)} </span>
)}
{user && (
<>
<span className="h-3.5 w-px bg-white/20" />
<span
className="max-w-[14rem] truncate text-background/70"
title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)}
>
{userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</span>
<button
type="button"
onClick={signOut}
className="flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<LogOut className="size-3" />
<span>로그아웃</span>
</button>
</>
)}
</div>
</div> </div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
@ -187,18 +215,44 @@ export function BuilderPage() {
); );
} }
// 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다. /**
// 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다. * 위저드는 **사이드바를 쓰지 않는다.**
*
* ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를
* 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
* 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다.
*/
return ( return (
<AppShell> <div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<div className="flex h-full min-h-full flex-col"> <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
{/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */}
{isSignedIn ? (
<Link
to="/sites"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
내 사이트
</Link>
) : (
<Link
to="/login"
className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
로그인
</Link>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{step === 1 && <Step1Industry />} {step === 1 && <Step1Industry />}
{step === 2 && <Step2PlaceSearch />} {step === 2 && <Step2PlaceSearch />}
{step === 3 && <Step3DataReview />} {step === 3 && <Step3DataReview />}
{step === 4 && <Step4Template />} {step === 4 && <Step4Template />}
{step === 5 && <Step5Generating />} {step === 5 && <Step5Generating />}
</div> </div>
</AppShell> </div>
); );
} }

View File

@ -22,8 +22,9 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); 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); const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
@ -135,7 +136,7 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
<span className="text-[11px] text-muted-foreground">또는</span> <span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
</div> </div>
<GoogleSignInButton onCredential={handleGoogle} text="continue_with" /> <GoogleSignInButton onCredential={handleGoogle} text="signin_with" />
</> </>
)} )}

View File

@ -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<number, typeof Building2> = {
[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<string | null>(null);
const [menuId, setMenuId] = useState<string | null>(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 (
<AppShell>
<PageContainer
title="내 사이트"
description="만든 사이트를 열어 고치고, 다시 발행합니다."
actions={
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
<Plus />새 사이트
</Button>
}
>
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{isError && (
<EmptyState
title="목록을 불러오지 못했습니다"
description={(error as Error)?.message ?? '잠시 후 다시 시도해 주세요.'}
action={
<Button size="sm" onClick={() => refetch()}>
다시 시도
</Button>
}
/>
)}
{!isLoading && !isError && rows.length === 0 && (
<EmptyState
icon={Wand2}
title="아직 만든 사이트가 없습니다"
description="상호명 하나로 시작하면 AI 가 정보를 모아 사이트를 만듭니다."
action={
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
<Plus />첫 사이트 만들기
</Button>
}
/>
)}
{rows.length > 0 && (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{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 (
<li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<Link to={editHref} className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{row.name}</span>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p>
</Link>
<div className="flex shrink-0 items-center gap-1.5">
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink className="size-3.5" />
사이트 열기
</a>
)}
<Button size="sm" onClick={() => navigate(editHref)}>
<Pencil />
{row.site_id ? '편집' : '이어서 만들기'}
</Button>
<Button
size="icon"
variant="ghost"
aria-label="더보기"
isLoading={busyId === row.place_id}
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
>
{busyId === row.place_id ? null : <MoreHorizontal />}
</Button>
</div>
{menuId === row.place_id && (
<>
{/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */}
<button
type="button"
aria-label="닫기"
className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)}
/>
<div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
<button
type="button"
disabled={row.status !== SiteStatus.PUBLISHED}
onClick={() => handleUnpublish(row)}
className="w-full cursor-pointer px-3 py-2 text-left text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
>
발행 내리기
</button>
</div>
</>
)}
</li>
);
})}
</ul>
)}
</PageContainer>
</AppShell>
);
}

View File

@ -31,6 +31,14 @@ export function toAuthUser(res: ResMe): AuthUser {
}; };
} }
/**
* 화면에 띄울 이름. 구글 계정의 로그인 아이디는 `google_<sub>` 라 그대로 보이면 안 된다 —
* 이름 → 이메일 순으로 떨어뜨리고 아이디는 마지막이다.
*/
export function userLabel(user: AuthUser): string {
return user.name || user.email || user.id;
}
interface AuthState { interface AuthState {
user: AuthUser | null; user: AuthUser | null;
/** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */ /** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */

View File

@ -2,3 +2,5 @@ export * from './types';
export * from './lib/cn'; export * from './lib/cn';
export * from './lib/slug'; export * from './lib/slug';
export * from './lib/facts'; export * from './lib/facts';
export * from './lib/section-data';
export * from './lib/color';

View File

@ -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,
};
}

View File

@ -0,0 +1,433 @@
/**
* 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 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<string, string> = {
songs: 'title',
daily: 'title',
course: 'name',
schedule: 'name',
people: 'name',
chronicle: 'title',
literature: 'workTitle',
postcard: 'line',
quiz: 'question',
planner: 'name',
};
export interface ParsedSectionData<T> {
items: T[];
title?: string;
subtitle?: string;
/** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */
error?: string;
/** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */
kindMismatch?: string;
/** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */
unverified: number;
/** source 가 붙은 항목 수. */
sourced: number;
}
const EMPTY: ParsedSectionData<never> = {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<T extends object>(
sectionType: string,
raw: string | undefined,
): ParsedSectionData<T> {
const requiredKey = SECTION_ITEM_REQUIRED_KEY[sectionType];
const text = (raw ?? '').trim();
if (!requiredKey || !text) return EMPTY as ParsedSectionData<T>;
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<string, unknown>;
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<string, unknown>)[requiredKey] === 'string' &&
((item as Record<string, unknown>)[requiredKey] as string).trim().length > 0,
);
let unverified = 0;
let sourced = 0;
for (const item of items) {
const row = item as Record<string, unknown>;
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,
};
}
/**
* 지금 계절. **간절기에는 두 개**를 돌려준다.
*
* ★ 왜 둘인가 — 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[] = [];
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);
}

View File

@ -1,3 +1,4 @@
import type {TemplateLook} from './builder';
import type { import type {
FactStatus, FactStatus,
LinkChannel, LinkChannel,
@ -229,6 +230,15 @@ export interface SiteTheme {
accent: string; accent: string;
}; };
fontStyle: string; fontStyle: string;
/**
* 템플릿의 생김새 — 서체 · 모서리 · 테두리 두께 · 그림자 · 섹션 여백.
*
* ★ 이 필드가 없던 동안 발행본은 **색만** 템플릿을 따랐다. 사장님이 레트로(간판체)를 골라도
* 발행 페이지는 늘 같은 고딕·명조로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
* ★ 값은 전부 CSS 에 그대로 들어가는 문자열이다(`TemplateLook`). 렌더러는 이걸 `--tpl-*` 로
* 내려보내기만 한다. 비어 있으면 렌더러 기본 서체로 떨어진다 — 화면이 깨지지 않는다.
*/
look?: TemplateLook;
/** 섹션 순서와 노출 여부. 관리자 에디터의 좌측 패널이 만든 결과 그대로. */ /** 섹션 순서와 노출 여부. 관리자 에디터의 좌측 패널이 만든 결과 그대로. */
sections: SectionSetting[]; sections: SectionSetting[];
} }
@ -258,4 +268,13 @@ export interface SectionSetting {
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다. * ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
*/ */
body?: string; body?: string;
/**
* 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON.
*
* ★ body·variantId 와 같은 사연이다 — 이 필드가 없으면 사장님이 붙여넣은 곡 목록이
* payload 경계에서 버려져 빌더에서는 보이는데 발행본에는 없다.
* ★ **문자열 그대로** 싣는다. 서버는 파싱하지 않는다 — 렌더러가 `parseSectionData()`
* (shared/lib/section-data.ts)로 읽고, 깨진 JSON 이면 그 섹션만 조용히 비운다.
*/
data?: string;
} }

View File

@ -12,7 +12,12 @@ import {
import {basename, dirname, join, resolve} from 'node:path'; import {basename, dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
import type {SitePayload} from '@o2o/shared'; 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 {render} from '@/entry-server';
import { import {
collectJsonLd, collectJsonLd,
@ -397,6 +402,27 @@ function countUniqueContent(payload: SitePayload): number {
const intro = payload.theme.sections.find((section) => section.id === 'intro'); const intro = payload.theme.sections.find((section) => section.id === 'intro');
if (intro?.enabled && long(intro.body)) count += 1; 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<Record<string, unknown>>(section.id, section.data);
for (const item of parsed.items) {
if (hasLongText(item)) count += 1;
}
}
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다. // 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) { for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
if (fact.type === 'text' && long(fact.value)) count += 1; if (fact.type === 'text' && long(fact.value)) count += 1;

View File

@ -38,13 +38,22 @@ html {
body { body {
background-color: var(--color-surface); background-color: var(--color-surface);
color: var(--color-ink); 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; font-feature-settings: "tnum" 1;
} }
@layer components { @layer components {
/* 제목 서체. 이름은 'serif' 로 남아 있지만 값은 템플릿이 정한다 —
레트로면 간판체, 심플이면 고딕이다. 토큰이 없으면 예전처럼 명조로 떨어진다. */
.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);
} }
/* 섹션 공통 폭 — 본문이 한 줄에 너무 길어지지 않게. */ /* 섹션 공통 폭 — 본문이 한 줄에 너무 길어지지 않게. */

View File

@ -2,6 +2,7 @@ import {
LinkChannel, LinkChannel,
PlaceCategory, PlaceCategory,
factText, factText,
parseSectionData,
sanitizeUnits, sanitizeUnits,
selectPublishable, selectPublishable,
selectPublishableFaqs, 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) ?? []; 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<T extends object>(payload: SitePayload, id: string) {
const section = payload.theme.sections.find((entry) => entry.id === id);
return parseSectionData<T>(id, section?.data);
}
export function unitSpec(payload: SitePayload) { export function unitSpec(payload: SitePayload) {
return UNIT_SPEC[payload.place.category]; return UNIT_SPEC[payload.place.category];
} }

View File

@ -8,6 +8,7 @@ import {
GallerySection, GallerySection,
HeroSection, HeroSection,
InquirySection, InquirySection,
ITEM_SECTIONS,
LocalGuideSection, LocalGuideSection,
WeatherSection, WeatherSection,
LocationSection, LocationSection,
@ -31,6 +32,7 @@ import {isSectionEnabled} from '@/lib/derive';
* 기본 레이아웃으로 그린다. 배리에이션 40종을 발행본에 옮기는 건 별도 작업이다. * 기본 레이아웃으로 그린다. 배리에이션 40종을 발행본에 옮기는 건 별도 작업이다.
* 렌더러가 모르는 키가 와도 화면이 깨지지 않아야 한다는 규칙(site-payload.ts)은 * 렌더러가 모르는 키가 와도 화면이 깨지지 않아야 한다는 규칙(site-payload.ts)은
* "값을 통째로 무시한다"로 이미 지켜진다. * "값을 통째로 무시한다"로 이미 지켜진다.
* ★ 붙여넣기 아이템(songs·daily·…)은 배리에이션이 타입당 하나뿐이라 지금은 어긋날 것이 없다.
*/ */
export function HomePage() { export function HomePage() {
const payload = useSite(); const payload = useSite();
@ -60,6 +62,9 @@ export function HomePage() {
weather: WeatherSection, weather: WeatherSection,
map: LocationSection, map: LocationSection,
faq: FaqSection, faq: FaqSection,
// 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
// ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다.
...ITEM_SECTIONS,
}; };
const rendered = new Set<string>(); const rendered = new Set<string>();

View File

@ -16,3 +16,5 @@ export {LocationSection} from './LocationSection';
export {FaqSection} from './FaqSection'; export {FaqSection} from './FaqSection';
export {SiteFooter} from './SiteFooter'; export {SiteFooter} from './SiteFooter';
export {MobileTabBar} from './MobileTabBar'; export {MobileTabBar} from './MobileTabBar';
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(theme.sections[].data).
export {ITEM_SECTIONS} from './items';

View File

@ -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<ChronicleItem>(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 (
<ItemSection
id="chronicle"
name={parsed.title || sectionName(payload, 'chronicle', '시간의 골목')}
subtitle={parsed.subtitle}
count={`붉은 점은 도시의 성격이 바뀐 해입니다 · ${turning}개 / 전체 ${items.length}개`}
>
<Rail label="연표">
{items.map((item, index) => {
const isTurning = item.turning === true;
return (
<article key={`${item.year ?? 'x'}-${item.title}-${index}`} className="w-[228px] shrink-0 snap-center">
<p className="serif text-3xl font-bold leading-none" style={{color: isTurning ? ITEM_ACCENT : undefined}}>
{item.year ?? '연도 미상'}
</p>
<div className="relative my-3 h-3">
<i
aria-hidden
className="absolute top-1/2 left-0 border-t"
// 마지막 해에서 선이 끊긴다 — 이어 두면 뒤가 더 있는 줄 안다.
style={{borderColor: ITEM_BORDER, right: index === items.length - 1 ? 'calc(100% - 12px)' : 0}}
/>
<i
aria-hidden
className="absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2"
style={{
backgroundColor: isTurning ? ITEM_ACCENT : ITEM_CARD,
borderColor: isTurning ? ITEM_ACCENT : ITEM_BORDER,
}}
/>
</div>
<div className="space-y-1.5 pr-5">
<h3 className="serif text-base font-bold">{item.title}</h3>
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
})}
</Rail>
</ItemSection>
);
}

View File

@ -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<CourseItem>(payload, 'course');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="course"
name={parsed.title || sectionName(payload, 'course', '반나절 산책')}
subtitle={parsed.subtitle}
>
<div className="space-y-10">
{parsed.items.map((course, courseIndex) => {
const stops = course.stops ?? [];
return (
<div key={`${course.name}-${courseIndex}`} className="space-y-3">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="serif text-lg font-bold">{course.name}</h3>
<span className="text-[11px] opacity-60">
{[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
<Rail label={`${course.name} 정거장`}>
{stops.map((stop, index) => {
const no = String(stop.order ?? index + 1).padStart(2, '0');
return (
<article
key={`${stop.name}-${index}`}
className="relative w-[236px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<div
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] opacity-70"
style={{borderColor: ITEM_INK}}
>
<span className="truncate">{course.name}</span>
<span className="shrink-0">NO.{no}</span>
</div>
<p className="serif px-4 pt-4 text-3xl font-bold leading-none" style={{color: ITEM_ACCENT}}>
{no}
</p>
<h4 className="serif px-4 pt-1.5 text-base font-bold">{stop.name}</h4>
{stop.note && <p className="px-4 pt-2 text-[13px] leading-relaxed opacity-80">{stop.note}</p>}
<div
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] opacity-60"
style={{borderColor: ITEM_BORDER}}
>
<span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span>
{stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>}
</div>
{/* 펀치 구멍 — 표를 표로 만드는 자리 */}
<i className="absolute -left-[7px] top-1/2 size-3 rounded-full border" style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}} />
<i className="absolute -right-[7px] top-1/2 size-3 rounded-full border" style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}} />
{index === stops.length - 1 && (
<span
className="serif absolute bottom-3 right-3 grid size-14 -rotate-12 place-items-center rounded-full border-2 text-center text-[11px] leading-tight opacity-75"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
완주
<br />
도장
</span>
)}
</article>
);
})}
</Rail>
<SourceLine source={course.source} verified={course.verified} />
</div>
);
})}
</div>
</ItemSection>
);
}

View File

@ -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<DailyItem>(payload, 'daily');
const [today, setToday] = useState<string>();
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 (
<ItemSection
id="daily"
name={parsed.title || sectionName(payload, 'daily', '오늘의 한 장')}
subtitle={parsed.subtitle}
count={tally(pages.length, parsed.unverified, '장')}
>
<Rail label="날짜별 이야기">
{pages.map((page, index) => {
const [month, day] = page.monthDay.split('-');
const isToday = today === page.monthDay;
return (
<article
key={`${page.monthDay}-${index}`}
className="w4-paper relative w-[254px] shrink-0 snap-center border"
style={{
backgroundColor: ITEM_CARD,
borderColor: isToday ? ITEM_ACCENT : ITEM_BORDER,
boxShadow: isToday ? '5px 6px 0 rgba(191,47,27,.18)' : '5px 6px 0 rgba(27,26,21,.13)',
}}
>
<div
className="w4-perf h-3.5 border-b border-dashed"
style={{['--tear' as string]: ITEM_CARD, borderColor: ITEM_BORDER}}
/>
<div className="border-b px-5 pb-3 pt-5 text-center" style={{borderColor: ITEM_BORDER}}>
<p className="text-[11px] tracking-[0.2em] opacity-60">{month}月</p>
<p className="serif mt-1 text-6xl font-bold leading-none" style={{color: ITEM_ACCENT}}>
{String(Number(day) || day)}
</p>
{isToday && <p className="mt-1.5 text-[10px] tracking-[0.3em]" style={{color: ITEM_ACCENT}}>오늘</p>}
</div>
<div className="space-y-2 px-5 pb-5 pt-4">
{page.category && <p className="text-[10px] tracking-[0.16em] opacity-60">{page.category}</p>}
<h3 className="serif text-base font-bold leading-snug">{page.title}</h3>
{page.body && <p className="text-[13px] leading-relaxed opacity-80">{page.body}</p>}
{page.tags && page.tags.length > 0 && (
<p className="text-[10px] opacity-55">{page.tags.join(' ')}</p>
)}
<div className="border-t border-dashed pt-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={page.source} verified={page.verified} />
</div>
</div>
</article>
);
})}
</Rail>
</ItemSection>
);
}

View File

@ -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<LiteratureItem>(payload, 'literature');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="literature"
name={parsed.title || sectionName(payload, 'literature', '문학 서가')}
subtitle={parsed.subtitle}
count={tally(parsed.items.length, parsed.unverified, '권')}
>
<Rail label="작품 목록">
{parsed.items.map((book, index) => (
<article
key={`${book.workTitle}-${index}`}
className="w4-paper flex w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
{/* 책등 — 세로쓰기가 이 아이템의 인상이다 */}
<p
className="serif shrink-0 px-2 py-4 text-sm font-bold"
style={{
backgroundColor: book.spineColor || FALLBACK_SPINE,
// 책등 색은 사장님이 정하므로 그 위 글자는 밝은 면 색으로 고정한다.
color: ITEM_INVERSE_INK,
writingMode: 'vertical-rl',
}}
>
{book.workTitle}
{book.author ? ` · ${book.author}` : ''}
</p>
<div className="min-w-0 space-y-2.5 p-4">
<h3 className="serif text-lg font-bold">{book.workTitle}</h3>
<p className="text-xs opacity-60">
{[book.author, book.year, book.genre].filter(Boolean).join(' · ')}
</p>
{book.background && <p className="text-sm leading-relaxed opacity-85">{book.background}</p>}
{book.whyHere && (
<p className="border-l-2 pl-3 text-sm leading-relaxed opacity-75" style={{borderColor: ITEM_BORDER}}>
{book.whyHere}
</p>
)}
<p className="border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 원문 대신 배경 — 작품 문장은 싣지 않습니다
</p>
<SourceLine source={book.source} verified={book.verified} />
</div>
</article>
))}
</Rail>
</ItemSection>
);
}

View File

@ -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<PeopleItem>(payload, 'people');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="people"
name={parsed.title || sectionName(payload, 'people', '인물 열전')}
subtitle={parsed.subtitle}
count={`${tally(parsed.items.length, parsed.unverified, '명')} · 사진이 없는 인물은 이름 활자로 대신합니다`}
dark
>
<div className="border border-white/10" style={{backgroundColor: ITEM_INVERSE}}>
<div className="w4-film-perf h-2.5 opacity-70" />
<div className="px-3 py-4">
<Rail label="인물 목록">
{parsed.items.map((person, index) => (
<article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center">
<span
className="serif grid aspect-3/4 w-full place-items-center border text-5xl"
// 프레임 안쪽은 필름보다 한 단 밝게 — 색을 박지 않고 글자색을 섞어 만든다.
style={{
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
aria-hidden
>
{person.name.trim().charAt(0)}
</span>
<h3 className="serif mt-3 text-base font-bold">
{person.name}
{person.aka && <span className="ml-1.5 text-xs opacity-60">호 {person.aka}</span>}
</h3>
<p className="mt-0.5 text-[11px] opacity-60">
{[person.role, person.years].filter(Boolean).join(' · ')}
</p>
{person.oneLine && <p className="mt-2 text-[13px] leading-relaxed opacity-85">{person.oneLine}</p>}
<div className="mt-2">
<SourceLine source={person.source} verified={person.verified} />
</div>
</article>
))}
</Rail>
</div>
<div className="w4-film-perf h-2.5 opacity-70" />
</div>
</ItemSection>
);
}

View File

@ -0,0 +1,145 @@
/**
* 계절별 추천 하루 — 발행본.
*
* ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 12월에 온 손님에게
* 봄·여름 코스까지 늘어놓으면 고를 것이 열두 개가 되고, 그건 추천이 아니라 목록이다.
* ★ 그런데 HTML 에는 **전 계절을 굽는다.** 두 가지 이유다 —
* ① 발행본은 정적이라 한 번 구우면 몇 달을 산다. 굽는 시점의 계절을 박으면 12월에도
* 가을 코스가 걸린다. 그래서 계절 판정은 **브라우저에서** 한다(일력의 '오늘'과 같은 수법).
* ② 이 사이트의 존재 이유가 인용이다. HTML 에서 지우면 검색·AI 가 나머지 계절을 못 읽는다.
* → 안 맞는 계절은 `hidden` 으로 접어 둔다. 문서에는 있고 화면에는 없다.
* ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다.
* 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다.
*/
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';
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 (
<article
className="w4-paper w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면
팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */}
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={
rank === 0
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{RANK_LABEL[rank] ?? `${rank + 1}위`}
</span>
<span className="text-[11px] opacity-60">
{day.from}–{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="serif text-lg font-bold">{item.name}</h4>
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
{item.why && <p className="text-[13px] leading-relaxed opacity-80">{item.why}</p>}
</div>
<ol className="mt-3 px-4 pb-3">
{day.stops.map((planned, index) => (
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */}
<span className="serif pt-2 text-[12px] tabular-nums opacity-75">{planned.time}</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && <p className="pt-1 text-[10px] opacity-50">↓ {planned.move}분 이동</p>}
<p className="pt-1 text-sm font-bold">{planned.stop.name}</p>
{planned.stop.note && (
<p className="mt-0.5 text-[12px] leading-relaxed opacity-75">{planned.stop.note}</p>
)}
<p className="mt-0.5 text-[10px] opacity-50">
{planned.time}–{planned.until}
{planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
<div className="border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
}
export function PlannerSection() {
const payload = useSite();
const parsed = sectionItems<PlannerItem>(payload, 'planner');
// ★ 서버 렌더에서는 undefined 다 — SSR 과 첫 렌더가 어긋나면 하이드레이션이 깨진다.
// 브라우저에 올라온 뒤에야 계절이 정해지고, 그때 안 맞는 묶음이 접힌다.
const [now, setNow] = useState<string[]>();
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 (
<ItemSection
id="planner"
name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')}
subtitle={parsed.subtitle}
count={
filtering
? `지금은 ${shown.join(' · ')} 코스입니다 · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`
: '시각은 출발 시각과 머무는 시간으로 계산한 것입니다'
}
>
<div className="space-y-10">
{groups.map((season) => {
const top = plannerTop(parsed.items, season);
if (top.length === 0) return null;
return (
<div
key={season ?? 'all'}
className="space-y-3"
// 문서에는 남기고 화면에서만 접는다 — 검색·AI 는 전 계절을 읽는다.
hidden={filtering && season !== undefined && !shown.includes(season)}
>
{season && <h3 className="serif text-lg font-bold">{season}</h3>}
<Rail label={`${season ?? '추천'} 코스`}>
{top.map((item, index) => (
<PlanCard key={`${item.name}-${index}`} item={item} rank={index} />
))}
</Rail>
</div>
);
})}
</div>
</ItemSection>
);
}

View File

@ -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<PostcardItem>(payload, 'postcard');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="postcard"
name={parsed.title || sectionName(payload, 'postcard', '오늘의 엽서')}
subtitle={parsed.subtitle}
count={tally(parsed.items.length, parsed.unverified, '장')}
>
<Rail label="엽서 목록">
{parsed.items.map((card, index) => (
<article
key={`${card.line}-${index}`}
className="w4-paper w-[304px] shrink-0 snap-center border p-4"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>
<p className="serif text-[17px] leading-snug">“{card.line}”</p>
{card.hashtags && card.hashtags.length > 0 && (
<p className="mt-2 text-[11px] opacity-60">{card.hashtags.join(' ')}</p>
)}
</div>
<div className="flex w-[62px] flex-col items-center gap-3">
<span
className="grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60"
style={{borderColor: ITEM_BORDER}}
>
郵票
<br />
10원
</span>
<span
className="serif grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
{card.postmark || card.place || '소인'}
</span>
</div>
</div>
<div className="mt-3 border-t border-dashed pt-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={card.source} verified={card.verified} />
</div>
</article>
))}
</Rail>
</ItemSection>
);
}

View File

@ -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<QuizItem>(payload, 'quiz');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="quiz"
name={parsed.title || sectionName(payload, 'quiz', '뒤집어 보는 질문')}
subtitle={parsed.subtitle}
count={`정답은 두지 않습니다 — 힌트와 출처까지만 · 총 ${parsed.items.length}문항`}
>
<Rail label="질문 목록">
{parsed.items.map((item, index) => (
<article
key={`${item.question}-${index}`}
className="w4-paper flex w-[262px] shrink-0 snap-center flex-col gap-3 border p-4"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<p className="text-[10px] tracking-[0.16em] opacity-60">
문제 {index + 1}
{item.level ? ` · ${item.level}` : ''}
</p>
<h3 className="serif text-[17px] font-bold leading-snug">{item.question}</h3>
{item.hint && (
<p className="border-t border-dashed pt-2.5 text-[13px] leading-relaxed opacity-80" style={{borderColor: ITEM_BORDER}}>
<span className="mr-1.5 text-[10px] tracking-[0.16em]" style={{color: ITEM_ACCENT}}>
힌트
</span>
{item.hint}
</p>
)}
<div className="mt-auto space-y-1">
{item.topic && <p className="text-[10px] opacity-55">{item.topic}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
))}
</Rail>
</ItemSection>
);
}

View File

@ -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<ScheduleItem>(payload, 'schedule');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="schedule"
name={parsed.title || sectionName(payload, 'schedule', '여행 스케줄')}
subtitle={parsed.subtitle}
>
<div className="space-y-10">
{parsed.items.map((schedule, scheduleIndex) => {
const slots = schedule.slots ?? [];
const span =
slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined;
return (
<div key={`${schedule.name}-${scheduleIndex}`} className="space-y-3">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="serif text-lg font-bold">{schedule.name}</h3>
<span className="text-[11px] opacity-60">
{[schedule.audience, schedule.season, span, `${slots.length}칸`].filter(Boolean).join(' · ')}
</span>
</div>
<Rail label={`${schedule.name} 시간표`}>
{slots.map((slot, index) => {
const {head, tail} = splitTime(slot.time ?? '');
return (
<article
key={`${slot.time}-${slot.title}-${index}`}
className="w-[228px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
{/* 플립보드는 템플릿의 어두운 면이다. 위의 글자는 밝은 면 색을 그대로 쓴다. */}
<div
className="relative px-4 py-3 text-center"
style={{backgroundColor: ITEM_INVERSE, color: ITEM_INVERSE_INK}}
>
<span className="serif text-3xl font-bold leading-none">
{head}
{tail && <span className="opacity-60">:{tail}</span>}
</span>
<i
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'color-mix(in oklab, currentColor 25%, transparent)'}}
/>
</div>
<div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="serif text-base font-bold">{slot.title}</h4>
{slot.place && (
<p className="text-[12px] font-semibold" style={{color: ITEM_ACCENT}}>
{slot.place}
</p>
)}
{slot.note && <p className="text-[13px] leading-relaxed opacity-80">{slot.note}</p>}
</div>
<div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] opacity-60"
style={{borderColor: ITEM_BORDER}}
>
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
</div>
</article>
);
})}
</Rail>
<SourceLine source={schedule.source} verified={schedule.verified} />
</div>
);
})}
</div>
</ItemSection>
);
}

View File

@ -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<SongItem>(payload, 'songs');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="songs"
name={parsed.title || sectionName(payload, 'songs', '가요 다방')}
subtitle={parsed.subtitle}
count={tally(parsed.items.length, parsed.unverified, '곡')}
>
<Rail label="곡 목록">
{parsed.items.map((song, index) => (
<article
key={`${song.title}-${index}`}
className="w4-paper w-[280px] shrink-0 snap-center border p-5"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="w4-disc mx-auto size-[132px] rounded-full"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
>
<span
className="block size-full rounded-full"
style={{boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.06)'}}
/>
</div>
<h3 className="serif mt-4 text-lg font-bold">{song.title}</h3>
<p className="mt-1 text-xs opacity-65">
{[
song.artist,
song.year ? String(song.year) : undefined,
song.lyricist || song.composer
? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}`
: undefined,
song.label,
]
.filter(Boolean)
.join(' · ')}
</p>
{song.story && <p className="mt-3 text-sm leading-relaxed opacity-85">{song.story}</p>}
{song.connection && (
<p className="mt-2 text-sm leading-relaxed" style={{color: ITEM_ACCENT}}>
{song.connection}
</p>
)}
<p className="mt-3 border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</p>
<div className="mt-2">
<SourceLine source={song.source} verified={song.verified} />
</div>
</article>
))}
</Rail>
</ItemSection>
);
}

View File

@ -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 (
<section
id={id}
aria-labelledby={`${id}-heading`}
className="w-full border-b"
style={{
backgroundColor: dark ? ITEM_INVERSE : 'var(--tpl-surface, #fafafa)',
color: dark ? ITEM_INVERSE_INK : ITEM_INK,
borderColor: 'color-mix(in oklab, currentColor 12%, transparent)',
paddingBlock: 'var(--tpl-section-space, 4rem)',
}}
>
<div className="shell">
<h2 id={`${id}-heading`} className="serif text-2xl tracking-tight sm:text-3xl lg:text-4xl">
{name}
</h2>
{subtitle && <p className="mt-2 text-sm opacity-70">{subtitle}</p>}
<div className="mt-8">{children}</div>
{count && <p className="mt-4 text-xs opacity-55">{count}</p>}
</div>
</section>
);
}
/** 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다. */
export function Rail({children, label}: {children: ReactNode; label: string}) {
return (
<div
role="group"
aria-label={label}
className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3"
>
{children}
</div>
);
}
/**
* 출처 한 줄 + 확신 배지.
*
* ★ 확인되지 않은 값을 숨기지 않고 드러낸다 — 발행본에서도 같다. 손님이 그 문장을
* 그대로 옮겨 적을 수 있어서, 어디까지가 확인된 것인지 화면이 말해야 한다.
* ★ 배지 색만은 토큰이 아니다. '확인/확인필요'는 디자인이 아니라 신호라, 팔레트를 따라가다
* 경고가 안 보이는 색이 되면 안 된다.
*/
export function SourceLine({source, verified}: {source?: DataSource; verified?: DataVerified}) {
if (!source && !verified) return null;
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70">
{verified && (
<span
className="border px-1.5 py-px"
// ★ 신호색에 둘레 글자색을 섞어 어두운 면에서도 읽히게 한다(빌더 캔버스와 같은 규칙).
style={{
color: `color-mix(in oklab, ${verified === '확인' ? '#2a6053' : '#b07d10'} 62%, currentColor)`,
borderColor: 'currentColor',
}}
>
{verified}
</span>
)}
{source?.name && (
<span>
출처 ·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
</p>
);
}
/** "총 N개 · 확인 필요 M" 각주. 단위(곡·장·명)는 아이템마다 다르다. */
export function tally(total: number, unverified: number, unit: string): string {
return `총 ${total}${unit}${unverified > 0 ? ` · 확인 필요 ${unverified}${unit}` : ''}`;
}

View File

@ -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<string, () => React.ReactElement | null> = {
songs: SongsSection,
daily: DailySection,
course: CourseSection,
schedule: ScheduleSection,
people: PeopleSection,
chronicle: ChronicleSection,
literature: LiteratureSection,
postcard: PostcardSection,
quiz: QuizSection,
planner: PlannerSection,
};

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
import type {SitePayload} from '@o2o/shared'; import {deriveSurfaces, type SitePayload} from '@o2o/shared';
import {collectJsonLd} from './jsonld'; import {collectJsonLd} from './jsonld';
import type {PageMeta} from './meta'; import type {PageMeta} from './meta';
@ -50,20 +50,72 @@ export interface HeadOptions {
} }
/** 템플릿 색을 CSS 변수로. 사장님이 고른 색이 여기서 실제 페이지 색이 된다. */ /** 템플릿 색을 CSS 변수로. 사장님이 고른 색이 여기서 실제 페이지 색이 된다. */
/**
* 템플릿 토큰을 <head> 에 심는다.
*
* ★ 색만 내려보내던 자리다. 그래서 사장님이 레트로(간판체·2px 테두리·갱지)를 골라도
* 발행 페이지는 늘 같은 고딕으로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
* 이제 `theme.look` 의 서체·모서리·테두리·여백까지 같이 심는다.
* ★ 면 토큰(surface/-alt/inverse/border)은 캔버스와 **같은 식**으로 유도한다(shared/lib/color).
* 식이 두 벌이면 미리보기와 발행본의 바탕색이 갈린다.
*/
export function themeStyle(payload: SitePayload): string { export function themeStyle(payload: SitePayload): string {
const {colors} = payload.theme; const {colors, look} = payload.theme;
return [ const surfaces = deriveSurfaces(colors);
' <style>', const lines = [
' :root {', `--tpl-primary: ${colors.primary};`,
` --tpl-primary: ${colors.primary};`, `--tpl-secondary: ${colors.secondary};`,
` --tpl-secondary: ${colors.secondary};`, `--tpl-bg: ${colors.bg};`,
` --tpl-bg: ${colors.bg};`, `--tpl-card: ${colors.card};`,
` --tpl-card: ${colors.card};`, `--tpl-text: ${colors.text};`,
` --tpl-text: ${colors.text};`, `--tpl-accent: ${colors.accent};`,
` --tpl-accent: ${colors.accent};`, `--tpl-surface: ${surfaces.surface};`,
' }', `--tpl-surface-alt: ${surfaces.surfaceAlt};`,
' </style>', `--tpl-inverse: ${surfaces.inverse};`,
].join('\n'); `--tpl-border: ${surfaces.border};`,
];
if (look) {
// ★ 값은 그대로 CSS 에 들어간다. `<`·`}` 이 섞이면 <style> 을 깨뜨릴 수 있어 걸러 낸다 —
// 서버가 해석하지 않고 실어 보내는 값이라 여기서 한 번은 봐야 한다.
const safe = (value?: string) => (value && !/[<>{};]/.test(value) ? value : undefined);
const pairs: [string, string | undefined][] = [
['--tpl-font-heading', safe(look.fontHeading)],
['--tpl-font-body', safe(look.fontBody)],
['--tpl-heading-tracking', safe(look.headingTracking)],
['--tpl-heading-weight', safe(look.headingWeight)],
['--tpl-section-space', safe(look.sectionSpace)],
['--tpl-border-width', safe(look.borderWidth)],
['--tpl-radius', safe(look.radius)],
['--tpl-shadow', safe(look.shadow)],
];
for (const [name, value] of pairs) if (value) lines.push(`${name}: ${value};`);
}
return [' <style>', ' :root {', ...lines.map((line) => ` ${line}`), ' }', ' </style>'].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 { export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOptions): string {
@ -124,7 +176,7 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption
lines.push( lines.push(
' <link rel="preconnect" href="https://fonts.googleapis.com" />', ' <link rel="preconnect" href="https://fonts.googleapis.com" />',
' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />', ' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
' <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@300..700&display=swap" rel="stylesheet" />', ` <link href="${escapeHtml(fontHref(payload))}" rel="stylesheet" />`,
// 기계용 파일을 head 에서도 가리킨다 — llms.txt 는 아직 표준이 아니라 링크로 힌트를 준다. // 기계용 파일을 head 에서도 가리킨다 — llms.txt 는 아직 표준이 아니라 링크로 힌트를 준다.
tag('link', {rel: 'sitemap', type: 'application/xml', href: `${base}/sitemap.xml`}), tag('link', {rel: 'sitemap', type: 'application/xml', href: `${base}/sitemap.xml`}),
tag('link', {rel: 'alternate', type: 'text/plain', href: `${base}/llms.txt`, title: 'LLM 요약'}), tag('link', {rel: 'alternate', type: 'text/plain', href: `${base}/llms.txt`, title: 'LLM 요약'}),