도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
/**
|
|
* [디자인] 탭 — 고른 섹션의 레이아웃 배리에이션을 바꾸는 자리.
|
|
*
|
|
* 사장님은 코드 이름이 아니라 모양으로 고른다. 그래서 카드마다 와이어프레임을 붙이고,
|
|
* "언제 이걸 고르면 좋은지"를 한 줄로 적는다.
|
|
*/
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
LayoutTemplate,
|
|
MousePointerClick,
|
|
Palette,
|
|
RotateCcw,
|
|
Shapes,
|
|
} from 'lucide-react';
|
|
import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
|
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
|
|
import {cn} from '@/lib/utils';
|
|
import {useBuilderStore} from '@/stores/builder';
|
|
import {COLOR_PALETTE_PRESETS} from './colorPalettes';
|
|
import {TemplatePreview} from './TemplatePreview';
|
|
import {resolveVariant, variantsFor} from './canvas/registry';
|
|
import {VariantThumb} from './canvas/thumbs';
|
|
|
|
/**
|
|
* 접히는 묶음.
|
|
*
|
|
* ★ [디자인] 탭은 290px 한 칸이다. 여기에 템플릿 미리보기 셋 + 팔레트 열두 칸 +
|
|
* 이 섹션의 배리에이션이 세로로 쌓이면 스크롤이 세 화면을 넘고, 정작 방금 고른 섹션의
|
|
* 배리에이션이 맨 아래로 밀린다. 큰 것(템플릿·색)은 접어 두고 필요할 때 편다.
|
|
* ★ `<details>` 다 — 상태를 리액트로 들면 탭을 오갈 때마다 접힘이 초기화된다.
|
|
*/
|
|
function Group({
|
|
title,
|
|
icon: Icon,
|
|
count,
|
|
open,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
icon: typeof Palette;
|
|
count?: string;
|
|
open?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<details open={open} className="group border-b border-border pb-3">
|
|
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-xs font-bold marker:content-none [&::-webkit-details-marker]:hidden">
|
|
<Icon className="size-3.5" />
|
|
<span>{title}</span>
|
|
{count && <span className="ml-auto font-mono text-[10px] text-muted-foreground">{count}</span>}
|
|
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
|
</summary>
|
|
<div className="mt-2 space-y-2">{children}</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 고르기.
|
|
*
|
|
* ★ 이 자리가 없었다. 템플릿은 온보딩 4단계에서 한 번 고르면 끝이었고, 에디터의 [디자인] 탭에는
|
|
* 팔레트와 섹션 배리에이션만 있었다 — 사장님은 **디자인을 바꾸러 들어와서 디자인을 못 바꿨다.**
|
|
* 스토어의 `selectTemplate` 과 서버 저장(`queueSiteTemplateSave`)은 처음부터 있었고 UI 만 없었다.
|
|
* ★ 미리보기는 위저드와 **같은 컴포넌트**다(TemplatePreview). 두 벌로 그리면 고를 때 본 것과
|
|
* 에디터에서 본 것이 갈린다.
|
|
*/
|
|
function TemplatePicker({open}: {open?: boolean}) {
|
|
const industry = useBuilderStore((s) => s.industry);
|
|
const templateId = useBuilderStore((s) => s.templateId);
|
|
const placeId = useBuilderStore((s) => s.placeId);
|
|
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
|
|
const templates = INDUSTRY_CONFIGS[industry].templates;
|
|
/**
|
|
* ★ 저장된 templateId 가 지금 목록에 **없을 수 있다.** 실제로 있었다 —
|
|
* `stay-warm-wood` 처럼 예전 이름이 sites.template_id 에 남아 있으면
|
|
* `resolveTemplate` 은 말없이 첫 템플릿으로 떨어지는데, 이 목록에서는 아무것도
|
|
* 선택돼 보이지 않아 "고를 수 없는 화면"이 된다. 떨어지는 자리를 여기서도 같게 본다.
|
|
*/
|
|
const activeId = templates.some((t) => t.id === templateId) ? templateId : templates[0].id;
|
|
|
|
return (
|
|
<Group title="템플릿" icon={LayoutTemplate} count={`${templates.length}종`} open={open}>
|
|
<div className="space-y-2">
|
|
{templates.map((template) => {
|
|
const isActive = activeId === template.id;
|
|
return (
|
|
<button
|
|
key={template.id}
|
|
type="button"
|
|
onClick={() => {
|
|
selectTemplate(template.id);
|
|
// 고른 순간 서버에도 남긴다 — 저장 안 하면 새로고침 한 번에 되돌아간다.
|
|
queueSiteTemplateSave(placeId, template.id);
|
|
}}
|
|
aria-pressed={isActive}
|
|
className={cn(
|
|
'block w-full overflow-hidden rounded-lg border p-2 text-left transition-all',
|
|
isActive
|
|
? 'border-primary ring-1 ring-primary'
|
|
: 'border-border hover:border-muted-foreground/50',
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="text-[11px] font-bold">{template.name}</span>
|
|
<span className="truncate text-[10px] text-muted-foreground">
|
|
{template.toneLabel} · {template.fontStyle}
|
|
</span>
|
|
{isActive && <Check className="ml-auto size-3 shrink-0 text-primary" />}
|
|
</span>
|
|
{/* 미리보기는 그 템플릿의 서체·모서리·그림자로 실제로 그린다 — 색 동그라미로는 뭘 고르는지 모른다. */}
|
|
<TemplatePreview template={template} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function ColorPalettePicker({open}: {open?: boolean}) {
|
|
const industry = useBuilderStore((s) => s.industry);
|
|
const selectedId = useBuilderStore((s) => s.colorPaletteId);
|
|
const selectColorPalette = useBuilderStore((s) => s.selectColorPalette);
|
|
const palettes = COLOR_PALETTE_PRESETS.filter((palette) => palette.industry === industry);
|
|
|
|
return (
|
|
<Group title="컬러 시스템" icon={Palette} count={`${palettes.length}종`} open={open}>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{palettes.map((palette) => {
|
|
const isActive = selectedId === palette.id;
|
|
return (
|
|
<button
|
|
key={palette.id}
|
|
type="button"
|
|
onClick={() => selectColorPalette(palette.id)}
|
|
aria-pressed={isActive}
|
|
className={cn(
|
|
'overflow-hidden rounded-lg border bg-card text-left transition-all',
|
|
isActive ? 'border-primary ring-1 ring-primary' : 'border-border hover:border-muted-foreground/50',
|
|
)}
|
|
>
|
|
<span className="grid h-7 grid-cols-4">
|
|
{palette.swatches.map((color) => (
|
|
<span key={color} style={{backgroundColor: color}} />
|
|
))}
|
|
</span>
|
|
<span className="flex items-center gap-1 px-2 py-1.5 text-[10px] font-semibold">
|
|
<span className="truncate">{palette.name}</span>
|
|
{isActive && <Check className="ml-auto size-3 shrink-0 text-primary" />}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{selectedId && (
|
|
<button
|
|
type="button"
|
|
onClick={() => selectColorPalette(null)}
|
|
className="w-full rounded-md border border-border py-1.5 text-[10px] font-semibold text-muted-foreground hover:bg-muted"
|
|
>
|
|
템플릿 기본 색상으로 되돌리기
|
|
</button>
|
|
)}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
export function SectionDesignPanel() {
|
|
const industry = useBuilderStore((s) => s.industry);
|
|
const sections = useBuilderStore((s) => s.sections);
|
|
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
|
|
const setSectionVariant = useBuilderStore((s) => s.setSectionVariant);
|
|
const resetSectionVariants = useBuilderStore((s) => s.resetSectionVariants);
|
|
|
|
const section = sections.find((s) => s.id === selectedSectionId);
|
|
const variants = section ? variantsFor(section.type, industry) : [];
|
|
const current = section ? resolveVariant(section, industry) : undefined;
|
|
|
|
if (!section) {
|
|
return (
|
|
<div className="flex-1 space-y-4 overflow-y-auto p-3.5">
|
|
<TemplatePicker open />
|
|
<ColorPalettePicker open />
|
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
|
<span className="flex size-9 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
<MousePointerClick className="size-4" />
|
|
</span>
|
|
<p className="text-xs font-semibold">섹션을 먼저 고르세요</p>
|
|
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
왼쪽 목록이나 미리보기에서 섹션을 누르면
|
|
<br />그 섹션의 레이아웃을 바꿀 수 있습니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 space-y-3 overflow-y-auto p-3.5">
|
|
{/* 큰 것부터 좁혀 간다 — 템플릿(전체) → 팔레트(색) → 이 섹션의 레이아웃. */}
|
|
<TemplatePicker />
|
|
<ColorPalettePicker />
|
|
<div className="flex items-center justify-between border-b border-border pb-2">
|
|
<span className="flex min-w-0 items-center gap-1.5 text-xs font-bold">
|
|
<Shapes className="size-3.5 shrink-0" />
|
|
<span className="truncate">{section.name}</span>
|
|
</span>
|
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">
|
|
{variants.length}종
|
|
</span>
|
|
</div>
|
|
|
|
{variants.length === 0 ? (
|
|
<p className="rounded-md border border-dashed border-border bg-muted/40 p-3 text-center text-[11px] leading-relaxed text-muted-foreground">
|
|
이 섹션은 아직 고를 수 있는 레이아웃이 하나뿐입니다.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{variants.map((variant) => {
|
|
const isActive = current?.id === variant.id;
|
|
|
|
return (
|
|
<li key={variant.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSectionVariant(section.id, variant.id)}
|
|
aria-pressed={isActive}
|
|
className={cn(
|
|
'flex w-full cursor-pointer items-start gap-2.5 rounded-lg border p-2.5 text-left transition-all',
|
|
isActive
|
|
? 'border-primary bg-primary/8 ring-1 ring-primary'
|
|
: 'border-border bg-card hover:bg-muted/60',
|
|
)}
|
|
>
|
|
<VariantThumb
|
|
thumb={variant.thumb}
|
|
className="h-[38px] w-[54px] shrink-0 rounded border border-border bg-white"
|
|
/>
|
|
|
|
<div className="min-w-0 flex-1 space-y-0.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-xs font-bold">{variant.name}</span>
|
|
{variant.isDefault && (
|
|
<span className="shrink-0 rounded bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground">
|
|
기본
|
|
</span>
|
|
)}
|
|
{isActive && <Check className="ml-auto size-3.5 shrink-0 text-primary" />}
|
|
</div>
|
|
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
{variant.description}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="space-y-2 border-t border-border pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={resetSectionVariants}
|
|
className="flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-md border border-border py-2 text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
>
|
|
<RotateCcw className="size-3" />
|
|
<span>모든 섹션을 기본 레이아웃으로</span>
|
|
</button>
|
|
|
|
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
레이아웃만 바뀝니다 —{' '}
|
|
<strong className="text-foreground">입력한 내용과 사진, 섹션 순서는 그대로</strong>{' '}
|
|
유지됩니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|