o2o-site-AEO/solution/frontend/src/features/builder/SectionDesignPanel.tsx
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(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>
2026-09-09 17:08:02 +09:00

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>
);
}