에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
CI / pr_and_merge (push) Blocked by required conditions
CI / test (push) Has been cancelled

This commit is contained in:
2025-11-20 00:53:39 +09:00
parent 211b0f8230
commit a6ef5f01cd
29 changed files with 3371 additions and 1052 deletions
+154 -531
View File
@@ -1,23 +1,102 @@
import { createStore } from "zustand";
import { create } from "zustand";
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
import { createBlogTemplateBlocks } from "@/app/editor/templates/blogTemplate";
import { createTeamTemplateBlocks } from "@/app/editor/templates/teamTemplate";
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트 블록
export type BlockType = "text" | "button" | "image" | "section" | "divider" | "list";
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록
export type BlockType = "text" | "button" | "image" | "section" | "divider" | "list" | "form";
// 텍스트 블록 속성
export interface TextBlockProps {
text: string;
// 텍스트 정렬: 기본은 left
align: "left" | "center" | "right";
// 텍스트 크기: 기본은 base
// 텍스트 크기: 기본은 base (기존 필드, 하위호환 유지)
size: "sm" | "base" | "lg";
// 폰트 크기 모드: 프리셋 스케일 또는 커스텀 값
fontSizeMode?: "scale" | "custom";
// 폰트 크기 스케일 (scale 모드일 때 사용)
fontSizeScale?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
// 폰트 크기 커스텀 값 (예: "18px", "1.125rem")
fontSizeCustom?: string;
// 줄 간격 모드: 프리셋 스케일 또는 커스텀 값
lineHeightMode?: "scale" | "custom";
// 줄 간격 스케일
lineHeightScale?: "tight" | "snug" | "normal" | "relaxed" | "loose";
// 줄 간격 커스텀 값 (예: "1.4", "24px")
lineHeightCustom?: string;
// 폰트 굵기 모드: 프리셋 스케일 또는 커스텀 값
fontWeightMode?: "scale" | "custom";
// 폰트 굵기 스케일
fontWeightScale?: "normal" | "medium" | "semibold" | "bold";
// 폰트 굵기 커스텀 값 (예: "500", "650")
fontWeightCustom?: string;
// 글자 간격 커스텀 값 (em 단위, 예: "0.05em", "-0.02em")
letterSpacingCustom?: string;
// 텍스트 색상 모드: 팔레트 또는 커스텀 웹색상
colorMode?: "palette" | "custom";
// 텍스트 색상 팔레트 (디자인 토큰)
colorPalette?:
| "default"
| "muted"
| "strong"
| "accent"
| "danger"
| "success"
| "warning"
| "info"
| "neutral";
// 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등)
colorCustom?: string;
// 최대 너비 모드: 프리셋 또는 커스텀 값
maxWidthMode?: "scale" | "custom";
// 최대 너비 스케일
maxWidthScale?: "none" | "prose" | "narrow";
// 최대 너비 커스텀 값 (예: "600px", "40rem")
maxWidthCustom?: string;
// 텍스트 장식
underline?: boolean;
strike?: boolean;
italic?: boolean;
}
// 버튼 블록 속성
export interface ButtonBlockProps {
label: string;
href: string;
// TODO: variant, size 등은 추후 확장
align?: "left" | "center" | "right";
// 버튼 크기: 더 세분화된 스케일(xs~xl)
size?: "xs" | "sm" | "md" | "lg" | "xl";
variant?: "solid" | "outline" | "ghost";
colorPalette?: "primary" | "muted" | "danger" | "success" | "neutral";
fullWidth?: boolean;
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 버튼 텍스트 크기 (예: "14px")
fontSizeCustom?: string;
// 버튼 텍스트 줄 간격 (예: "1.4")
lineHeightCustom?: string;
// 버튼 텍스트 글자 간격 (em 단위, 예: "0.05em")
letterSpacingCustom?: string;
// 채움(배경) 색상 커스텀 값
fillColorCustom?: string;
// 외곽선(보더) 색상 커스텀 값
strokeColorCustom?: string;
// 버튼 텍스트 색상 커스텀 값
textColorCustom?: string;
}
// 이미지 블록 속성
@@ -50,6 +129,15 @@ export interface SectionBlockProps {
}>;
}
// 폼 블록 속성 (1차 MVP: 고정 contact 폼)
export interface FormBlockProps {
kind: "contact";
// 전송 대상: 현재는 internal 만 지원, 이후 webhook 등 확장 가능
submitTarget: "internal";
successMessage?: string;
errorMessage?: string;
}
// 공통 블록 모델
export interface Block {
id: string;
@@ -60,7 +148,8 @@ export interface Block {
| ImageBlockProps
| SectionBlockProps
| DividerBlockProps
| ListBlockProps;
| ListBlockProps
| FormBlockProps;
// 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null)
sectionId?: string | null;
columnId?: string | null;
@@ -78,6 +167,7 @@ export interface EditorState {
addDividerBlock: () => void;
addListBlock: () => void;
addSectionBlock: () => void;
addFormBlock: () => void;
addHeroTemplateSection: () => void;
addFeaturesTemplateSection: () => void;
addCtaTemplateSection: () => void;
@@ -87,7 +177,10 @@ export interface EditorState {
addBlogTemplateSection: () => void;
addTeamTemplateSection: () => void;
addFooterTemplateSection: () => void;
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps>) => void;
updateBlock: (
id: string,
partial: Partial<TextBlockProps> | Partial<ButtonBlockProps>,
) => void;
selectBlock: (id: string | null) => void;
replaceBlocks: (blocks: Block[]) => void;
reorderBlocks: (activeId: string, overId: string) => void;
@@ -155,74 +248,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
addHeroTemplateSection: () => {
const sectionId = createId();
// Hero 템플릿용 섹션: 기본 배경/패딩 + 1컬럼(12)
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "lg",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
// Hero 텍스트 블록 (예: 헤드라인)
const heroHeadlineId = createId();
const heroHeadline: Block = {
id: heroHeadlineId,
type: "text",
props: {
text: "Hero 제목을 여기에 입력하세요",
align: "center",
size: "lg",
},
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
sectionId,
columnId: firstColumnId,
};
// Hero 서브텍스트 블록
const heroSubId = createId();
const heroSub: Block = {
id: heroSubId,
type: "text",
props: {
text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
align: "center",
size: "base",
},
sectionId,
columnId: firstColumnId,
};
// CTA 버튼 블록
const heroButtonId = createId();
const heroButton: Block = {
id: heroButtonId,
type: "button",
props: {
label: "지금 시작하기",
href: "#",
},
sectionId,
columnId: firstColumnId,
};
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, heroHeadline, heroSub, heroButton];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: heroButtonId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -230,64 +266,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
addFeaturesTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "md",
columns,
},
sectionId: null,
columnId: null,
};
const featureBlocks: Block[] = columns.flatMap((col, index) => {
const titleId = createId();
const descId = createId();
const title: Block = {
id: titleId,
type: "text",
props: {
text: `Feature ${index + 1} 제목`,
align: "left",
size: "lg",
},
sectionId,
columnId: col.id,
};
const description: Block = {
id: descId,
type: "text",
props: {
text: "해당 기능을 간단히 설명하는 텍스트입니다.",
align: "left",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [title, description];
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
sectionId,
createId,
});
const lastBlockId = featureBlocks[featureBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...featureBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -295,72 +284,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
addBlogTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const postDefinitions = [
{ title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." },
];
const blogBlocks: Block[] = columns.flatMap((col, index) => {
const post = postDefinitions[index] ?? postDefinitions[0];
const titleId = createId();
const summaryId = createId();
const titleBlock: Block = {
id: titleId,
type: "text",
props: {
text: post.title,
align: "left",
size: "lg",
},
sectionId,
columnId: col.id,
};
const summaryBlock: Block = {
id: summaryId,
type: "text",
props: {
text: post.summary,
align: "left",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [titleBlock, summaryBlock];
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
sectionId,
createId,
});
const lastBlockId = blogBlocks[blogBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...blogBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -368,85 +302,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
addTeamTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const memberDefinitions = [
{ name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." },
{ name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." },
{ name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." },
];
const teamBlocks: Block[] = columns.flatMap((col, index) => {
const m = memberDefinitions[index] ?? memberDefinitions[0];
const nameId = createId();
const roleId = createId();
const bioId = createId();
const nameBlock: Block = {
id: nameId,
type: "text",
props: {
text: m.name,
align: "center",
size: "lg",
},
sectionId,
columnId: col.id,
};
const roleBlock: Block = {
id: roleId,
type: "text",
props: {
text: m.role,
align: "center",
size: "base",
},
sectionId,
columnId: col.id,
};
const bioBlock: Block = {
id: bioId,
type: "text",
props: {
text: m.bio,
align: "center",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [nameBlock, roleBlock, bioBlock];
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
sectionId,
createId,
});
const lastBlockId = teamBlocks[teamBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...teamBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -454,59 +320,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
addFooterTemplateSection: () => {
const sectionId = createId();
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "md",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const linksId = createId();
const copyrightId = createId();
const linksBlock: Block = {
id: linksId,
type: "text",
props: {
text: "이용약관 · 개인정보처리방침",
align: "center",
size: "sm",
},
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
sectionId,
columnId: firstColumnId,
};
const copyrightBlock: Block = {
id: copyrightId,
type: "text",
props: {
text: "© 2025 MyLanding. All rights reserved.",
align: "center",
size: "sm",
},
sectionId,
columnId: firstColumnId,
};
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, linksBlock, copyrightBlock];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: copyrightId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -514,57 +338,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
addCtaTemplateSection: () => {
const sectionId = createId();
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "primary",
paddingY: "md",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const textId = createId();
const textBlock: Block = {
id: textId,
type: "text",
props: {
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.",
align: "center",
size: "base",
},
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
sectionId,
columnId: firstColumnId,
};
const buttonId = createId();
const buttonBlock: Block = {
id: buttonId,
type: "button",
props: {
label: "CTA 버튼",
href: "#",
},
sectionId,
columnId: firstColumnId,
};
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, textBlock, buttonBlock];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: buttonId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -573,79 +357,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
addFaqTemplateSection: () => {
const sectionId = createId();
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const faqPairs = [
{
question: "자주 묻는 질문 1",
answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
},
{
question: "자주 묻는 질문 2",
answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
},
{
question: "자주 묻는 질문 3",
answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
},
];
const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
const qId = createId();
const aId = createId();
const questionBlock: Block = {
id: qId,
type: "text",
props: {
text: pair.question,
align: "left",
size: "lg",
},
sectionId,
columnId: firstColumnId,
};
const answerBlock: Block = {
id: aId,
type: "text",
props: {
text: pair.answer,
align: "left",
size: "sm",
},
sectionId,
columnId: firstColumnId,
};
return [questionBlock, answerBlock];
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
sectionId,
createId,
});
const lastBlockId = faqBlocks[faqBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...faqBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -654,71 +376,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
addPricingTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const planDefinitions = [
{ name: "Basic", price: "₩9,900/월" },
{ name: "Pro", price: "₩29,900/월" },
{ name: "Enterprise", price: "문의" },
];
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
const plan = planDefinitions[index] ?? planDefinitions[0];
const nameId = createId();
const priceId = createId();
const nameBlock: Block = {
id: nameId,
type: "text",
props: {
text: plan.name,
align: "center",
size: "lg",
},
sectionId,
columnId: col.id,
};
const priceBlock: Block = {
id: priceId,
type: "text",
props: {
text: plan.price,
align: "center",
size: "base",
},
sectionId,
columnId: col.id,
};
return [nameBlock, priceBlock];
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
sectionId,
createId,
});
const lastBlockId = pricingBlocks[pricingBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...pricingBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -727,71 +395,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
addTestimonialsTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const testimonialDefinitions = [
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
];
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
const bodyId = createId();
const authorId = createId();
const bodyBlock: Block = {
id: bodyId,
type: "text",
props: {
text: t.body,
align: "left",
size: "base",
},
sectionId,
columnId: col.id,
};
const authorBlock: Block = {
id: authorId,
type: "text",
props: {
text: `- ${t.author}`,
align: "left",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [bodyBlock, authorBlock];
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
sectionId,
createId,
});
const lastBlockId = testimonialBlocks[testimonialBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...testimonialBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -967,6 +581,15 @@ const createEditorState = (set: any, get: any): EditorState => ({
props: {
label: "버튼",
href: "#",
align: "left",
size: "md",
variant: "solid",
colorPalette: "primary",
fullWidth: false,
borderRadius: "md",
fontSizeCustom: "14px",
fillColorCustom: "",
strokeColorCustom: "",
},
sectionId,
columnId,
@@ -985,7 +608,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
block.id === id
? {
...block,
props: { ...block.props, ...partial },
props: { ...(block.props as any), ...(partial as any) },
}
: block,
),