Merge branch 'main' into feature/auth

# Conflicts:
#	docs/DEVLOG.md
This commit is contained in:
Mina Choi 2026-09-02 21:34:12 +09:00
commit cfec47230c
47 changed files with 3581 additions and 574 deletions

View File

@ -5,6 +5,84 @@
--- ---
## 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 — 회원가입과 구글 로그인 ## 2026-09-02 — 회원가입과 구글 로그인
**한 일** **한 일**
@ -47,8 +125,6 @@
`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터 `email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과. 상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과.
---
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜** **왜**

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

@ -87,9 +87,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

@ -105,8 +105,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,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,194 @@
/**
* 계절별 추천 하루 — 계절 탭 + 순위 카드.
*
* ★ 여행 스케줄(schedule)과 축이 다르다. 저쪽은 사장님이 시각을 적어 둔 시간표고,
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
*/
import {useMemo, useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
const RANK_LABEL = ['1위', '2위', '3위'];
/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */
function spanText(minutes: number): string {
const hour = Math.floor(minutes / 60);
const rest = minutes % 60;
return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분';
}
function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
const day = planDay(item);
return (
<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 [picked, setPicked] = useState(0);
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
const season = seasons[picked] ?? 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, index) => (
<button
key={name}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(index);
}}
aria-current={index === picked}
className={cn('border px-3 py-1 text-xs transition-opacity', index !== picked && 'opacity-55')}
style={
index === picked
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{name}
</button>
))}
</div>
)}
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
{top.map((item, index) => (
<PlanCard key={`${item.name}-${index}`} item={item} rank={index} />
))}
</div>
<p className="text-[11px] opacity-60">
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
{top.length}개 / 전체 {parsed.items.length}개
</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

@ -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,411 @@
/**
* 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 JSON 은 어떤 모양인가".
*
* ★ 왜 shared 인가
* 같은 JSON 을 두 렌더러가 읽는다 — 빌더 캔버스(solution/frontend)와 발행 사이트(solution/site).
* 파서를 각자 두면 "빌더에서는 보이는데 발행하면 없다"가 조용히 생긴다(슬러그 규칙과 같은 함정).
* 프롬프트·예시·라벨처럼 **쓰는 쪽**만 필요한 것은 빌더에 남는다(`canvas/dataSpec.ts`).
*/
/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */
export type DataVerified = '확인' | '확인필요';
export interface DataSource {
name: string;
url?: string;
}
export interface SongItem {
title: string;
artist?: string;
lyricist?: string;
composer?: string;
year?: number;
label?: string;
labelColor?: string;
story?: string;
connection?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface DailyItem {
monthDay: string;
category?: string;
title: string;
body?: string;
season?: string;
tags?: string[];
verified?: DataVerified;
source?: DataSource;
}
export interface CourseStop {
order?: number;
name: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface CourseItem {
name: string;
duration?: string;
startsFrom?: string;
stops?: CourseStop[];
verified?: DataVerified;
source?: DataSource;
}
export interface ScheduleSlot {
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
time: string;
title: string;
place?: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface ScheduleItem {
name: string;
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
audience?: string;
season?: string;
slots?: ScheduleSlot[];
verified?: DataVerified;
source?: DataSource;
}
export interface PeopleItem {
name: string;
/** 호·예명. 본명 옆에 나란히 불리는 이름이 있으면 프레임 아래 각인으로 붙는다. */
aka?: string;
years?: string;
role?: string;
oneLine?: string;
/**
* 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고,
* 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약).
*/
imageQuery?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface ChronicleItem {
/** 연도가 없으면 레일 끝으로 밀린다 — 순서를 지어내지 않는다. */
year?: number;
title: string;
summary?: string;
place?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
verified?: DataVerified;
source?: DataSource;
}
export interface LiteratureItem {
workTitle: string;
author?: string;
/** "1937" · "1937–1938 연재" 처럼 원문 그대로. 숫자로 좁히면 연재물이 안 들어간다. */
year?: string;
genre?: string;
spineColor?: string;
background?: string;
whyHere?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface PostcardItem {
line: string;
hashtags?: string[];
place?: string;
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
postmark?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface QuizItem {
question: string;
/** ★ answer 는 없다. 이 데이터는 검증되지 않은 줄이 더 많아서, 정답을 단정하면 틀린 걸 단정한다. */
hint?: string;
topic?: string;
level?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface PlannerStop {
name: string;
/** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */
minutes?: number;
/** 앞 칸에서 여기까지 오는 시간(분). 첫 칸은 업소에서 나서는 시간이다. */
moveMinutes?: number;
note?: string;
searchQuery?: string;
}
export interface PlannerItem {
name: string;
/** '봄' · '여름' · '가을' · '겨울'. 화면의 계절 탭이 이 값에서 파생된다. */
season?: string;
/** 그 계절 안에서의 순위. 1·2·3 만 쓴다 — 4위부터는 아무도 안 고른다. */
rank?: number;
/** 하루가 시작하는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */
startTime?: string;
audience?: string;
/** 왜 이 계절에 이 코스인가. 순위를 납득시키는 한 문장. */
why?: string;
stops?: PlannerStop[];
verified?: DataVerified;
source?: DataSource;
}
/**
* 섹션 타입 → 없으면 그 줄을 통째로 버리는 키.
*
* ★ 이 표가 곧 "붙여넣기 아이템이 무엇무엇인가"의 목록이다. 여기 없는 타입의 data 는 파싱하지 않는다.
* 빈 껍데기(제목 없는 줄)가 화면에 줄만 남기는 걸 막는 자리이기도 하다.
*/
export const SECTION_ITEM_REQUIRED_KEY: Record<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,
};
}
/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */
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,119 @@
/**
* 계절별 추천 하루 — 발행본.
*
* ★ 캔버스는 계절 탭으로 하나씩 보여주지만, 발행본은 **계절 전부를 편다.**
* 탭 뒤에 숨은 계절은 HTML 에 없는 것과 같고, 이 사이트의 존재 이유가 인용이다.
* ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다.
* 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다.
*/
import {planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common';
const RANK_LABEL = ['1위', '2위', '3위'];
/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */
function spanText(minutes: number): string {
const hour = Math.floor(minutes / 60);
const rest = minutes % 60;
return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분';
}
function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
const day = planDay(item);
return (
<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');
if (parsed.items.length === 0) return null;
const seasons = plannerSeasons(parsed.items);
// 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다.
const groups = seasons.length > 0 ? seasons : [undefined];
return (
<ItemSection
id="planner"
name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')}
subtitle={parsed.subtitle}
count="시각은 출발 시각과 머무는 시간으로 계산한 것입니다"
>
<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">
{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 요약'}),