[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:
parent
c250b656fd
commit
23162783f9
@ -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` 하나로 모았다
|
||||
|
||||
**한 일**
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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 은 "{parsed.kindMismatch}" 아이템 것입니다. {spec.label} 섹션에는 kind 가 "{spec.kind}" 인
|
||||
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);
|
||||
|
||||
419
solution/frontend/src/features/builder/canvas/dataSpec.ts
Normal file
419
solution/frontend/src/features/builder/canvas/dataSpec.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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_')),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -37,6 +37,13 @@ export interface SectionItem {
|
||||
description?: string;
|
||||
/** 사장님이 직접 편집하는 섹션 본문. 수집된 값과 분리해 자동 수집이 덮어쓰지 않는다. */
|
||||
body?: string;
|
||||
/**
|
||||
* 붙여넣기 아이템(가요·일력·코스)의 원문 JSON.
|
||||
*
|
||||
* ★ 파싱해서 넣지 않고 **문자열 그대로** 둔다. 편집 중인 JSON 은 늘 깨져 있고,
|
||||
* 파싱본만 들고 있으면 깨진 순간 사장님이 쓴 걸 잃는다.
|
||||
*/
|
||||
data?: string;
|
||||
/**
|
||||
* 이 섹션을 어떤 레이아웃으로 그릴지 — 사장님이 [디자인] 탭에서 고른 값.
|
||||
* 비어 있으면 렌더러가 해당 타입의 기본 배리에이션으로 떨어진다(빈 화면을 만들지 않는다).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user