에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
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
@@ -0,0 +1,167 @@
import type { ChangeEvent } from "react";
import { useState } from "react";
export type ColorPaletteItem = {
// 팔레트 식별자
id: string;
// UI에 보여줄 이름
label: string;
// HEX 색상 값
color: string;
};
export type ColorPickerFieldProps = {
// 필드 라벨 텍스트
label: string;
// 컬러 인풋에 대한 접근성 라벨
ariaLabelColorInput: string;
// HEX 텍스트 인풋에 대한 접근성 라벨
ariaLabelHexInput: string;
// 현재 선택된 색상 값 (HEX)
value: string;
// 값 변경 콜백
onChange: (value: string) => void;
// 선택 가능한 팔레트 목록
palette?: ColorPaletteItem[];
// 현재 선택된 팔레트 ID (있다면 드롭다운 요약에 표시)
selectedPaletteId?: string;
// 팔레트 선택 시 호출되는 콜백 (팔레트 메타 정보까지 필요할 때 사용)
onPaletteSelect?: (item: ColorPaletteItem) => void;
};
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
// 기본/중립 계열
{ id: "default", label: "기본", color: "#e5e7eb" },
{ id: "muted", label: "연한", color: "#9ca3af" },
{ id: "strong", label: "강조", color: "#f9fafb" },
{ id: "neutral", label: "중립", color: "#94a3b8" },
// 브랜드/포인트 계열
{ id: "accent", label: "포인트", color: "#38bdf8" },
{ id: "accent-deep", label: "포인트 진하게", color: "#0ea5e9" },
{ id: "accent-soft", label: "포인트 연하게", color: "#bae6fd" },
// 상태 계열
{ id: "danger", label: "위험", color: "#f97373" },
{ id: "danger-deep", label: "위험 진하게", color: "#ef4444" },
{ id: "success", label: "성공", color: "#22c55e" },
{ id: "success-soft", label: "성공 연하게", color: "#bbf7d0" },
{ id: "warning", label: "경고", color: "#eab308" },
{ id: "info", label: "정보", color: "#0ea5e9" },
// 보라/핑크 계열
{ id: "purple", label: "보라", color: "#a855f7" },
{ id: "purple-soft", label: "연한 보라", color: "#e9d5ff" },
{ id: "pink", label: "핑크", color: "#ec4899" },
{ id: "pink-soft", label: "연한 핑크", color: "#f9a8d4" },
// 배경 대비용 어두운 텍스트
{ id: "dark", label: "어두운 텍스트", color: "#020617" },
{ id: "dark-muted", label: "어두운 연한", color: "#64748b" },
];
export function ColorPickerField({
label,
ariaLabelColorInput,
ariaLabelHexInput,
value,
onChange,
palette = [],
selectedPaletteId,
onPaletteSelect,
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
// 텍스트 인풋 변경 시 그대로 onChange로 전달한다.
const handleHexInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
// 팔레트 버튼 클릭 시 해당 팔레트의 색상으로 onChange를 호출한다.
const handlePaletteClick = (item: ColorPaletteItem) => {
onChange(item.color);
if (onPaletteSelect) {
onPaletteSelect(item);
}
};
// 현재 선택된 팔레트 정보를 계산한다.
const selectedPalette =
selectedPaletteId && palette.length > 0
? palette.find((item) => item.id === selectedPaletteId) ?? null
: null;
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>{label}</span>
<div className="flex items-center gap-2">
<input
type="color"
aria-label={ariaLabelColorInput}
className="h-8 w-8 rounded border border-slate-700 bg-slate-900 p-0"
value={value || "#ffffff"}
onChange={handleColorInputChange}
/>
<input
className="w-28 flex-none rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label={ariaLabelHexInput}
placeholder="예: #ff0000"
value={value}
onChange={handleHexInputChange}
/>
{palette.length > 0 ? (
<div className="relative text-[11px] flex-none">
<button
type="button"
className="w-28 rounded border border-slate-800 bg-slate-950/60 flex items-center justify-between px-2 py-1 text-left"
onClick={() => setOpen((prev) => !prev)}
>
<span className="flex items-center gap-1">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
/>
<span className="truncate text-slate-200">
{selectedPalette?.label ?? "색상 팔레트"}
</span>
</span>
<span className="text-slate-500 text-[10px]"></span>
</button>
{open ? (
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-800 bg-slate-950 max-h-40 overflow-auto z-10">
{palette.map((item) => (
<button
key={item.id}
type="button"
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-900 last:border-b-0 ${
selectedPaletteId === item.id
? "bg-sky-900/40 text-sky-100"
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
}`}
onClick={() => {
handlePaletteClick(item);
setOpen(false);
}}
>
<span className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span>{item.label}</span>
</span>
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
</label>
);
}
@@ -0,0 +1,97 @@
import type { ChangeEvent } from "react";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
export type NumericPresetOption = {
// 프리셋 식별자 (예: "tight", "normal")
id: string;
// UI 에 표시할 라벨
label: string;
// UI 기준 값 (px, 배율 등)
value: number;
};
export type NumericPropertyControlProps = {
// 필드 라벨 (예: 글자 크기, 줄 간격)
label: string;
// 단위 설명 (예: px, 배율 등)
unitLabel?: string;
// 현재 UI 값
value: number;
min: number;
max: number;
step: number;
// 프리셋 목록 (없으면 select 를 렌더링하지 않음)
presets?: NumericPresetOption[];
// 값 변경 시 호출되는 콜백 (UI 값 기준)
onChangeValue: (value: number) => void;
};
export function NumericPropertyControl({
label,
unitLabel,
value,
min,
max,
step,
presets,
onChangeValue,
}: NumericPropertyControlProps) {
// 현재 값에 가장 가까운 프리셋 ID 를 계산한다.
const currentPresetId = (() => {
if (!presets || presets.length === 0) return "";
let bestId = presets[0].id;
let bestDist = Math.abs(presets[0].value - value);
for (let i = 1; i < presets.length; i += 1) {
const dist = Math.abs(presets[i].value - value);
if (dist < bestDist) {
bestDist = dist;
bestId = presets[i].id;
}
}
return bestId;
})();
const handlePresetChange = (event: ChangeEvent<HTMLSelectElement>) => {
if (!presets || presets.length === 0) return;
const id = event.target.value;
const found = presets.find((p) => p.id === id);
if (!found) return;
onChangeValue(found.value);
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>
{label}
{unitLabel ? ` ${unitLabel}` : ""}
</span>
<div className="flex items-center gap-2">
{presets && presets.length > 0 ? (
<select
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label={`${label} 프리셋`}
value={currentPresetId}
onChange={handlePresetChange}
>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
) : null}
<PropertySliderField
label=""
ariaLabelSlider={`${label} 슬라이더`}
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
value={Number.isFinite(value) ? value : 0}
min={min}
max={max}
step={step}
onChange={onChangeValue}
/>
</div>
</label>
);
}
@@ -0,0 +1,70 @@
import type { ChangeEvent } from "react";
export type PropertySliderFieldProps = {
// 슬라이더 그룹 전체 라벨 텍스트
label: string;
// 슬라이더 요소에 대한 접근성 라벨
ariaLabelSlider: string;
// 텍스트 입력 요소에 대한 접근성 라벨
ariaLabelInput: string;
// 현재 값 (슬라이더와 입력이 공유)
value: number;
// 최소 값
min: number;
// 최대 값
max: number;
// 슬라이더 스텝
step: number;
// 값 변경 콜백
onChange: (value: number) => void;
};
export function PropertySliderField({
label,
ariaLabelSlider,
ariaLabelInput,
value,
min,
max,
step,
onChange,
}: PropertySliderFieldProps) {
// 슬라이더 변경 시 상위에서 전달받은 onChange에 숫자 값으로 전달한다.
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
const numeric = Number(event.target.value || 0);
onChange(numeric);
};
// 텍스트 입력 변경 시에도 숫자 값으로 파싱하여 onChange를 호출한다.
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
const numeric = Number(raw);
if (!Number.isNaN(numeric)) {
onChange(numeric);
}
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>{label}</span>
<div className="flex items-center gap-2">
<input
type="range"
min={min}
max={max}
step={step}
aria-label={ariaLabelSlider}
value={value}
onChange={handleSliderChange}
className="flex-1"
/>
</div>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label={ariaLabelInput}
value={Number.isFinite(value) ? value : ""}
onChange={handleInputChange}
/>
</label>
);
}
@@ -1,6 +1,14 @@
"use client";
import type { Block, TextBlockProps, ButtonBlockProps, ImageBlockProps, SectionBlockProps } from "@/features/editor/state/editorStore";
import { useState } from "react";
import type {
Block,
TextBlockProps,
ButtonBlockProps,
ImageBlockProps,
SectionBlockProps,
FormBlockProps,
} from "@/features/editor/state/editorStore";
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
interface PublicPageRendererProps {
@@ -11,6 +19,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [formMessage, setFormMessage] = useState<string>("");
const renderBlock = (block: Block) => {
if (block.type === "text") {
const props = block.props as TextBlockProps;
@@ -20,7 +31,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sizeClass = props.size === "sm" ? "text-sm" : props.size === "lg" ? "text-2xl" : "text-base";
return (
<p key={block.id} className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed`}>
<p
key={block.id}
className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
>
{props.text}
</p>
);
@@ -35,11 +49,98 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
href={props.href}
className="inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 transition-colors"
>
{props.label}
<span className="whitespace-pre-wrap">{props.label}</span>
</a>
);
}
if (block.type === "form") {
const props = block.props as FormBlockProps;
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
try {
setFormStatus("submitting");
setFormMessage("");
const res = await fetch("/api/forms/submit", {
method: "POST",
body: data,
});
if (res.ok) {
setFormStatus("success");
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
form.reset();
} else {
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("[PublicPageRenderer] form submit error", error);
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
};
return (
<form key={block.id} className="space-y-3" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1 text-xs text-slate-200">
<label className="flex flex-col gap-1">
<span></span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name="name"
placeholder="이름을 입력하세요"
required
/>
</label>
<label className="flex flex-col gap-1">
<span></span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name="email"
type="email"
placeholder="you@example.com"
required
/>
</label>
<label className="flex flex-col gap-1">
<span></span>
<textarea
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name="message"
placeholder="전달할 내용을 입력하세요"
required
/>
</label>
</div>
<button
type="submit"
disabled={formStatus === "submitting"}
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
>
{formStatus === "submitting" ? "전송 중..." : "폼 전송"}
</button>
{formStatus !== "idle" && formMessage ? (
<p
className={`text-xs mt-1 ${
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
}`}
>
{formMessage}
</p>
) : null}
</form>
);
}
if (block.type === "image") {
const props = block.props as ImageBlockProps;
+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,
),