[feat] solution/frontend: 붙여넣기 아이템 셋 — 가요 다방·오늘의 한 장·반나절 산책

gunsan_365_story_db.xlsx 365행을 뜯어 보니 고유 주제는 52개이고 한 주제가 7회씩 돈다
(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 —
그래서 묶는 축을 주제로 잡고 날짜는 일력 한 장에만 썼다. 같은 파일 DB_Guide 가 가사·원문
전재를 금지해 가요 스키마에 lyrics 필드를 아예 두지 않았다. 없는 칸은 채울 수 없다.

- canvas/dataSpec.ts: 스키마·예시·프롬프트 단일 표. 레지스트리와 같은 결이라 한 줄을 더하면
  캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. 파싱은 절대 throw 하지 않는다 — 편집 중인 JSON 은
  늘 깨져 있고 그때 캔버스가 죽으면 고칠 방법이 없다
- shared/types/builder: SectionItem.data 는 파싱본이 아니라 원문 문자열이다. 파싱본만 들면
  JSON 이 깨진 순간 사장님이 쓴 걸 잃는다
- variants/{songs,daily,course}: 턴테이블·일력·승차권. 카드 격자를 쓰지 않고 전부 가로로 넘긴다
- RightTabsPanel: JSON 칸 + 프롬프트 복사/보기·예시 넣기·줄맞춤. 프롬프트에 상호와 주소에서 뽑은
  시·군·구를 박아 내보낸다 — 빈칸을 남기면 못 채우고 그대로 보내고 모델이 엉뚱한 곳을 지어낸다
- dataSpec.locate: JSON.parse 오류가 두 형식이다. position 형만 보면 조각 인용 형에서 위치를
  통째로 잃는다 — 조각을 원문에서 되찾아 센다
- SECTION_DATA_MAX_CHARS: 테마 상한 64KB(site_service._THEME_MAX_BYTES)를 세 섹션이 함께
  넘길 수 있고 거절은 발행 직전에야 드러난다. 화면에서 먼저 끊는다
- industryData: 네 업종에 꺼진 채로 넣는다. 내용 없이 켜져 있으면 발행본에 빈 섹션이 나간다
- index.html: Gugi·Gowun Batang·Nanum Pen Script. 없으면 고딕으로 떨어져 감성이 사라진다

발행 사이트(solution/site)는 아직 variantId 도 data 도 읽지 않는다 — 지금은 빌더 캔버스 전용이다.

tsc·eslint·vite build 통과(frontend·admin), 세 배리에이션 SSR 렌더 확인, 파서 경계 12건 확인
This commit is contained in:
Mina Choi 2026-09-01 17:58:58 +09:00
parent c250b656fd
commit 23162783f9
15 changed files with 1424 additions and 2 deletions

View File

@ -5,6 +5,40 @@
---
## 2026-09-01 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책
**한 일**
- 섹션 타입 3개 추가(`songs` · `daily` · `course`). 데이터가 수집(fact)이 아니라
**사장님이 붙여넣은 JSON** 에서 온다 — 새 갈래다.
- `canvas/dataSpec.ts` 신설: 스키마·예시·프롬프트가 한 표에 모인다. 배리에이션 레지스트리와 같은 결이라
여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다.
- `SectionItem.data?: string` 추가. **파싱본이 아니라 원문 문자열**을 담는다.
- [콘텐츠] 탭에 JSON 칸 + [프롬프트 복사] [프롬프트 보기] [예시 넣기] [줄맞춤].
- 업종 시드 넷 모두에 세 섹션을 **꺼진 채로** 넣었다.
**왜 이 모양인가**
`gunsan_365_story_db.xlsx`(365행)를 분석한 결과 **고유 주제는 52개고 한 주제가 7회씩 돈다**
(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 —
그래서 묶는 축을 주제로 잡고, 날짜는 일력 한 장에만 썼다.
같은 시트 `DB_Guide` 가 **가사·현대문학 원문 전재를 금지**해서 가요 스키마에 `lyrics` 필드를
아예 두지 않았다. 없는 칸은 채울 수 없다.
**밟은 함정**
- **테마 상한 64KB**(`site_service._THEME_MAX_BYTES`). 세 섹션이 각자 JSON 을 채우면 넘고,
거절은 발행 직전에야 드러난다. → `SECTION_DATA_MAX_CHARS`(12,000자)로 화면에서 먼저 끊는다.
- **`JSON.parse` 오류 메시지가 두 형식이다.** `position N (line L column C)` 형과, 위치 없이
깨진 조각만 인용하는 형. 앞의 것만 보면 후자에서 위치를 통째로 잃는다 — 조각을 원문에서 되찾아 센다.
- 파싱은 **절대 throw 하지 않는다.** 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
**아직 안 한 것**
- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다.
- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다).
**검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인,
파서 경계 12건 확인.
---
## 2026-09-01 — 설정을 `.env` 하나로 모았다
**한 일**

View File

@ -12,6 +12,11 @@
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&display=swap"
rel="stylesheet"
/>
<!-- 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 -->
<link
href="https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="/brand/favicon-w4a.svg?v=14" />
<meta name="theme-color" content="#1463ff" />
<title>Web4Ai · AI 웹 빌더</title>

View File

@ -22,6 +22,11 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
{ id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' },
// 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts).
// ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다.
{ id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' },
{ id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' },
{ id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' },
],
templates: [
@ -134,6 +139,11 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '드라이브 코스 및 주차 진입로 안내' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
{ id: 'local', type: 'local', name: '주변 나들이', isLocked: false, isEnabled: true, description: '양평 드라이브 코스 및 명소' },
// 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts).
// ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다.
{ id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' },
{ id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' },
{ id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' },
],
templates: [
@ -229,6 +239,11 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '지하철역 출구 및 발렛부스 위치' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
{ id: 'local', type: 'local', name: '주변 안내', isLocked: false, isEnabled: true, description: '청담 명품거리 및 갤러리 안내' },
// 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts).
// ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다.
{ id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' },
{ id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' },
{ id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' },
],
templates: [
@ -324,6 +339,11 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '이천 예스파크 진입로 및 주차장' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 체험장 주변 날씨' },
{ id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' },
// 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts).
// ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다.
{ id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' },
{ id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' },
{ id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' },
],
templates: [

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import {
AlertTriangle,
Check,
ClipboardCopy,
Image as ImageIcon,
Info,
FileText,
@ -10,7 +11,9 @@ import {
Pencil,
Plus,
Shapes,
Sparkles,
Trash2,
Wand2,
X,
} from 'lucide-react';
import type {InfoField} from '@o2o/shared';
@ -20,9 +23,16 @@ import {Input} from '@/components/ui/input';
import {PasteFactsPanel, RecollectPanel} from '@/features/onboarding';
import {cn} from '@/lib/utils';
import {useBuilderStore, useUnverifiedFields, type RightTab} from '@/stores/builder';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {FaqPanel} from './FaqPanel';
import {SectionDesignPanel} from './SectionDesignPanel';
import {resolveVariant} from './canvas/registry';
import {
buildPrompt,
dataSpecFor,
parseSectionData,
SECTION_DATA_MAX_CHARS,
} from './canvas/dataSpec';
const TABS: {id: RightTab; label: string; icon: typeof Info}[] = [
{id: 'content', label: '콘텐츠', icon: FileText},
@ -148,7 +158,8 @@ function ContentTab() {
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-xs leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
</label>}
{!supportsTitle && !supportsDescription && !supportsBody && (
{dataSpecFor(section.type) && <SectionDataPanel sectionId={section.id} sectionType={section.type} />}
{!supportsTitle && !supportsDescription && !supportsBody && !dataSpecFor(section.type) && (
<p className="rounded-md border border-dashed border-border p-3 text-center text-[11px] text-muted-foreground">
이 레이아웃에는 직접 입력하는 문구가 없습니다. 수집된 데이터만 표시합니다.
</p>
@ -160,6 +171,159 @@ function ContentTab() {
);
}
/**
* 붙여넣기 아이템의 JSON 입력.
*
* ★ 원문을 그대로 저장한다. 깨진 JSON 도 담아 두고, 왜 깨졌는지만 아래에 말한다 —
* 저장을 막으면 사장님은 고칠 기회 없이 쓰던 걸 잃는다.
*/
function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionType: string}) {
const spec = dataSpecFor(sectionType);
const sections = useBuilderStore((s) => s.sections);
const updateSectionData = useBuilderStore((s) => s.updateSectionData);
const storeName = useBuilderStore((s) => s.storeName);
const location = useBuilderStore((s) => s.location);
const industry = useBuilderStore((s) => s.industry);
const [copied, setCopied] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
const section = sections.find((item) => item.id === sectionId);
if (!spec || !section) return null;
const raw = section.data ?? '';
const parsed = parseSectionData(sectionType, raw);
const tooLong = raw.length > SECTION_DATA_MAX_CHARS;
// 상호·주소가 이미 박혀 있는 프롬프트. 사장님이 빈칸을 채울 일이 없어야 한다.
const promptText = buildPrompt(spec, {
storeName,
location,
industryLabel: INDUSTRY_CONFIGS[industry].name,
});
const copyPrompt = async () => {
const text = promptText;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
// 클립보드가 막힌 브라우저 — 프롬프트를 화면에 띄워 직접 긁을 수 있게 한다.
window.prompt('아래 프롬프트를 복사해 ChatGPT 에 붙여넣으세요', text);
}
};
/** 유효한 JSON 만 2칸 들여쓰기로 다시 쓴다. 깨져 있으면 손대지 않는다. */
const tidy = () => {
try {
updateSectionData(sectionId, JSON.stringify(JSON.parse(raw), null, 2));
} catch {
// 그대로 둔다 — 아래 오류 줄이 이미 어디가 깨졌는지 말하고 있다.
}
};
return (
<div className="space-y-2 rounded-md border border-border bg-muted/30 p-2.5">
<div className="flex items-center gap-1.5">
<Sparkles className="size-3.5 text-primary" />
<span className="text-xs font-bold">{spec.label} 내용 (JSON)</span>
</div>
<p className="text-[10px] leading-relaxed text-muted-foreground">
프롬프트를 복사해 ChatGPT·Claude 에 그대로 붙여넣으면 JSON 을 줍니다. 받은 JSON 을 아래에 붙여넣으세요.
</p>
<div className="flex flex-wrap gap-1.5">
<Button type="button" size="sm" variant="primary" className="h-7 gap-1 text-[11px]" onClick={copyPrompt}>
<ClipboardCopy className="size-3" />
{copied ? '복사됨' : '프롬프트 복사'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 gap-1 text-[11px]"
onClick={() => updateSectionData(sectionId, spec.sample)}
>
<Wand2 className="size-3" />
예시 넣기
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-[11px]"
onClick={() => setShowPrompt((v) => !v)}
>
{showPrompt ? '프롬프트 접기' : '프롬프트 보기'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px]"
onClick={tidy}
disabled={!raw.trim() || Boolean(parsed.error)}
>
줄맞춤
</Button>
</div>
{showPrompt && (
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground">
아래 내용이 그대로 복사됩니다. 상호와 지역은 이미 채워져 있습니다.
</p>
<textarea
readOnly
value={promptText}
rows={12}
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
className="w-full resize-y rounded-md border border-border bg-background/60 px-2.5 py-2 font-mono text-[10px] leading-relaxed text-muted-foreground outline-none"
/>
</div>
)}
<textarea
value={raw}
onChange={(event) => updateSectionData(sectionId, event.target.value)}
placeholder={`{ "kind": "${spec.kind}", "items": [ ... ] }`}
rows={10}
spellCheck={false}
className="w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 font-mono text-[11px] leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
{parsed.error ? (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
{parsed.error}
</p>
) : raw.trim() ? (
<p className="text-[10px] text-muted-foreground">
{spec.label} {parsed.items.length}개 · 출처 {parsed.sourced}개
{parsed.unverified > 0 && ` · 확인필요 ${parsed.unverified}개`}
</p>
) : null}
{parsed.kindMismatch && (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-warning">
<AlertTriangle className="mt-px size-3 shrink-0" />
이 JSON 은 &quot;{parsed.kindMismatch}&quot; 아이템 것입니다. {spec.label} 섹션에는 kind 가 &quot;{spec.kind}&quot; 인
JSON 을 넣어 주세요.
</p>
)}
{tooLong && (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
너무 깁니다({raw.length.toLocaleString()}자). {SECTION_DATA_MAX_CHARS.toLocaleString()}자 아래로 줄여
주세요 — 이 상태로는 디자인 설정이 서버에 저장되지 않습니다.
</p>
)}
</div>
);
}
function PhotosTab() {
const photos = useBuilderStore((s) => s.photos);
const setPrimaryPhoto = useBuilderStore((s) => s.setPrimaryPhoto);

View File

@ -0,0 +1,419 @@
/**
* 붙여넣기 아이템 계약 — "이 섹션 타입은 어떤 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 SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string;
label: string;
/** 없으면 그 줄을 통째로 버리는 키. 빈 껍데기가 화면에 줄만 남기는 걸 막는다. */
requiredKey: string;
/** [예시 넣기] 가 그대로 넣는 값. */
sample: string;
/** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */
task: string;
/** 그 아이템에만 걸리는 금지·형식 규칙. */
rules: string;
}
// ★ 테마 전체 상한이 64KB 다(site_service._THEME_MAX_BYTES). 세 아이템이 각자 다 채우면 거절당하고,
// 거절은 발행 직전에야 드러난다. 그래서 한 섹션당 여기서 먼저 끊는다.
export const SECTION_DATA_MAX_CHARS = 12000;
export interface PromptContext {
storeName: string;
/** 사업장 주소 원문. 여기서 시·군·구를 뽑아 '지역'으로 쓴다. */
location: string;
/** 업종 이름('숙박' · '카페' …). 어떤 손님에게 쓰는 글인지 알려 준다. */
industryLabel: string;
}
/**
* 주소 원문에서 시·군·구 한 조각.
*
* ★ 주소를 통째로 넣으면 모델이 그 동네 안쪽만 뒤진다. 이야깃거리는 시 단위에 있다.
*/
export function regionOf(location: string): string {
const token = location
.trim()
.split(/\s+/)
.find((word) => /[시군구]$/.test(word) && word.length >= 2);
return token ?? location.trim();
}
const PROMPT_RULES = `
[공통 규칙]
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.
3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.
4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. 애매한 걸 "확인" 으로 올리지 않는다.
5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.
6. 설명 문장은 항목당 두 문장을 넘기지 않는다.
7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.
`;
/**
* 붙여넣으면 바로 답이 나오는 프롬프트.
*
* ★ `[지역]` 같은 빈칸을 남기지 않는다. 빈칸이 있으면 사장님이 못 채우고 그대로 보내고,
* 모델은 빈칸을 지명으로 착각해 엉뚱한 곳 이야기를 지어낸다.
*/
export function buildPrompt(spec: SectionDataSpec, ctx: PromptContext): string {
const store = ctx.storeName.trim() || '(가게 이름을 먼저 입력해 주세요)';
const region = regionOf(ctx.location) || '(주소를 먼저 입력해 주세요)';
const address = ctx.location.trim();
const head = `너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다.
[업소] ${store} (${ctx.industryLabel})
[지역] ${region}${address && address !== region ? `\n[주소] ${address}` : ''}
아래 '해야 할 일'에서 [지역] = ${region}, [업소] = ${store}.
`;
return `${head}
${spec.task}
${PROMPT_RULES}${spec.rules}`;
}
export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
songs: {
kind: 'songs',
label: '가요 다방',
requiredKey: 'title',
sample: JSON.stringify(
{
kind: 'songs',
version: 1,
title: '가요 다방',
subtitle: '이 도시를 노래한 곡들',
items: [
{
title: '금강 나그네',
artist: '이미자',
lyricist: '이병훈',
composer: '박춘석',
year: 1966,
label: '지구레코드',
labelColor: '#d4551f',
story: '군산항을 모티브로 한 곡. 금강 하구와 항구의 이별 정서를 담았습니다.',
connection: '우리 스테이에서 걸어 12분, 내항 부잔교가 이 노래의 배경입니다.',
verified: '확인',
source: {name: '세계일보', url: 'https://www.segye.com/newsView/20160329000709'},
},
{
title: '님 떠난 군산항',
artist: '이미자',
labelColor: '#1d3c74',
story: '군산항의 이별 정서를 담은 곡. 발표 연도와 작사·작곡은 아직 확인 중입니다.',
verified: '확인필요',
source: {name: '세계일보', url: 'https://www.segye.com/newsView/20160329000709'},
},
{
title: '헤어진 군산항',
artist: '박제연',
labelColor: '#2a6053',
story: '1960년대 군산을 노래한 곡으로 전해집니다. 항구도시의 정서를 담았습니다.',
verified: '확인필요',
source: {name: '세계일보', url: 'https://www.segye.com/newsView/20160329000709'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.
1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.
[스키마]
{ "kind":"songs", "version":1, "title":"가요 다방", "subtitle":"...", "items":[
{ "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡",
"year":1966, "label":"음반사", "labelColor":"#d4551f",
"story":"곡의 배경 (두 문장 이내, 가사 없이)",
"connection":"[업소]와 이 곡을 잇는 한 문장",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.`,
},
daily: {
kind: 'daily',
label: '오늘의 한 장',
requiredKey: 'title',
sample: JSON.stringify(
{
kind: 'daily',
version: 1,
title: '오늘의 한 장',
items: [
{
monthDay: '09-01',
category: '장소',
title: '군산 내항 부잔교',
body: '조수 차가 큰 항구에서 배를 대기 위해 물 위에 띄운 다리입니다. 미곡 반출의 통로이기도 했습니다.',
season: '가을',
tags: ['#군산내항', '#부잔교'],
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
monthDay: '09-02',
category: '축제',
title: '군산시간여행축제',
body: '근대문화도시의 정체성을 축제로 풀어냅니다. 밤에 걷는 골목이 낮과 다릅니다.',
season: '가을',
tags: ['#시간여행축제', '#근대문화'],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
monthDay: '09-03',
category: '음식',
title: '이성당',
body: '오래된 빵집 하나가 도시의 이력이 된 자리입니다. 단팥빵 앞의 줄이 그 증거입니다.',
season: '가을',
tags: ['#이성당', '#오래된가게'],
verified: '확인',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]의 이야깃거리를 30개 찾아, 하루에 하나씩 뜯어 보는 일력용 JSON 으로 만든다.
같은 주제를 두 번 쓰지 않는다. 계절이 맞는 날짜에 배치한다(축제는 실제 개최 시기에).
[스키마]
{ "kind":"daily", "version":1, "title":"오늘의 한 장", "items":[
{ "monthDay":"09-02", "category":"역사|인물|장소|음식|축제|바다|문학",
"title":"제목", "body":"두 문장 이내", "season":"봄|여름|가을|겨울",
"tags":["#태그"], "verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· monthDay 는 MM-DD 다. 연도를 넣지 않는다 — 해마다 다시 쓰는 일력이다.
· 30개를 억지로 채우지 마라. 확실한 것이 12개면 12개만 낸다.
· 같은 장소를 계절만 바꿔 반복하지 않는다.`,
},
course: {
kind: 'course',
label: '반나절 산책',
requiredKey: 'name',
sample: JSON.stringify(
{
kind: 'course',
version: 1,
title: '반나절 산책',
items: [
{
name: '탁류길 코스',
duration: '3시간',
startsFrom: '스테이 머뭄',
stops: [
{order: 1, name: '채만식문학관', minutes: 12, note: '금강 하구 옆. 소설의 배경을 먼저 눈에 담습니다.', searchQuery: '군산 채만식문학관'},
{order: 2, name: '군산 내항 부잔교', minutes: 9, note: '조수에 오르내리던 뜬다리. 이 코스의 중심입니다.', searchQuery: '군산 내항 부잔교'},
{order: 3, name: '째보선창', minutes: 7, note: '항구 노동의 기억이 남은 선창입니다.', searchQuery: '군산 째보선창'},
{order: 4, name: '초원사진관', minutes: 11, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'},
{order: 5, name: '이성당', minutes: 6, note: '코스의 끝. 단팥빵 하나로 반나절을 닫습니다.', searchQuery: '군산 이성당'},
],
verified: '확인',
source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에서 출발하는 반나절 산책 코스를 2~3개 만든다. 코스마다 정거장 4~6곳.
걸어서 이동할 수 있는 순서로 배열하고, 앞 정거장에서 다음까지 걸리는 분을 minutes 에 적는다.
[스키마]
{ "kind":"course", "version":1, "title":"반나절 산책", "items":[
{ "name":"코스 이름", "duration":"3시간", "startsFrom":"[업소]",
"stops":[ {"order":1,"name":"장소","minutes":12,
"note":"한 문장","searchQuery":"검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 정거장에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.
· minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다.
· 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`,
},
};
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {
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

@ -63,6 +63,11 @@ import {ProgramsTable} from './variants/programs/ProgramsTable';
import {ExhibitionGallery} from './variants/exhibition/ExhibitionGallery';
import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(dataSpec.ts).
import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [
{
@ -397,6 +402,39 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
songs: [
{
id: 'songs.turntable',
name: '턴테이블',
description: '도넛판을 눌러 얹으면 판이 돈다. 곡 3~8개일 때 가장 좋다.',
thumb: 'carousel',
Component: SongsTurntable,
isDefault: true,
},
],
daily: [
{
id: 'daily.calendar',
name: '일력',
description: '오늘 날짜에 맞는 한 장이 자동으로 펼쳐진다. 매일 바뀌니 다시 올 이유가 된다.',
thumb: 'compact',
Component: DailyCalendar,
isDefault: true,
},
],
course: [
{
id: 'course.tickets',
name: '승차권',
description: '정거장 하나가 표 한 장. 마지막 표에 완주 도장이 찍힌다.',
thumb: 'carousel',
Component: CourseTickets,
isDefault: true,
},
],
exhibition: [
{
id: 'exhibition.gallery',

View File

@ -0,0 +1,172 @@
/**
* 반나절 산책 — 승차권 캐러셀.
*
* 표 한 장이 정거장 하나다. 절취선·펀치 구멍·완주 도장은 스탬프투어라는 실제 형식을 그대로 옮긴 것.
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type CourseItem, type CourseStop} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_LINE,
RETRO_PAPER,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine,
useCarousel,
} from '../retro/common';
import '../retro/retro.css';
function Ticket({
stop,
courseName,
index,
isLast,
}: {
stop: CourseStop;
courseName: string;
index: number;
isLast: boolean;
}) {
const no = String(stop.order ?? index + 1).padStart(2, '0');
return (
<div
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'}}
>
<div
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] text-stone-600"
style={{borderColor: '#1b1a15'}}
>
<span className="truncate">{courseName}</span>
<span className="shrink-0">NO.{no}</span>
</div>
<p className="px-4 pt-4 leading-none" style={{fontFamily: RETRO_SIGN, fontSize: 32, color: RETRO_RED}}>
{no}
</p>
<h4 className="px-4 pt-1.5 text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}>
{stop.name}
</h4>
{stop.note && (
<p className="px-4 pt-2 text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}>
{stop.note}
</p>
)}
<div
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
<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: RETRO_PAPER, borderColor: '#1b1a15'}}
/>
<i
className="absolute -right-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: '#1b1a15'}}
/>
{isLast && (
<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"
style={{fontFamily: RETRO_SIGN, borderColor: RETRO_RED, color: RETRO_RED}}
>
완주
<br />
도장
</span>
)}
</div>
);
}
function CourseRow({course}: {course: CourseItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const stops = course.stops ?? [];
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
{course.name}
</h3>
<span className="text-[11px] text-stone-500">
{[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{stops.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={course.name} />
)}
</div>
{stops.length === 0 ? (
<p className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500" style={{borderColor: RETRO_LINE}}>
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{stops.map((stop, index) => (
<Ticket
key={`${stop.name}-${index}`}
stop={stop}
courseName={course.name}
index={index}
isLast={index === stops.length - 1}
/>
))}
</div>
)}
<SourceLine source={course.source} verified={course.verified} />
</div>
);
}
export function CourseTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<CourseItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="반나절 산책" />
) : (
<div className="space-y-8">
{parsed.items.map((course, index) => (
<CourseRow key={`${course.name}-${index}`} course={course} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,162 @@
/**
* 오늘의 한 장 — 뜯어 넘기는 일력.
*
* 이 팩에서 유일하게 캐러셀이 아니다. '오늘'은 하나여야 하니까. 다만 어제·내일이 양옆에
* 반쯤 걸쳐 있어 넘길 수 있다는 걸 눈으로 알려 준다.
*/
import {useMemo, useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type DailyItem} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_LINE,
RETRO_PAPER,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine,
} from '../retro/common';
import '../retro/retro.css';
const DOW = ['일', '월', '화', '수', '목', '금', '토'];
function todayMonthDay(): string {
const now = new Date();
return `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/** MM-DD 를 올해 날짜로 읽어 요일을 낸다. 형식이 아니면 undefined — 지어내지 않는다. */
function dowOf(monthDay: string): string | undefined {
const m = /^(\d{2})-(\d{2})$/.exec(monthDay.trim());
if (!m) return undefined;
const date = new Date(new Date().getFullYear(), Number(m[1]) - 1, Number(m[2]));
if (Number.isNaN(date.getTime())) return undefined;
return DOW[date.getDay()];
}
function CalendarPage({item, muted}: {item: DailyItem; muted?: boolean}) {
const [month, day] = item.monthDay.split('-');
const dow = dowOf(item.monthDay);
return (
<div
className={`w4-paper relative w-[254px] shrink-0 border shadow-[5px_6px_0_rgba(27,26,21,.13)] transition-all ${
muted ? 'scale-[.93] opacity-45' : ''
}`}
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_LINE}}
>
{/* 뜯어낸 자국 */}
<div
className="w4-perf h-3.5 border-b border-dashed"
style={{['--tear' as string]: RETRO_PAPER, borderColor: RETRO_LINE}}
/>
<div className="absolute inset-x-0 top-0 flex justify-around px-10">
{[0, 1, 2].map((i) => (
<i
key={i}
className="-mt-1 size-2 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: RETRO_LINE}}
/>
))}
</div>
<div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: RETRO_LINE}}>
<p className="text-[11px] tracking-[0.2em] text-stone-500">{month}月</p>
<p className="mt-1 leading-none" style={{fontFamily: RETRO_SIGN, fontSize: 72, color: RETRO_RED}}>
{String(Number(day) || day)}
</p>
{dow && <p className="mt-1.5 text-[11px] tracking-[0.3em] text-stone-600">{dow}曜</p>}
</div>
<div className="space-y-2 px-5 pb-5 pt-4">
{item.category && (
<p className="text-[10px] tracking-[0.16em] text-emerald-800">{item.category}</p>
)}
<h3 className="text-base font-bold leading-snug text-stone-900" style={{fontFamily: RETRO_BODY}}>
{item.title}
</h3>
{item.body && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}>
{item.body}
</p>
)}
{item.tags && item.tags.length > 0 && (
<p className="text-[10px] text-stone-500">{item.tags.join(' ')}</p>
)}
<div className="border-t border-dashed pt-2.5" style={{borderColor: RETRO_LINE}}>
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</div>
);
}
export function DailyCalendar(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<DailyItem>(section.type, section.data);
const items = useMemo(
() => [...parsed.items].sort((a, b) => a.monthDay.localeCompare(b.monthDay)),
[parsed.items],
);
// 오늘 날짜가 목록에 없으면 오늘 **이후 첫 장**을 편다 — 지난 날짜를 오늘로 내걸지 않는다.
const startIndex = useMemo(() => {
if (items.length === 0) return 0;
const today = todayMonthDay();
const exact = items.findIndex((item) => item.monthDay === today);
if (exact >= 0) return exact;
const upcoming = items.findIndex((item) => item.monthDay >= today);
return upcoming >= 0 ? upcoming : 0;
}, [items]);
const [offset, setOffset] = useState(0);
const index = items.length > 0 ? (startIndex + offset + items.length * 100) % items.length : 0;
const at = (delta: number) => items[(index + delta + items.length) % items.length];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_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="w4-scroll flex items-start justify-center gap-5 overflow-x-auto pb-3">
{items.length > 1 && <CalendarPage item={at(-1)} muted />}
<CalendarPage item={items[index]} />
{items.length > 1 && <CalendarPage item={at(1)} muted />}
</div>
{items.length > 1 && (
<CarouselNav
onPrev={() => setOffset((v) => v - 1)}
onNext={() => setOffset((v) => v + 1)}
label="날짜"
/>
)}
<p className="text-center text-[11px] text-stone-500">
오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`}
</p>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,153 @@
/**
* 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

@ -0,0 +1,69 @@
/**
* 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

@ -0,0 +1,160 @@
/**
* 가요 다방 — 턴테이블 하나 + 도넛판 캐러셀.
*
* 판을 누르면 톤암이 내려오고 판이 돈다. 가사를 못 싣는 제약(DB_Guide: 원문 전재 금지)을
* 숨기지 않고 라벨 아래 각인으로 드러낸다 — 제약이 곧 이 아이템의 인상이다.
*/
import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type SongItem} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_SIGN,
SourceLine,
useCarousel,
} from '../retro/common';
import '../retro/retro.css';
const FALLBACK_LABEL = '#d4551f';
export function SongsTurntable(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props;
const parsed = parseSectionData<SongItem>(section.type, section.data);
const [playing, setPlaying] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 곡으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[playing] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
<SectionBody width="wide">
<div className="space-y-1.5">
<p className="text-[11px] tracking-[0.28em] text-amber-500/90">33⅓ RPM</p>
<h2 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-400" style={{fontFamily: RETRO_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<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="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="w4-disc w4-spin absolute inset-2 rounded-full"
style={{['--lbl' as string]: current.labelColor || FALLBACK_LABEL}}
>
<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>
{/* 톤암 — 곡이 얹혀 있으니 항상 내려와 있다. */}
<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 className="absolute left-0 -top-1 h-4 w-4 rounded-sm bg-stone-700" />
<div className="absolute -top-2 right-0 size-6 rounded-full bg-radial from-stone-300 to-stone-600" />
</div>
</div>
{/* 지금 도는 곡 */}
<div className="min-w-0 space-y-3">
<p className="text-[10px] tracking-[0.24em] text-amber-500/90">
A면 · {playing + 1} / {parsed.items.length}
</p>
<h3 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{current.title}
</h3>
<p className="text-xs text-stone-400">
{[
current.artist,
current.year ? String(current.year) : undefined,
current.lyricist || current.composer
? `작사 ${current.lyricist ?? '미상'} / 작곡 ${current.composer ?? '미상'}`
: undefined,
current.label,
]
.filter(Boolean)
.join(' · ')}
</p>
{current.story && (
<p
className="max-w-[46ch] text-sm leading-relaxed text-stone-300"
style={{fontFamily: RETRO_BODY}}
>
{current.story}
</p>
)}
{current.connection && (
<p
className="max-w-[46ch] text-sm leading-relaxed"
style={{fontFamily: RETRO_BODY, color: template.colors.accent}}
>
{current.connection}
</p>
)}
<span className="inline-block border border-dashed border-stone-600 px-2 py-1 text-[10px] tracking-[0.1em] text-stone-400">
◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</span>
<SourceLine source={current.source} verified={current.verified} tone="dark" />
</div>
</div>
{/* 판 고르기 */}
<div className="space-y-2">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
{parsed.items.map((song, index) => (
<button
key={`${song.title}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setPlaying(index);
}}
aria-current={index === playing}
className="w-24 shrink-0 snap-center text-center"
>
<span
className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
boxShadow:
index === playing
? '0 0 0 2px #c9930f, 0 3px 10px rgba(0,0,0,.45)'
: undefined,
}}
/>
<span className="mt-2 block truncate text-[10px] leading-tight text-stone-400">
{song.title}
</span>
</button>
))}
</div>
{parsed.items.length > 3 && (
<CarouselNav
onPrev={() => scrollBy(-1)}
onNext={() => scrollBy(1)}
tone="dark"
label="곡"
/>
)}
</div>
</>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -7,6 +7,7 @@
*/
import type {IndustryType, SectionItem, TemplateItem} from '@o2o/shared';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {dataSpecFor} from '@/features/builder/canvas/dataSpec';
import type {SectionRenderProps} from '@/features/builder/canvas/types';
import type {SectionVariant} from '@/features/builder/canvas/types';
import {asTemplate, type TokenGroup} from './tokenGroups';
@ -23,8 +24,10 @@ export function baseTemplate(industry: IndustryType): TemplateItem {
* 없으면 최소 형태로 지어낸다 — 그 업종 메뉴엔 없지만 배리에이션은 존재하는 경우다.
*/
function sectionFor(sectionType: string, industry: IndustryType, variantId: string): SectionItem {
// 붙여넣기 아이템은 JSON 이 없으면 "붙여넣으세요" 안내만 뜬다 — 디자인을 보러 온 화면에선 예시를 얹는다.
const sample = dataSpecFor(sectionType)?.sample;
const found = INDUSTRY_CONFIGS[industry].sections.find((s) => s.type === sectionType);
if (found) return {...found, isEnabled: true, variantId};
if (found) return {...found, isEnabled: true, variantId, ...(sample ? {data: sample} : {})};
return {
id: sectionType,
type: sectionType,
@ -32,6 +35,7 @@ function sectionFor(sectionType: string, industry: IndustryType, variantId: stri
isLocked: false,
isEnabled: true,
variantId,
...(sample ? {data: sample} : {}),
};
}

View File

@ -48,6 +48,8 @@ export interface SiteThemePayload {
variantId?: string;
description?: string;
body?: string;
/** 붙여넣기 아이템의 원문 JSON. 서버는 이 값도 해석하지 않고 그대로 보관한다. */
data?: string;
}[];
customInfoFields?: InfoField[];
visiblePhotoIds?: string[];
@ -79,6 +81,7 @@ export function toThemePayload(
locked: s.isLocked,
...(s.description ? {description: s.description} : {}),
...(s.body ? {body: s.body} : {}),
...(s.data ? {data: s.data} : {}),
...(s.variantId ? {variantId: s.variantId} : {}),
})),
customInfoFields: infoFields.filter((field) => field.id.startsWith('custom_')),

View File

@ -126,6 +126,8 @@ interface BuilderState {
toggleSection: (id: string) => void;
reorderSection: (fromIndex: number, toIndex: number) => void;
updateSectionContent: (sectionId: string, patch: Pick<SectionItem, 'name' | 'description' | 'body'>) => void;
/** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */
updateSectionData: (sectionId: string, data: string) => void;
setSectionVariant: (sectionId: string, variantId: string) => void;
resetSectionVariants: () => void;
/** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */
@ -512,6 +514,15 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
persistTheme();
},
updateSectionData: (sectionId, data) => {
set((state) => ({
sections: state.sections.map((section) =>
section.id === sectionId ? {...section, data} : section,
),
}));
persistTheme();
},
// 레이아웃만 갈아끼운다 — 섹션의 내용·순서·노출 여부는 건드리지 않는다.
// 그래야 사장님이 "이 모양 저 모양" 눌러보다가 편집한 걸 잃지 않는다.
setSectionVariant: (sectionId, variantId) => {
@ -562,6 +573,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
name: saved.name || seed.name,
description: saved.description ?? seed.description,
body: saved.body ?? seed.body,
data: saved.data ?? seed.data,
// 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도
// SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다.
isEnabled: seed.isLocked ? true : saved.enabled,

View File

@ -37,6 +37,13 @@ export interface SectionItem {
description?: string;
/** 사장님이 직접 편집하는 섹션 본문. 수집된 값과 분리해 자동 수집이 덮어쓰지 않는다. */
body?: string;
/**
* 붙여넣기 아이템(가요·일력·코스)의 원문 JSON.
*
* ★ 파싱해서 넣지 않고 **문자열 그대로** 둔다. 편집 중인 JSON 은 늘 깨져 있고,
* 파싱본만 들고 있으면 깨진 순간 사장님이 쓴 걸 잃는다.
*/
data?: string;
/**
* 이 섹션을 어떤 레이아웃으로 그릴지 — 사장님이 [디자인] 탭에서 고른 값.
* 비어 있으면 렌더러가 해당 타입의 기본 배리에이션으로 떨어진다(빈 화면을 만들지 않는다).