Merge branch 'main' into feature/auth
텍스트 충돌은 없었지만 **로그인 관문이 두 겹**이 됐다. 같은 문제를 오늘 두 자리에서 풀었다.
- main 969fb67 : 에디터 진입(6단계)에서 받는다 — 위저드 1~5단계는 열어 둔다
- 이 브랜치 : /builder 문 앞에서 받는다 — 위저드 진입부터 계정을 요구한다
지금은 문 앞 게이트가 먼저 걸리므로 EditorSignInGate 는 세션이 도중에 끊긴 경우에만 뜬다.
둘 중 하나를 고르는 건 제품 결정이라 코드로 정하지 않았다. 에디터 게이트만 남기려면
app/router.tsx 의 RequireAuth 한 겹을 벗기면 된다.
남은 중복: features/auth/SignInForm 과 lib/session 이 'signIn → me' 순서를 각자 들고 있다.
게이트 결정이 난 뒤 한 벌로 합친다.
pytest 527 passed / 8 failed(전부 기존 실패). tsc·eslint·vite build 통과.
This commit is contained in:
commit
e8e695c5a9
@ -43,6 +43,85 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
|
||||
|
||||
**왜**
|
||||
업종마다 템플릿이 다섯이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다.
|
||||
고르는 화면의 미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 —
|
||||
사장님은 뭐가 다른지 알 수 없으니 아무거나 골랐다. 사용자 말: "가라 UI 로 되어 있어서 뭐가뭔지 모르겠음".
|
||||
|
||||
**한 일**
|
||||
- `TemplateItem.look`(`TemplateLook`) 신설: 제목·본문 서체, 모서리, 테두리 두께, 그림자,
|
||||
제목 자간·굵기, 섹션 여백. **CSS 에 그대로 들어가는 문자열**로 들고 있다 — 숫자로 두면
|
||||
쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다.
|
||||
- 업종당 **3개**로 정리: 심플(고딕·둥근·그림자) · 매거진(명조 제목·각짐·그림자 없음·여백 큼) ·
|
||||
레트로(간판체·2px 테두리·오프셋 그림자·갱지). `templatesFor()` 팩토리 하나가 찍어내고
|
||||
**업종은 accent 하나만 바꾼다** — 생김새는 업종이 아니라 취향의 문제다.
|
||||
`industryData.ts` 537줄 → 237줄.
|
||||
- 고르는 화면의 미리보기를 **그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다**(`TemplatePreview`).
|
||||
|
||||
**핵심 수법 — Tailwind 테마 변수를 캔버스 안에서만 덮는다**
|
||||
`.site-canvas` 에 `--radius-*` · `--shadow-*` 를 내려보내면, 변이 파일 40여 개에 흩어진
|
||||
`rounded-*` · `shadow-*` 를 **한 줄도 안 고치고** 전부 템플릿을 따르게 된다.
|
||||
배수는 Tailwind 기본 비율을 그대로 옮겨, 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다.
|
||||
|
||||
**밟은 함정**
|
||||
- `.site-canvas` 는 이미 `--tpl-font-heading/body` 를 **읽고 있었는데 아무도 넣지 않았다.**
|
||||
서체가 갈리지 않던 진짜 이유가 이 빠진 고리였다.
|
||||
- 간판체(Gugi)는 굵기가 한 벌뿐이라 `font-weight:700` 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다.
|
||||
→ `--tpl-heading-weight` 로 템플릿이 400 을 지정할 수 있게 했다.
|
||||
- `Noto Serif KR` 을 안 불러오고 있었다. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다.
|
||||
- 옛 템플릿 id(`stay-warm-wood` 등)가 DB 에 남아 있어도 `resolveTemplate` 이 첫 템플릿으로 떨어뜨린다.
|
||||
|
||||
**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site). 템플릿 12벌의 look 전량 대조,
|
||||
옛 id 폴백·레트로만 아이템을 데려오는지 확인.
|
||||
|
||||
---
|
||||
|
||||
## 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 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
|
||||
|
||||
**섹션 관리에 붙인 것**
|
||||
- 좌측 패널 하단 **[+ 섹션 추가]** → 목록에서 골라 넣는다. 시드에 박아 두지 않는 이유는,
|
||||
붙여넣기 아이템은 내용이 없으면 빈 칸이라 아무도 안 쓰는 항목이 늘 붙어 있게 되기 때문이다.
|
||||
- 나중에 넣은 섹션만 휴지통으로 뺄 수 있다(업종 기본 섹션은 스위치로 끈다).
|
||||
- **레트로 템플릿**(업종마다 하나: 옛 항구 · 옛 다방 · 노포 · 시간여행)을 고르면 세 아이템이 함께 들어온다.
|
||||
`TemplateItem.defaultSectionTypes` 가 그 계약이고, **넣기만 하고 빼지 않는다** —
|
||||
템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 자기가 지웠다고 생각한다.
|
||||
- 저장 payload 에 `type` 을 실었다. 시드에 없는 섹션은 복원 때 `id` 로 못 찾아 **통째로 버려졌다**
|
||||
(사장님이 채운 JSON 까지 같이). 이제 `type` 으로 되살린다.
|
||||
|
||||
**아직 안 한 것**
|
||||
- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다.
|
||||
- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다).
|
||||
|
||||
**검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인,
|
||||
파서 경계 12건 + 추가·삭제·템플릿·저장복원 왕복 12건 확인.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-01 — 설정을 `.env` 하나로 모았다
|
||||
|
||||
**한 일**
|
||||
|
||||
@ -9,7 +9,12 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&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" />
|
||||
|
||||
@ -1,4 +1,113 @@
|
||||
import type {IndustryData, IndustryType} from '@o2o/shared';
|
||||
import type {IndustryData, IndustryType, TemplateItem} from '@o2o/shared';
|
||||
/**
|
||||
* 템플릿 세 벌.
|
||||
*
|
||||
* ★ 예전엔 업종마다 다섯 벌이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다 —
|
||||
* 골라도 뭐가 달라지는지 알 수 없었다. 지금은 서체·모서리·테두리·그림자·여백까지 갈린다.
|
||||
* 같은 얼굴을 색만 바꿔 늘리지 않는다.
|
||||
* ★ 업종이 바꾸는 건 accent 하나다. 생김새는 업종이 아니라 취향의 문제다.
|
||||
*/
|
||||
const LOOK = {
|
||||
simple: {
|
||||
fontHeading: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
|
||||
fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
|
||||
radius: '0.75rem',
|
||||
borderWidth: '1px',
|
||||
shadow: '0 1px 2px rgb(0 0 0 / 0.06)',
|
||||
headingTracking: '-0.02em',
|
||||
headingWeight: '700',
|
||||
sectionSpace: '3.5rem',
|
||||
},
|
||||
magazine: {
|
||||
fontHeading: "'Noto Serif KR', 'Batang', 'Times New Roman', serif",
|
||||
fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
|
||||
radius: '0px',
|
||||
borderWidth: '1px',
|
||||
shadow: 'none',
|
||||
headingTracking: '-0.03em',
|
||||
headingWeight: '700',
|
||||
sectionSpace: '5rem',
|
||||
},
|
||||
retro: {
|
||||
fontHeading: "'Gugi', 'Noto Sans KR', sans-serif",
|
||||
fontBody: "'Gowun Batang', 'Noto Serif KR', serif",
|
||||
radius: '0px',
|
||||
borderWidth: '2px',
|
||||
shadow: '4px 4px 0 rgb(27 26 21 / 0.16)',
|
||||
// 간판체는 자간을 벌리면 글자가 흩어진다. 굵기도 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다.
|
||||
headingTracking: '0em',
|
||||
headingWeight: '400',
|
||||
sectionSpace: '4rem',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** 업종 하나의 템플릿 세 벌. accent 만 업종이 정한다. */
|
||||
function templatesFor(
|
||||
industryId: IndustryType,
|
||||
accent: string,
|
||||
retro: {name: string; description: string},
|
||||
): TemplateItem[] {
|
||||
return [
|
||||
{
|
||||
id: `${industryId}-simple`,
|
||||
industryId,
|
||||
name: '심플',
|
||||
tone: 'info',
|
||||
toneLabel: '깔끔한 기본',
|
||||
description: '흰 바탕에 고딕. 읽기 쉽고 어디에도 어울립니다. 무엇을 고를지 모르겠으면 이것.',
|
||||
colors: {
|
||||
primary: '#18181b',
|
||||
secondary: '#52525b',
|
||||
bg: '#ffffff',
|
||||
card: '#fafafa',
|
||||
text: '#09090b',
|
||||
accent,
|
||||
},
|
||||
fontStyle: '고딕',
|
||||
look: LOOK.simple,
|
||||
},
|
||||
{
|
||||
id: `${industryId}-magazine`,
|
||||
industryId,
|
||||
name: '매거진',
|
||||
tone: 'photo',
|
||||
toneLabel: '잡지 편집',
|
||||
description: '명조 제목에 각진 모서리, 그림자 없이 선으로만. 사진과 글이 많을 때 품이 납니다.',
|
||||
colors: {
|
||||
primary: '#111111',
|
||||
secondary: '#57534e',
|
||||
bg: '#ffffff',
|
||||
card: '#f7f6f4',
|
||||
text: '#111111',
|
||||
accent,
|
||||
},
|
||||
fontStyle: '명조 제목',
|
||||
look: LOOK.magazine,
|
||||
},
|
||||
{
|
||||
id: `${industryId}-retro`,
|
||||
industryId,
|
||||
name: retro.name,
|
||||
tone: 'photo',
|
||||
toneLabel: '레트로 감성',
|
||||
description: retro.description,
|
||||
colors: {
|
||||
primary: '#1b1a15',
|
||||
secondary: '#4c4739',
|
||||
bg: '#e4dac0',
|
||||
card: '#f2ebd9',
|
||||
text: '#1b1a15',
|
||||
// 레트로의 정체성이 이 주(朱) 잉크다 — 업종 accent 로 갈아끼우지 않는다.
|
||||
accent: '#bf2f1b',
|
||||
},
|
||||
fontStyle: '옛 간판체',
|
||||
look: LOOK.retro,
|
||||
// 이 템플릿이 팔려는 게 바로 이 아이템들이다.
|
||||
defaultSectionTypes: ['songs', 'daily', 'course'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
||||
stay: {
|
||||
@ -24,93 +133,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
||||
{ id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' },
|
||||
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' },
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
id: 'stay-o2o-editorial',
|
||||
industryId: 'stay',
|
||||
name: 'O2O 에디토리얼',
|
||||
tone: 'photo',
|
||||
toneLabel: '차분한 모던',
|
||||
description: '절제된 타이포그래피와 여백, 차분한 슬레이트 톤으로 신뢰감과 감성을 함께 전합니다.',
|
||||
colors: {
|
||||
primary: '#18181b',
|
||||
secondary: '#52525b',
|
||||
bg: '#ffffff',
|
||||
card: '#fafafa',
|
||||
text: '#09090b',
|
||||
accent: '#2563eb',
|
||||
},
|
||||
fontStyle: 'Modern Editorial',
|
||||
},
|
||||
{
|
||||
id: 'stay-quiet-margin',
|
||||
industryId: 'stay',
|
||||
name: '조용한 여백',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '넓은 여백과 감성적인 사진 배치로 여유로운 휴식 분위기를 전달합니다.',
|
||||
colors: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
bg: '#ffffff',
|
||||
card: '#f8fafc',
|
||||
text: '#0f172a',
|
||||
accent: '#0284c7',
|
||||
},
|
||||
fontStyle: 'Clean Minimalist',
|
||||
},
|
||||
{
|
||||
id: 'stay-warm-wood',
|
||||
industryId: 'stay',
|
||||
name: '따뜻한 목조',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '아늑하고 내추럴한 톤으로 자연 친화적인 공간의 따스함을 극대화합니다.',
|
||||
colors: {
|
||||
primary: '#43302b',
|
||||
secondary: '#786055',
|
||||
bg: '#faf8f5',
|
||||
card: '#f2ece4',
|
||||
text: '#29201d',
|
||||
accent: '#c27847',
|
||||
},
|
||||
fontStyle: 'Warm Natural',
|
||||
},
|
||||
{
|
||||
id: 'stay-clear-info',
|
||||
industryId: 'stay',
|
||||
name: '또렷한 정보',
|
||||
tone: 'info',
|
||||
toneLabel: '정보 중심',
|
||||
description: '객실 옵션, 구비 시설, 체크인 규정을 한눈에 파악할 수 있는 실용적인 구조입니다.',
|
||||
colors: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#475569',
|
||||
bg: '#ffffff',
|
||||
card: '#f1f5f9',
|
||||
text: '#020617',
|
||||
accent: '#2563eb',
|
||||
},
|
||||
fontStyle: 'Crisp Modern',
|
||||
},
|
||||
{
|
||||
id: 'stay-booking-focus',
|
||||
industryId: 'stay',
|
||||
name: '예약 중심',
|
||||
tone: 'book',
|
||||
toneLabel: '예약·신청 강조',
|
||||
description: '객실별 예약 버튼과 실시간 문의 CTA를 전면에 배치하여 전환율을 높입니다.',
|
||||
colors: {
|
||||
primary: '#18181b',
|
||||
secondary: '#52525b',
|
||||
bg: '#ffffff',
|
||||
card: '#fafafa',
|
||||
text: '#09090b',
|
||||
accent: '#059669',
|
||||
},
|
||||
fontStyle: 'Conversion Driven',
|
||||
},
|
||||
],
|
||||
templates: templatesFor('stay', '#2563eb', {
|
||||
name: '옛 항구',
|
||||
description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.',
|
||||
}),
|
||||
},
|
||||
|
||||
cafe: {
|
||||
@ -136,76 +162,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
||||
{ id: 'local', type: 'local', name: '주변 나들이', isLocked: false, isEnabled: true, description: '양평 드라이브 코스 및 명소' },
|
||||
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' },
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
id: 'cafe-modern-espresso',
|
||||
industryId: 'cafe',
|
||||
name: '모던 에스프레소',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '다크 차콜 톤과 고해상도 커피 샷으로 프로페셔널한 스페셜티 이미지를 완성합니다.',
|
||||
colors: {
|
||||
primary: '#1c1917',
|
||||
secondary: '#78716c',
|
||||
bg: '#ffffff',
|
||||
card: '#fafaf9',
|
||||
text: '#0c0a09',
|
||||
accent: '#b45309',
|
||||
},
|
||||
fontStyle: 'Sleek Roast',
|
||||
},
|
||||
{
|
||||
id: 'cafe-sun-terrace',
|
||||
industryId: 'cafe',
|
||||
name: '햇살과 테라스',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '산뜻한 화이트와 그린 포인트로 자연 채광 가득한 힐링 테라스 분위기를 연출합니다.',
|
||||
colors: {
|
||||
primary: '#14532d',
|
||||
secondary: '#4b5563',
|
||||
bg: '#ffffff',
|
||||
card: '#f0fdf4',
|
||||
text: '#052e16',
|
||||
accent: '#16a34a',
|
||||
},
|
||||
fontStyle: 'Garden Terrace',
|
||||
},
|
||||
{
|
||||
id: 'cafe-roastery-story',
|
||||
industryId: 'cafe',
|
||||
name: '감성 로스터리',
|
||||
tone: 'info',
|
||||
toneLabel: '정보 중심',
|
||||
description: '원두 컵노트, 로스팅 프로파일, 브루잉 가이드를 체계적으로 정리해 전달합니다.',
|
||||
colors: {
|
||||
primary: '#332924',
|
||||
secondary: '#6c584c',
|
||||
bg: '#fcfaf8',
|
||||
card: '#f4ede6',
|
||||
text: '#201815',
|
||||
accent: '#8c5e3d',
|
||||
},
|
||||
fontStyle: 'Artisan Roast',
|
||||
},
|
||||
{
|
||||
id: 'cafe-quick-order',
|
||||
industryId: 'cafe',
|
||||
name: '퀵 오더 & 예약',
|
||||
tone: 'book',
|
||||
toneLabel: '예약·신청 강조',
|
||||
description: '시그니처 원두 패키지 스마트스토어 링크 및 단체 대관 접수를 최우선으로 노출합니다.',
|
||||
colors: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#475569',
|
||||
bg: '#ffffff',
|
||||
card: '#f8fafc',
|
||||
text: '#020617',
|
||||
accent: '#ea580c',
|
||||
},
|
||||
fontStyle: 'Dynamic Commerce',
|
||||
},
|
||||
],
|
||||
templates: templatesFor('cafe', '#b45309', {
|
||||
name: '옛 다방',
|
||||
description: '갱지 바탕에 간판체. LP 와 손글씨로, 다방 시절의 인상으로 지금의 커피를 이야기합니다.',
|
||||
}),
|
||||
},
|
||||
|
||||
restaurant: {
|
||||
@ -231,76 +191,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
||||
{ id: 'local', type: 'local', name: '주변 안내', isLocked: false, isEnabled: true, description: '청담 명품거리 및 갤러리 안내' },
|
||||
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' },
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
id: 'rest-neat-table',
|
||||
industryId: 'restaurant',
|
||||
name: '정갈한 한상',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '단아한 여백과 요리 고유의 색감을 살려 프리미엄 다이닝의 격을 높입니다.',
|
||||
colors: {
|
||||
primary: '#1c1917',
|
||||
secondary: '#57534e',
|
||||
bg: '#ffffff',
|
||||
card: '#fafaf9',
|
||||
text: '#0c0a09',
|
||||
accent: '#b45309',
|
||||
},
|
||||
fontStyle: 'Sophisticated Table',
|
||||
},
|
||||
{
|
||||
id: 'rest-modern-dining',
|
||||
industryId: 'restaurant',
|
||||
name: '모던 다이닝',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '세련된 다크 모던 톤으로 주류 페어링과 비스트로의 트렌디함을 강조합니다.',
|
||||
colors: {
|
||||
primary: '#09090b',
|
||||
secondary: '#52525b',
|
||||
bg: '#ffffff',
|
||||
card: '#f4f4f5',
|
||||
text: '#09090b',
|
||||
accent: '#e11d48',
|
||||
},
|
||||
fontStyle: 'Chic Bistro',
|
||||
},
|
||||
{
|
||||
id: 'rest-taste-origin',
|
||||
industryId: 'restaurant',
|
||||
name: '맛과 원산지',
|
||||
tone: 'info',
|
||||
toneLabel: '정보 중심',
|
||||
description: '계절 코스 구성, 식재료 산지 지도, 조리 과정을 상세히 기록하여 신뢰도를 높입니다.',
|
||||
colors: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#475569',
|
||||
bg: '#ffffff',
|
||||
card: '#f1f5f9',
|
||||
text: '#0f172a',
|
||||
accent: '#0d9488',
|
||||
},
|
||||
fontStyle: 'Artisan Story',
|
||||
},
|
||||
{
|
||||
id: 'rest-booking-type',
|
||||
industryId: 'restaurant',
|
||||
name: '테이블 예약형',
|
||||
tone: 'book',
|
||||
toneLabel: '예약·신청 강조',
|
||||
description: '시간대별 룸 예약 위젯과 잔여석 상태를 직관적으로 노출합니다.',
|
||||
colors: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#334155',
|
||||
bg: '#ffffff',
|
||||
card: '#f8fafc',
|
||||
text: '#020617',
|
||||
accent: '#2563eb',
|
||||
},
|
||||
fontStyle: 'Direct Reserve',
|
||||
},
|
||||
],
|
||||
templates: templatesFor('restaurant', '#16a34a', {
|
||||
name: '노포',
|
||||
description: '갱지 바탕에 간판체. 오래 해온 집이라는 사실 자체가 메뉴판이 됩니다.',
|
||||
}),
|
||||
},
|
||||
|
||||
tour: {
|
||||
@ -326,76 +220,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
||||
{ id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' },
|
||||
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' },
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
id: 'tour-visual-tour',
|
||||
industryId: 'tour',
|
||||
name: '비주얼 투어',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '체험 현장의 생생한 사진과 감각적인 그리드로 방문 욕구를 자극합니다.',
|
||||
colors: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#475569',
|
||||
bg: '#ffffff',
|
||||
card: '#f8fafc',
|
||||
text: '#020617',
|
||||
accent: '#0284c7',
|
||||
},
|
||||
fontStyle: 'Visual Journey',
|
||||
},
|
||||
{
|
||||
id: 'tour-gallery-archive',
|
||||
industryId: 'tour',
|
||||
name: '갤러리 아카이브',
|
||||
tone: 'photo',
|
||||
toneLabel: '사진 중심',
|
||||
description: '단아한 미술관 도록 스타일로 공예품과 전시 작품의 미학을 돋보이게 합니다.',
|
||||
colors: {
|
||||
primary: '#27272a',
|
||||
secondary: '#71717a',
|
||||
bg: '#fafafa',
|
||||
card: '#f4f4f5',
|
||||
text: '#18181b',
|
||||
accent: '#d97706',
|
||||
},
|
||||
fontStyle: 'Art Museum',
|
||||
},
|
||||
{
|
||||
id: 'tour-detailed-guide',
|
||||
industryId: 'tour',
|
||||
name: '상세 안내형',
|
||||
tone: 'info',
|
||||
toneLabel: '정보 중심',
|
||||
description: '연령대별 추천 코스, 단계별 커리큘럼, 준비물 안내를 명확하게 정리합니다.',
|
||||
colors: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
bg: '#ffffff',
|
||||
card: '#f1f5f9',
|
||||
text: '#0f172a',
|
||||
accent: '#4f46e5',
|
||||
},
|
||||
fontStyle: 'Curriculum Guide',
|
||||
},
|
||||
{
|
||||
id: 'tour-ticket-booking',
|
||||
industryId: 'tour',
|
||||
name: '티켓 예매형',
|
||||
tone: 'book',
|
||||
toneLabel: '예약·신청 강조',
|
||||
description: '클래스 날짜 선택과 즉시 티켓 결제 연동에 최적화된 고전환 레이아웃입니다.',
|
||||
colors: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#334155',
|
||||
bg: '#ffffff',
|
||||
card: '#f8fafc',
|
||||
text: '#020617',
|
||||
accent: '#059669',
|
||||
},
|
||||
fontStyle: 'Instant Booking',
|
||||
},
|
||||
],
|
||||
templates: templatesFor('tour', '#7c3aed', {
|
||||
name: '시간여행',
|
||||
description: '갱지 바탕에 간판체. 일력과 승차권으로 지역의 시간을 걸어 다니게 만듭니다.',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
37
solution/frontend/src/features/auth/EditorSignInGate.tsx
Normal file
37
solution/frontend/src/features/auth/EditorSignInGate.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 에디터 앞의 로그인 관문.
|
||||
*
|
||||
* ★ 위저드(1~5단계)는 로그인을 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다.
|
||||
* 에디터부터는 편집한 것을 저장하고 발행해야 하는데 그게 전부 토큰을 쓴다. 토큰 없이 들여보내면
|
||||
* 저장이 조용히 실패하고 사장님은 발행하고 나서야 안다.
|
||||
* ★ /login 으로 튕기지 않는다. 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없다.
|
||||
*/
|
||||
import {SignInForm} from './SignInForm';
|
||||
|
||||
export function EditorSignInGate({onBack}: {onBack: () => void}) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
|
||||
<SignInForm
|
||||
submitLabel="로그인하고 편집 시작"
|
||||
header={
|
||||
<div className="space-y-1 text-center">
|
||||
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="mx-auto mb-3 h-9 w-auto" />
|
||||
<h1 className="text-sm font-bold">편집을 시작하려면 로그인해 주세요</h1>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
여기까지 만든 내용은 그대로 있습니다. 편집한 것을 저장하고 발행하는 데 계정이 필요합니다.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
디자인 고르기로 돌아가기
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
solution/frontend/src/features/auth/SignInForm.tsx
Normal file
104
solution/frontend/src/features/auth/SignInForm.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 로그인 폼 한 벌.
|
||||
*
|
||||
* ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서
|
||||
* (signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다.
|
||||
*/
|
||||
import {useState, type FormEvent, type ReactNode} from 'react';
|
||||
import {LogIn} from 'lucide-react';
|
||||
import {login, me, UserRole} from '@/api';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {notifyApiError} from '@/lib/notify';
|
||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||
|
||||
interface SignInFormProps {
|
||||
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
|
||||
header: ReactNode;
|
||||
/** 폼 아래 각주(빌더로 돌아가기 등). */
|
||||
footer?: ReactNode;
|
||||
submitLabel?: string;
|
||||
onSignedIn?: () => void;
|
||||
}
|
||||
|
||||
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
|
||||
const signIn = useAuthStore((s) => s.signIn);
|
||||
const [id, setId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const res = await login({id, password});
|
||||
if (res.result?.success === false) {
|
||||
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
|
||||
return;
|
||||
}
|
||||
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다.
|
||||
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다.
|
||||
if (!res.access_token || !res.refresh_token) {
|
||||
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
|
||||
return;
|
||||
}
|
||||
const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token};
|
||||
|
||||
// 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다.
|
||||
signIn(tokens, {userId: '', id, role: UserRole.USER});
|
||||
|
||||
const meRes = await me();
|
||||
// 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다.
|
||||
if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes));
|
||||
onSignedIn?.();
|
||||
} catch (error) {
|
||||
notifyApiError(error, '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
|
||||
>
|
||||
{header}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="login-id" className="mb-1.5 block text-xs font-semibold">
|
||||
아이디
|
||||
</label>
|
||||
<Input
|
||||
id="login-id"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="login-pw" className="mb-1.5 block text-xs font-semibold">
|
||||
비밀번호
|
||||
</label>
|
||||
<Input
|
||||
id="login-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>
|
||||
<LogIn />
|
||||
<span>{submitLabel}</span>
|
||||
</Button>
|
||||
|
||||
{footer}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -60,6 +60,8 @@ export function CanvasView() {
|
||||
// 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다.
|
||||
const surfaces = deriveSurfaces(template.colors);
|
||||
|
||||
const look = template.look;
|
||||
|
||||
const themeVars = {
|
||||
'--tpl-surface': surfaces.surface,
|
||||
'--tpl-surface-alt': surfaces.surfaceAlt,
|
||||
@ -71,6 +73,28 @@ export function CanvasView() {
|
||||
'--tpl-card': template.colors.card,
|
||||
'--tpl-text': template.colors.text,
|
||||
'--tpl-accent': template.colors.accent,
|
||||
|
||||
// 색만 바꾸면 템플릿이 다 같아 보인다 — 생김새를 가르는 건 아래쪽이다.
|
||||
'--tpl-font-heading': look.fontHeading,
|
||||
'--tpl-font-body': look.fontBody,
|
||||
'--tpl-heading-tracking': look.headingTracking,
|
||||
'--tpl-heading-weight': look.headingWeight,
|
||||
'--tpl-section-space': look.sectionSpace,
|
||||
'--tpl-border-width': look.borderWidth,
|
||||
|
||||
// ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진
|
||||
// rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다.
|
||||
// 배수는 Tailwind 기본 비율(0.25/0.375/0.5/0.75/1/1.5rem)을 그대로 옮긴 것이라,
|
||||
// 기준값이 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다.
|
||||
'--radius-sm': `calc(${look.radius} * 0.34)`,
|
||||
'--radius-md': `calc(${look.radius} * 0.5)`,
|
||||
'--radius-lg': `calc(${look.radius} * 0.67)`,
|
||||
'--radius-xl': look.radius,
|
||||
'--radius-2xl': `calc(${look.radius} * 1.34)`,
|
||||
'--radius-3xl': `calc(${look.radius} * 2)`,
|
||||
'--shadow-xs': look.shadow,
|
||||
'--shadow-sm': look.shadow,
|
||||
'--shadow-md': look.shadow,
|
||||
} as CSSProperties;
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -6,12 +6,15 @@ import {
|
||||
SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {CSS} from '@dnd-kit/utilities';
|
||||
import {useState} from 'react';
|
||||
import type {SectionItem} from '@o2o/shared';
|
||||
import {GripVertical, Layers, Lock, Shapes} from 'lucide-react';
|
||||
import {Check, GripVertical, Layers, Lock, Plus, Shapes, Trash2, X} from 'lucide-react';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {ADDABLE_SECTIONS, addableSection} from './canvas/addable';
|
||||
import {resolveVariant} from './canvas/registry';
|
||||
import {VariantThumb} from './canvas/thumbs';
|
||||
|
||||
interface SectionListPanelProps {
|
||||
onAfterSelect?: () => void;
|
||||
@ -28,7 +31,10 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
|
||||
const isSelected = useBuilderStore((s) => s.selectedSectionId === section.id);
|
||||
const selectSection = useBuilderStore((s) => s.selectSection);
|
||||
const toggleSection = useBuilderStore((s) => s.toggleSection);
|
||||
const removeSection = useBuilderStore((s) => s.removeSection);
|
||||
const variant = resolveVariant(section, industry);
|
||||
// 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다 — 뺄 수 있는 건 나중에 넣은 것뿐이다.
|
||||
const canRemove = Boolean(addableSection(section.type));
|
||||
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({
|
||||
id: section.id,
|
||||
});
|
||||
@ -131,6 +137,20 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()} role="presentation">
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
title={`${section.name} 섹션 빼기`}
|
||||
aria-label={`${section.name} 섹션 빼기`}
|
||||
onClick={() => {
|
||||
if (section.data?.trim() && !window.confirm(`${section.name} 에 넣은 내용도 함께 지워집니다. 뺄까요?`)) return;
|
||||
removeSection(section.id);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{section.isLocked ? (
|
||||
<span title="SEO·필수 마크업이 달린 섹션이라 끌 수 없습니다." className="p-1 text-muted-foreground">
|
||||
<Lock className="size-4" />
|
||||
@ -144,9 +164,70 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
|
||||
);
|
||||
}
|
||||
|
||||
/** [+ 섹션 추가] 가 여는 목록. 이미 들어 있는 것은 '넣음' 으로 표시만 하고 다시 넣지 않는다. */
|
||||
function AddSectionPicker({onClose, onAfterSelect}: {onClose: () => void; onAfterSelect?: () => void}) {
|
||||
const sections = useBuilderStore((s) => s.sections);
|
||||
const addSection = useBuilderStore((s) => s.addSection);
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-muted/40 p-2">
|
||||
<div className="flex items-center justify-between px-1 pb-1.5">
|
||||
<span className="text-[11px] font-bold">추가할 섹션</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="섹션 추가 닫기"
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-1 pb-2 text-[10px] leading-relaxed text-muted-foreground">
|
||||
내용은 [콘텐츠] 탭에서 프롬프트를 복사해 받은 JSON 을 붙여넣어 채웁니다.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{ADDABLE_SECTIONS.map((item) => {
|
||||
const already = sections.some((section) => section.type === item.type);
|
||||
return (
|
||||
<li key={item.type}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={already}
|
||||
onClick={() => {
|
||||
addSection(item.type);
|
||||
onClose();
|
||||
onAfterSelect?.();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 rounded-md border p-2 text-left transition-colors',
|
||||
already
|
||||
? 'cursor-default border-border bg-muted/50 text-muted-foreground'
|
||||
: 'border-border bg-card hover:border-primary hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<VariantThumb thumb={item.thumb} className="mt-0.5 w-9 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1 text-[11px] font-semibold text-foreground">
|
||||
{item.name}
|
||||
{already && <Check className="size-3 text-muted-foreground" />}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-relaxed text-muted-foreground">
|
||||
{already ? '이미 목록에 있습니다' : item.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionListPanel({onAfterSelect}: SectionListPanelProps) {
|
||||
const sections = useBuilderStore((s) => s.sections);
|
||||
const reorderSection = useBuilderStore((s) => s.reorderSection);
|
||||
const [isPickerOpen, setPickerOpen] = useState(false);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {activationConstraint: {distance: 6}}),
|
||||
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
|
||||
@ -191,6 +272,19 @@ export function SectionListPanel({onAfterSelect}: SectionListPanelProps) {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{isPickerOpen ? (
|
||||
<AddSectionPicker onClose={() => setPickerOpen(false)} onAfterSelect={onAfterSelect} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 border-t border-border bg-card p-2.5 text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-primary/5 hover:text-primary"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
섹션 추가
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="border-t border-border bg-muted/30 p-2.5 text-center text-[10px] text-muted-foreground">
|
||||
* 자물쇠 항목은 검색·AI 노출에 필요한 마크업이 달려 있어 고정입니다.
|
||||
</p>
|
||||
|
||||
51
solution/frontend/src/features/builder/canvas/addable.ts
Normal file
51
solution/frontend/src/features/builder/canvas/addable.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 나중에 넣을 수 있는 섹션 — [+ 섹션 추가] 가 고르는 목록.
|
||||
*
|
||||
* 업종 시드(industryData)는 "처음부터 있는 것"이고 여기는 "필요하면 넣는 것"이다.
|
||||
* 붙여넣기 아이템은 내용이 없으면 빈 섹션이라, 시드에 박아 두면 아무도 안 쓰는 칸이 늘 붙어 있다.
|
||||
*/
|
||||
import type {SectionItem} from '@o2o/shared';
|
||||
import {SECTION_DATA_SPEC} from './dataSpec';
|
||||
import {defaultVariant} from './registry';
|
||||
import type {ThumbKey} from './types';
|
||||
|
||||
export interface AddableSection {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
thumb: ThumbKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가할 수 있는 섹션 목록.
|
||||
*
|
||||
* ★ dataSpec 이 단일 출처다 — 아이템을 하나 더 만들면 여기에도 자동으로 나타난다.
|
||||
* 목록을 따로 들면 아이템을 추가하고 이 표를 잊어 "만들었는데 고를 수가 없는" 상태가 된다.
|
||||
*/
|
||||
export const ADDABLE_SECTIONS: AddableSection[] = Object.values(SECTION_DATA_SPEC).map((spec) => {
|
||||
const variant = defaultVariant(spec.kind);
|
||||
return {
|
||||
type: spec.kind,
|
||||
name: spec.label,
|
||||
description: variant?.description ?? '',
|
||||
thumb: variant?.thumb ?? 'cards',
|
||||
};
|
||||
});
|
||||
|
||||
export function addableSection(type: string): AddableSection | undefined {
|
||||
return ADDABLE_SECTIONS.find((item) => item.type === type);
|
||||
}
|
||||
|
||||
/** 목록에 새로 넣을 섹션 한 줄. id 는 타입과 같다 — 같은 아이템을 두 번 넣지 않는다. */
|
||||
export function newSectionOf(type: string): SectionItem | undefined {
|
||||
const addable = addableSection(type);
|
||||
if (!addable) return undefined;
|
||||
return {
|
||||
id: type,
|
||||
type,
|
||||
name: addable.name,
|
||||
description: addable.description,
|
||||
isLocked: false,
|
||||
isEnabled: true,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -54,13 +54,18 @@ export function SectionFrame({
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'relative cursor-pointer transition-all',
|
||||
padded && 'px-4 py-12 sm:px-6 sm:py-16 md:px-8',
|
||||
padded && 'px-4 sm:px-6 md:px-8',
|
||||
isSelected
|
||||
? 'ring-2 ring-stone-900 ring-offset-2'
|
||||
: 'hover:outline-1 hover:outline-dashed hover:outline-stone-300',
|
||||
className,
|
||||
)}
|
||||
style={{backgroundColor: TONE_BG[tone]}}
|
||||
style={{
|
||||
backgroundColor: TONE_BG[tone],
|
||||
// 세로 여백은 템플릿이 정한다 — 이 값 하나로 페이지의 호흡이 바뀐다.
|
||||
// 폴백은 걷어낸 py-12/py-16 과 같은 값이라, 토큰이 없는 곳에서는 지금까지와 동일하다.
|
||||
...(padded ? {paddingBlock: 'var(--tpl-section-space, 3.5rem)'} : {}),
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<span
|
||||
|
||||
@ -52,7 +52,7 @@ export function SectionHeading({
|
||||
</span>
|
||||
)}
|
||||
<h2
|
||||
className="serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
|
||||
className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
@ -76,7 +76,7 @@ export function SectionHeading({
|
||||
</span>
|
||||
)}
|
||||
<h2
|
||||
className="serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
|
||||
className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
@ -93,7 +93,7 @@ export function SectionHeading({
|
||||
return (
|
||||
<div className={cn('flex items-baseline justify-between gap-3', className)}>
|
||||
<h2
|
||||
className="text-sm font-bold uppercase tracking-[0.18em] text-stone-500"
|
||||
className="tpl-title text-sm font-bold uppercase tracking-[0.18em] text-stone-500"
|
||||
style={colors ? {color: colors.secondary} : undefined}
|
||||
>
|
||||
{title}
|
||||
@ -113,7 +113,7 @@ export function SectionHeading({
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
className="serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
|
||||
className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
|
||||
@ -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} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {useSearchParams} from 'react-router';
|
||||
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react';
|
||||
import type {PlaceCandidate} from '@/api';
|
||||
import {getAccessToken, type PlaceCandidate} from '@/api';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
@ -83,6 +83,14 @@ export function Step2PlaceSearch() {
|
||||
const runSearch = () => {
|
||||
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
|
||||
setPickedIndex(null);
|
||||
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
|
||||
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
|
||||
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
void search.search(storeName, location);
|
||||
};
|
||||
|
||||
@ -113,6 +121,12 @@ export function Step2PlaceSearch() {
|
||||
const pickByUrl = async () => {
|
||||
const url = placeUrl.trim();
|
||||
if (!url) return;
|
||||
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
const identity = await search.confirmByUrl(url);
|
||||
if (!identity) return;
|
||||
confirmIdentity(identity);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {Check} from 'lucide-react';
|
||||
import type {TemplateItem} from '@o2o/shared';
|
||||
import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
|
||||
import {cn} from '@/lib/utils';
|
||||
@ -6,6 +7,79 @@ import {useBuilderStore} from '@/stores/builder';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardSteps} from './WizardSteps';
|
||||
|
||||
|
||||
/**
|
||||
* 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다.
|
||||
*
|
||||
* ★ 예전에는 회색 막대 세 줄과 색 동그라미였다. 다섯 템플릿이 전부 같은 그림이라
|
||||
* 무엇을 고르는지 알 수 없었고, 그래서 아무거나 골랐다.
|
||||
*/
|
||||
function TemplatePreview({template}: {template: TemplateItem}) {
|
||||
const {look, colors} = template;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="my-3 overflow-hidden p-3.5"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
borderRadius: look.radius,
|
||||
border: `${look.borderWidth} solid ${colors.secondary}22`,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-[9px] uppercase"
|
||||
style={{color: colors.accent, letterSpacing: '0.22em', fontFamily: look.fontBody}}
|
||||
>
|
||||
Section
|
||||
</p>
|
||||
<p
|
||||
className="mt-1.5 text-base leading-tight"
|
||||
style={{
|
||||
fontFamily: look.fontHeading,
|
||||
fontWeight: look.headingWeight,
|
||||
letterSpacing: look.headingTracking,
|
||||
}}
|
||||
>
|
||||
오래 머무는 자리
|
||||
</p>
|
||||
<p
|
||||
className="mt-1.5 text-[10px] leading-relaxed"
|
||||
style={{fontFamily: look.fontBody, color: colors.secondary}}
|
||||
>
|
||||
제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-3 p-2"
|
||||
style={{
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: `calc(${look.radius} * 0.67)`,
|
||||
border: `${look.borderWidth} solid ${colors.secondary}22`,
|
||||
boxShadow: look.shadow,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px]" style={{fontFamily: look.fontBody, color: colors.secondary}}>
|
||||
카드 · 모서리 {look.radius === '0px' ? '각짐' : '둥금'} · 그림자{' '}
|
||||
{look.shadow === 'none' ? '없음' : '있음'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="mt-2.5 inline-block px-2.5 py-1 text-[9px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: colors.accent,
|
||||
color: colors.bg,
|
||||
borderRadius: `calc(${look.radius} * 0.5)`,
|
||||
fontFamily: look.fontBody,
|
||||
}}
|
||||
>
|
||||
예약 문의
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Step4Template() {
|
||||
const industry = useBuilderStore((s) => s.industry);
|
||||
const templateId = useBuilderStore((s) => s.templateId);
|
||||
@ -79,31 +153,7 @@ export function Step4Template() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 색·레이아웃 미리보기 와이어프레임 */}
|
||||
<div className="my-3 space-y-2.5 rounded-xl border border-border bg-muted/50 p-3.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['primary', 'secondary', 'card'] as const).map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
className="size-3.5 rounded-full border border-black/10"
|
||||
style={{backgroundColor: template.colors[key]}}
|
||||
title={key}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-1 font-mono text-[10px] text-muted-foreground">
|
||||
{template.colors.primary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<div
|
||||
className="h-2.5 w-2/3 rounded"
|
||||
style={{backgroundColor: template.colors.primary, opacity: 0.85}}
|
||||
/>
|
||||
<div className="h-1.5 w-full rounded bg-border" />
|
||||
<div className="h-1.5 w-4/5 rounded bg-border" />
|
||||
</div>
|
||||
</div>
|
||||
<TemplatePreview template={template} />
|
||||
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{template.description}
|
||||
|
||||
@ -132,12 +132,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
||||
await ensureAutoSession();
|
||||
|
||||
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
|
||||
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
|
||||
if (!getAccessToken()) {
|
||||
setState({
|
||||
...INITIAL,
|
||||
phase: 'unavailable',
|
||||
unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
|
||||
});
|
||||
setState({...INITIAL});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -42,12 +42,22 @@ export interface SiteThemePayload {
|
||||
colorPaletteId?: string | null;
|
||||
sections: {
|
||||
id: string;
|
||||
/**
|
||||
* 섹션 타입.
|
||||
*
|
||||
* ★ 예전엔 안 실었다 — 시드에 다 있으니 id 로 찾으면 됐다. [+ 섹션 추가] 가 생기면서
|
||||
* 시드에 없는 섹션이 저장되기 시작했고, type 이 없으면 복원 때 그게 뭐였는지 알 길이 없어
|
||||
* 통째로 버려진다(사장님이 채운 JSON 까지 같이).
|
||||
*/
|
||||
type?: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
variantId?: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
/** 붙여넣기 아이템의 원문 JSON. 서버는 이 값도 해석하지 않고 그대로 보관한다. */
|
||||
data?: string;
|
||||
}[];
|
||||
customInfoFields?: InfoField[];
|
||||
visiblePhotoIds?: string[];
|
||||
@ -74,11 +84,13 @@ export function toThemePayload(
|
||||
colorPaletteId,
|
||||
sections: sections.map((s) => ({
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
name: s.name,
|
||||
enabled: s.isEnabled,
|
||||
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_')),
|
||||
|
||||
@ -60,8 +60,19 @@ body {
|
||||
★ 폴백이 inherit 이라 --tpl-font-heading 이 없으면 예전과 똑같이 본문 서체를 그대로 물려받는다. */
|
||||
.site-canvas h1,
|
||||
.site-canvas h2,
|
||||
.site-canvas h3 {
|
||||
.site-canvas h3,
|
||||
.site-canvas h4 {
|
||||
font-family: var(--tpl-font-heading, inherit);
|
||||
letter-spacing: var(--tpl-heading-tracking, normal);
|
||||
}
|
||||
/* ★ 굵기는 폴백을 inherit 로 두지 않는다. 간판체(Gugi)는 굵기가 한 벌뿐이라 700 을 주면
|
||||
브라우저가 가짜 볼드를 씌워 획이 뭉갠다 — 템플릿이 400 을 지정할 수 있어야 한다. */
|
||||
.site-canvas :is(h1, h2, h3, h4).tpl-title {
|
||||
font-weight: var(--tpl-heading-weight, 700);
|
||||
}
|
||||
/* 카드·패널 테두리 두께도 템플릿이 정한다. 레트로는 2px 라야 인쇄물처럼 보인다. */
|
||||
.site-canvas .border {
|
||||
border-width: var(--tpl-border-width, 1px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@ import {useEffect, useRef} from 'react';
|
||||
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react';
|
||||
import {Link, useSearchParams} from 'react-router';
|
||||
import {SiteStatus} from '@o2o/shared';
|
||||
import {getAccessToken} from '@/api';
|
||||
import {AppShell} from '@/components/layout/AppShell';
|
||||
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
|
||||
import {
|
||||
Step1Industry,
|
||||
Step2PlaceSearch,
|
||||
@ -12,6 +14,7 @@ import {
|
||||
} from '@/features/onboarding';
|
||||
import {EditorLayout} from '@/features/builder';
|
||||
import {usePlaceSync} from '@/hooks/usePlaceSync';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
|
||||
|
||||
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
|
||||
@ -78,7 +81,11 @@ export function BuilderPage() {
|
||||
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
|
||||
|
||||
const step = useBuilderStore((s) => s.step);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
|
||||
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
|
||||
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
|
||||
const wiredPlaceId = useBuilderStore((s) => s.placeId);
|
||||
@ -115,6 +122,11 @@ export function BuilderPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
|
||||
if (step === EDITOR_STEP && !isSignedIn) {
|
||||
return <EditorSignInGate onBack={() => goToStep(4)} />;
|
||||
}
|
||||
|
||||
if (step === EDITOR_STEP) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-screen flex-col overflow-hidden">
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
ViewportMode,
|
||||
} from '@o2o/shared';
|
||||
import {FALLBACK_INDUSTRY, INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {addableSection, newSectionOf} from '@/features/builder/canvas/addable';
|
||||
import {COLOR_PALETTE_PRESETS} from '@/features/builder/colorPalettes';
|
||||
import {createFactSaver, type FactSaver} from '@/features/builder/factSave';
|
||||
import {
|
||||
@ -125,7 +126,13 @@ interface BuilderState {
|
||||
selectSection: (id: string | null) => void;
|
||||
toggleSection: (id: string) => void;
|
||||
reorderSection: (fromIndex: number, toIndex: number) => void;
|
||||
/** [+ 섹션 추가] — 이미 있으면 켜기만 한다. */
|
||||
addSection: (type: string) => void;
|
||||
/** 나중에 넣은 섹션만 뺀다. 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다. */
|
||||
removeSection: (id: string) => 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;
|
||||
/** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */
|
||||
@ -458,8 +465,31 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
setGatherStage: (gatherStage) => set({gatherStage}),
|
||||
finishGather: () => set({isGathering: false, gatherCompleted: true, gatherStage: 3}),
|
||||
|
||||
/**
|
||||
* 템플릿을 고른다. 그 템플릿이 데리고 오는 섹션이 있으면 함께 들어온다.
|
||||
*
|
||||
* ★ 넣기만 하고 빼지 않는다. 템플릿을 눌러 보다가 넣어 둔 섹션이 사라지면
|
||||
* 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다.
|
||||
*/
|
||||
selectTemplate: (templateId) => {
|
||||
set({templateId, colorPaletteId: null});
|
||||
set((state) => {
|
||||
const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId);
|
||||
const wanted = template?.defaultSectionTypes ?? [];
|
||||
const added = wanted
|
||||
.filter((type) => !state.sections.some((sec) => sec.type === type))
|
||||
.map(newSectionOf)
|
||||
.filter((sec): sec is SectionItem => sec !== undefined);
|
||||
return {
|
||||
templateId,
|
||||
colorPaletteId: null,
|
||||
sections: [
|
||||
...state.sections.map((sec) =>
|
||||
wanted.includes(sec.type) ? {...sec, isEnabled: true} : sec,
|
||||
),
|
||||
...added,
|
||||
],
|
||||
};
|
||||
});
|
||||
persistTheme();
|
||||
},
|
||||
selectColorPalette: (colorPaletteId) => {
|
||||
@ -512,6 +542,42 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
persistTheme();
|
||||
},
|
||||
|
||||
addSection: (type) => {
|
||||
const created = newSectionOf(type);
|
||||
if (!created) return;
|
||||
set((state) => {
|
||||
const existing = state.sections.find((sec) => sec.type === type);
|
||||
// 이미 있으면 새로 만들지 않는다 — 넣어 둔 JSON 이 날아간다.
|
||||
if (existing) {
|
||||
return {
|
||||
sections: state.sections.map((sec) =>
|
||||
sec.id === existing.id ? {...sec, isEnabled: true} : sec,
|
||||
),
|
||||
selectedSectionId: existing.id,
|
||||
};
|
||||
}
|
||||
return {sections: [...state.sections, created], selectedSectionId: created.id};
|
||||
});
|
||||
persistTheme();
|
||||
},
|
||||
|
||||
removeSection: (id) => {
|
||||
set((state) => ({
|
||||
sections: state.sections.filter((sec) => sec.id !== id),
|
||||
selectedSectionId: state.selectedSectionId === id ? null : state.selectedSectionId,
|
||||
}));
|
||||
persistTheme();
|
||||
},
|
||||
|
||||
updateSectionData: (sectionId, data) => {
|
||||
set((state) => ({
|
||||
sections: state.sections.map((section) =>
|
||||
section.id === sectionId ? {...section, data} : section,
|
||||
),
|
||||
}));
|
||||
persistTheme();
|
||||
},
|
||||
|
||||
// 레이아웃만 갈아끼운다 — 섹션의 내용·순서·노출 여부는 건드리지 않는다.
|
||||
// 그래야 사장님이 "이 모양 저 모양" 눌러보다가 편집한 걸 잃지 않는다.
|
||||
setSectionVariant: (sectionId, variantId) => {
|
||||
@ -555,16 +621,22 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
// (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다.
|
||||
.map((saved): SectionItem | null => {
|
||||
const seed = seedById.get(saved.id);
|
||||
if (!seed) return null;
|
||||
// ★ 시드에 없어도 [+ 섹션 추가] 로 넣을 수 있는 것이면 되살린다.
|
||||
// 여기서 버리면 사장님이 추가하고 JSON 까지 채운 섹션이 새로고침 한 번에 사라진다.
|
||||
const revived =
|
||||
seed ??
|
||||
(saved.type && addableSection(saved.type) ? newSectionOf(saved.type) : undefined);
|
||||
if (!revived) return null;
|
||||
seedById.delete(saved.id);
|
||||
return {
|
||||
...seed,
|
||||
name: saved.name || seed.name,
|
||||
description: saved.description ?? seed.description,
|
||||
body: saved.body ?? seed.body,
|
||||
...revived,
|
||||
name: saved.name || revived.name,
|
||||
description: saved.description ?? revived.description,
|
||||
body: saved.body ?? revived.body,
|
||||
data: saved.data ?? revived.data,
|
||||
// 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도
|
||||
// SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다.
|
||||
isEnabled: seed.isLocked ? true : saved.enabled,
|
||||
isEnabled: revived.isLocked ? true : saved.enabled,
|
||||
...(saved.variantId ? {variantId: saved.variantId} : {}),
|
||||
};
|
||||
})
|
||||
|
||||
@ -37,6 +37,13 @@ export interface SectionItem {
|
||||
description?: string;
|
||||
/** 사장님이 직접 편집하는 섹션 본문. 수집된 값과 분리해 자동 수집이 덮어쓰지 않는다. */
|
||||
body?: string;
|
||||
/**
|
||||
* 붙여넣기 아이템(가요·일력·코스)의 원문 JSON.
|
||||
*
|
||||
* ★ 파싱해서 넣지 않고 **문자열 그대로** 둔다. 편집 중인 JSON 은 늘 깨져 있고,
|
||||
* 파싱본만 들고 있으면 깨진 순간 사장님이 쓴 걸 잃는다.
|
||||
*/
|
||||
data?: string;
|
||||
/**
|
||||
* 이 섹션을 어떤 레이아웃으로 그릴지 — 사장님이 [디자인] 탭에서 고른 값.
|
||||
* 비어 있으면 렌더러가 해당 타입의 기본 배리에이션으로 떨어진다(빈 화면을 만들지 않는다).
|
||||
@ -60,7 +67,45 @@ export interface TemplateItem {
|
||||
text: string;
|
||||
accent: string;
|
||||
};
|
||||
/** 사람에게 보이는 서체 이름('고딕' · '명조 제목'). 실제 서체는 look 이 정한다. */
|
||||
fontStyle: string;
|
||||
/**
|
||||
* 실제로 화면을 가르는 값.
|
||||
*
|
||||
* ★ 예전엔 색 여섯 개뿐이라 템플릿을 바꿔도 색조만 달라졌다 — 다섯 개가 다 같아 보였다.
|
||||
* 서체·모서리·테두리·그림자·여백이 여기 있고, 캔버스가 CSS 변수로 그대로 내려보낸다.
|
||||
*/
|
||||
look: TemplateLook;
|
||||
/**
|
||||
* 이 템플릿을 고르면 함께 들어오는 섹션(canvas/addable 의 타입).
|
||||
*
|
||||
* ★ 넣기만 하고 빼지 않는다. 템플릿을 이리저리 눌러보다 사장님이 넣어 둔 섹션이 사라지면
|
||||
* 그게 템플릿 때문인 줄 모른다.
|
||||
*/
|
||||
defaultSectionTypes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿의 생김새. 값은 전부 CSS 에 그대로 들어가는 문자열이다 —
|
||||
* 숫자로 들고 있다가 쓰는 쪽에서 단위를 붙이면, 빠뜨린 곳이 조용히 0 이 된다.
|
||||
*/
|
||||
export interface TemplateLook {
|
||||
/** 제목 서체 스택. `--tpl-font-heading` */
|
||||
fontHeading: string;
|
||||
/** 본문 서체 스택. `--tpl-font-body` */
|
||||
fontBody: string;
|
||||
/** 모서리. Tailwind 의 `--radius-*` 를 캔버스 안에서만 덮어 rounded-* 전부가 따라온다. */
|
||||
radius: string;
|
||||
/** 카드·패널 테두리 두께. */
|
||||
borderWidth: string;
|
||||
/** 카드 그림자. Tailwind 의 `--shadow-*` 를 덮는다. 'none' 이면 평평해진다. */
|
||||
shadow: string;
|
||||
/** 제목 자간. */
|
||||
headingTracking: string;
|
||||
/** 제목 굵기. 간판체처럼 한 굵기뿐인 서체는 400 이어야 한다. */
|
||||
headingWeight: string;
|
||||
/** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
|
||||
sectionSpace: string;
|
||||
}
|
||||
|
||||
export interface PhotoItem {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user