에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user