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: '19021950',
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":"19021950", "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: '19371938 연재',
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" · "19371938 연재" 처럼 원문 그대로. 숫자로 좁히면 연재물이 안 들어간다. */
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:3016: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 요약'}),