1339 lines
51 KiB
TypeScript
1339 lines
51 KiB
TypeScript
"use client";
|
|
|
|
import type { CSSProperties, ReactNode } from "react";
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
DndContext,
|
|
PointerSensor,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
DragEndEvent,
|
|
useDroppable,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
useSortable,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { useEditorStore } from "@/features/editor/state/editorStore";
|
|
import type {
|
|
Block,
|
|
TextBlockProps,
|
|
ButtonBlockProps,
|
|
ImageBlockProps,
|
|
SectionBlockProps,
|
|
DividerBlockProps,
|
|
ListBlockProps,
|
|
FormBlockProps,
|
|
} from "@/features/editor/state/editorStore";
|
|
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
|
|
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
|
|
import { ListPropertiesPanel } from "./panels/ListPropertiesPanel";
|
|
import { DividerPropertiesPanel } from "./panels/DividerPropertiesPanel";
|
|
import { ImagePropertiesPanel } from "./panels/ImagePropertiesPanel";
|
|
import { SectionPropertiesPanel } from "./panels/SectionPropertiesPanel";
|
|
|
|
export default function EditorPage() {
|
|
const blocks = useEditorStore((state) => state.blocks);
|
|
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
|
const addTextBlock = useEditorStore((state) => state.addTextBlock);
|
|
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
|
|
const addImageBlock = useEditorStore((state) => state.addImageBlock);
|
|
const addDividerBlock = useEditorStore((state) => state.addDividerBlock);
|
|
const addListBlock = useEditorStore((state) => state.addListBlock);
|
|
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
|
|
const addFormBlock = useEditorStore((state) => state.addFormBlock);
|
|
const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection);
|
|
const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection);
|
|
const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection);
|
|
const addFaqTemplateSection = useEditorStore((state) => state.addFaqTemplateSection);
|
|
const addPricingTemplateSection = useEditorStore((state) => state.addPricingTemplateSection);
|
|
const addTestimonialsTemplateSection = useEditorStore((state) => state.addTestimonialsTemplateSection);
|
|
const addBlogTemplateSection = useEditorStore((state) => state.addBlogTemplateSection);
|
|
const addTeamTemplateSection = useEditorStore((state) => state.addTeamTemplateSection);
|
|
const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection);
|
|
const updateBlock = useEditorStore((state) => state.updateBlock);
|
|
const selectBlock = useEditorStore((state) => state.selectBlock);
|
|
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
|
const reorderBlocks = useEditorStore((state) => state.reorderBlocks);
|
|
const moveBlock = useEditorStore((state) => state.moveBlock);
|
|
const undo = useEditorStore((state) => state.undo);
|
|
const redo = useEditorStore((state) => state.redo);
|
|
const removeBlock = useEditorStore((state) => state.removeBlock);
|
|
const duplicateBlock = useEditorStore((state) => state.duplicateBlock);
|
|
|
|
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
|
const [editingText, setEditingText] = useState("");
|
|
|
|
const [exportJson, setExportJson] = useState("");
|
|
const [importJson, setImportJson] = useState("");
|
|
|
|
const [projectTitle, setProjectTitle] = useState("");
|
|
const [projectSlug, setProjectSlug] = useState("");
|
|
const [loadSlug, setLoadSlug] = useState("");
|
|
const [projectMessage, setProjectMessage] = useState("");
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
|
|
|
|
const sensors = useSensors(useSensor(PointerSensor));
|
|
|
|
const startEditing = (id: string, initialText: string) => {
|
|
selectBlock(id);
|
|
setEditingBlockId(id);
|
|
setEditingText(initialText);
|
|
};
|
|
|
|
const commitEditing = () => {
|
|
if (!editingBlockId) return;
|
|
updateBlock(editingBlockId, { text: editingText });
|
|
setEditingBlockId(null);
|
|
};
|
|
|
|
const cancelEditing = () => {
|
|
if (!editingBlockId) return;
|
|
const current = blocks.find((b) => b.id === editingBlockId);
|
|
if (current && current.type === "text") {
|
|
const textProps = current.props as TextBlockProps;
|
|
setEditingText(textProps.text);
|
|
}
|
|
setEditingBlockId(null);
|
|
};
|
|
|
|
const handleExportJson = () => {
|
|
try {
|
|
const payload = JSON.stringify(blocks, null, 2);
|
|
setExportJson(payload);
|
|
setImportJson(payload);
|
|
} catch {
|
|
// JSON 직렬화 실패 시는 조용히 무시 (순수 클라이언트 상태라 치명적이지 않음)
|
|
}
|
|
};
|
|
|
|
const handleClearCanvas = () => {
|
|
replaceBlocks([]);
|
|
setExportJson("");
|
|
setImportJson("");
|
|
};
|
|
|
|
const handleApplyImportJson = () => {
|
|
if (!importJson.trim()) return;
|
|
try {
|
|
const parsed = JSON.parse(importJson);
|
|
if (!Array.isArray(parsed)) return;
|
|
// 최소한의 구조 검증: id, type, props.text 가 있는 블록만 사용
|
|
const safeBlocks = parsed.filter((b) => b && typeof b.id === "string" && b.type === "text" && b.props && typeof b.props.text === "string");
|
|
replaceBlocks(safeBlocks);
|
|
// 내보내기 영역도 최신 상태로 업데이트
|
|
setExportJson(JSON.stringify(safeBlocks, null, 2));
|
|
} catch {
|
|
// 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능)
|
|
}
|
|
};
|
|
|
|
const handleSaveProject = async () => {
|
|
const slug = projectSlug.trim();
|
|
if (!slug) {
|
|
setProjectMessage("프로젝트 주소를 입력해 주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
|
|
if (typeof window !== "undefined") {
|
|
const localKey = `pb:project:${slug}`;
|
|
const payload = {
|
|
title: projectTitle.trim() || "제목 없음",
|
|
contentJson: blocks,
|
|
};
|
|
window.localStorage.setItem(localKey, JSON.stringify(payload));
|
|
}
|
|
|
|
// 2) 서버에도 저장 (백업/공유용)
|
|
const response = await fetch("/api/projects", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
title: projectTitle.trim() || "제목 없음",
|
|
slug,
|
|
contentJson: blocks,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
setProjectMessage("프로젝트 저장에 실패했습니다.");
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
|
|
} catch (error) {
|
|
console.error(error);
|
|
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleLoadProject = async () => {
|
|
const slug = loadSlug.trim() || projectSlug.trim();
|
|
if (!slug) {
|
|
setProjectMessage("불러올 주소를 입력해 주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 1) 로컬스토리지에서 먼저 조회
|
|
if (typeof window !== "undefined") {
|
|
const localKey = `pb:project:${slug}`;
|
|
const raw = window.localStorage.getItem(localKey);
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && Array.isArray(parsed.contentJson)) {
|
|
replaceBlocks(parsed.contentJson as any);
|
|
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
|
return;
|
|
}
|
|
} catch {
|
|
// 로컬 파싱 에러는 무시하고 서버 시도
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) 로컬에 없으면 서버에서 조회
|
|
const response = await fetch(`/api/projects/${slug}`);
|
|
if (!response.ok) {
|
|
setProjectMessage("프로젝트를 불러오지 못했습니다.");
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data && Array.isArray(data.contentJson)) {
|
|
replaceBlocks(data.contentJson as any);
|
|
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
|
|
} else {
|
|
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
setProjectMessage("프로젝트 불러오기 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
const activeId = String(active.id);
|
|
const overId = String(over.id);
|
|
|
|
// 컬럼 droppable에 드롭한 경우: 해당 섹션/컬럼으로 이동만 수행한다.
|
|
if (overId.startsWith("column:")) {
|
|
const [, sectionId, columnId] = overId.split(":");
|
|
moveBlock(activeId, sectionId || null, columnId || null);
|
|
return;
|
|
}
|
|
|
|
const activeBlock = blocks.find((b) => b.id === activeId);
|
|
const overBlock = blocks.find((b) => b.id === overId);
|
|
|
|
if (!activeBlock || !overBlock) {
|
|
reorderBlocks(activeId, overId);
|
|
return;
|
|
}
|
|
|
|
const activeSectionId = activeBlock.sectionId ?? null;
|
|
const activeColumnId = activeBlock.columnId ?? null;
|
|
const overSectionId = overBlock.sectionId ?? null;
|
|
const overColumnId = overBlock.columnId ?? null;
|
|
|
|
// 같은 섹션/컬럼 내에서는 순서만 변경한다.
|
|
if (activeSectionId === overSectionId && activeColumnId === overColumnId) {
|
|
reorderBlocks(activeId, overId);
|
|
return;
|
|
}
|
|
|
|
// 다른 컬럼/섹션으로 이동: 위치를 변경한 뒤, 블록 기준 드롭이면 순서 재배치까지 진행한다.
|
|
moveBlock(activeId, overSectionId, overColumnId);
|
|
reorderBlocks(activeId, overId);
|
|
};
|
|
|
|
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
|
|
|
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
|
|
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
|
|
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
// 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동)
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } =
|
|
(useEditorStore as any).getState();
|
|
|
|
if (!currentBlocks.length) return;
|
|
|
|
const currentIndex = currentSelectedId
|
|
? currentBlocks.findIndex((b: Block) => b.id === currentSelectedId)
|
|
: -1;
|
|
|
|
let nextIndex = currentIndex;
|
|
|
|
if (event.key === "ArrowDown") {
|
|
nextIndex = currentIndex < currentBlocks.length - 1 ? currentIndex + 1 : currentIndex;
|
|
} else if (event.key === "ArrowUp") {
|
|
nextIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex;
|
|
}
|
|
|
|
if (nextIndex !== -1 && nextIndex !== currentIndex) {
|
|
event.preventDefault();
|
|
const nextId = currentBlocks[nextIndex]?.id;
|
|
if (nextId) {
|
|
storeSelectBlock(nextId);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const isMac = navigator.platform.toLowerCase().includes("mac");
|
|
const metaKey = isMac ? event.metaKey : event.ctrlKey;
|
|
|
|
// Delete / Backspace 로 선택된 블록 삭제
|
|
if (event.key === "Delete" || event.key === "Backspace") {
|
|
const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState();
|
|
if (currentSelectedId) {
|
|
event.preventDefault();
|
|
removeBlock(currentSelectedId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Cmd/Ctrl 없는 경우에는 여기서 종료한다.
|
|
if (!metaKey) return;
|
|
|
|
// Cmd/Ctrl + D → 현재 선택된 블록 복제
|
|
if (event.key.toLowerCase() === "d") {
|
|
const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState();
|
|
if (currentSelectedId) {
|
|
event.preventDefault();
|
|
duplicateBlock(currentSelectedId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Cmd/Ctrl + Shift + Z → Redo
|
|
if (event.key.toLowerCase() === "z" && event.shiftKey) {
|
|
event.preventDefault();
|
|
redo();
|
|
return;
|
|
}
|
|
|
|
// Cmd/Ctrl + Z → Undo
|
|
if (event.key.toLowerCase() === "z") {
|
|
event.preventDefault();
|
|
undo();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [undo, redo]);
|
|
|
|
const renderBlocks = (targetBlocks: Block[]) =>
|
|
targetBlocks.map((block) => {
|
|
const isSelected = block.id === selectedBlockId;
|
|
const isEditing = block.id === editingBlockId;
|
|
|
|
return (
|
|
<SortableEditorBlock
|
|
key={block.id}
|
|
block={block}
|
|
isSelected={isSelected}
|
|
isEditing={isEditing}
|
|
editingText={editingText}
|
|
startEditing={startEditing}
|
|
commitEditing={commitEditing}
|
|
cancelEditing={cancelEditing}
|
|
setEditingText={setEditingText}
|
|
selectBlock={selectBlock}
|
|
allBlocks={blocks}
|
|
renderBlocks={renderBlocks}
|
|
updateBlock={updateBlock}
|
|
/>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
|
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
|
|
<div className="flex items-baseline gap-3">
|
|
<h1 className="text-xl font-semibold">Page Editor</h1>
|
|
<p className="text-xs text-slate-400">빌더 MVP용 에디터 페이지 골격</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Link
|
|
href="/preview"
|
|
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
|
>
|
|
프리뷰 열기
|
|
</Link>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
|
onClick={() => setMenuOpen((prev) => !prev)}
|
|
>
|
|
메뉴
|
|
<span className="text-[9px]">▼</span>
|
|
</button>
|
|
{menuOpen && (
|
|
<div className="absolute right-0 mt-1 w-48 rounded border border-slate-700 bg-slate-900 shadow-lg text-[11px] z-30">
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
setActiveModal("project");
|
|
}}
|
|
>
|
|
프로젝트 저장/불러오기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
setActiveModal("json");
|
|
}}
|
|
>
|
|
JSON 내보내기/불러오기
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<section className="flex flex-1 overflow-hidden">
|
|
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
|
|
<h2 className="font-medium">블록</h2>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addTextBlock}
|
|
>
|
|
텍스트 블록 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addButtonBlock}
|
|
>
|
|
버튼 블록 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addImageBlock}
|
|
>
|
|
이미지 블록 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addDividerBlock}
|
|
>
|
|
구분선 블록 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addListBlock}
|
|
>
|
|
리스트 블록 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addSectionBlock}
|
|
>
|
|
섹션 블록 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
|
|
onClick={addFormBlock}
|
|
>
|
|
폼 블록 추가
|
|
</button>
|
|
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
|
|
<h3 className="text-[11px] font-medium text-slate-300">템플릿</h3>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-sky-700 bg-sky-950 px-3 py-2 text-left text-xs text-sky-100 hover:bg-sky-900"
|
|
onClick={addHeroTemplateSection}
|
|
>
|
|
Hero 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addFeaturesTemplateSection}
|
|
>
|
|
Features 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addCtaTemplateSection}
|
|
>
|
|
CTA 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addFaqTemplateSection}
|
|
>
|
|
FAQ 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addPricingTemplateSection}
|
|
>
|
|
Pricing 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addTestimonialsTemplateSection}
|
|
>
|
|
Testimonials 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addBlogTemplateSection}
|
|
>
|
|
Blog 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addTeamTemplateSection}
|
|
>
|
|
Team 템플릿 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
|
onClick={addFooterTemplateSection}
|
|
>
|
|
Footer 템플릿 추가
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-slate-500">
|
|
Text / Image / Button / Divider / List / Section 블록을 이 영역에서 추가할 수 있습니다.
|
|
</p>
|
|
</aside>
|
|
<div
|
|
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
|
|
data-testid="editor-canvas"
|
|
>
|
|
{blocks.length === 0 ? (
|
|
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
|
|
왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
|
|
</div>
|
|
) : (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={blocks.map((b) => b.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{renderBlocks(rootBlocks)}
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
</div>
|
|
<aside className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto">
|
|
<h2 className="font-medium mb-2">속성 패널</h2>
|
|
{selectedBlockId ? (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2 text-[11px]">
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
|
|
onClick={() => removeBlock(selectedBlockId)}
|
|
>
|
|
블록 삭제
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
|
onClick={() => duplicateBlock(selectedBlockId)}
|
|
>
|
|
블록 복제
|
|
</button>
|
|
</div>
|
|
{(() => {
|
|
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
|
|
if (!selectedBlock) return null;
|
|
|
|
if (selectedBlock.type === "text") {
|
|
const textProps = selectedBlock.props as TextBlockProps;
|
|
|
|
return (
|
|
<TextPropertiesPanel
|
|
textProps={textProps}
|
|
selectedBlockId={selectedBlockId}
|
|
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
editingBlockId={editingBlockId}
|
|
setEditingText={setEditingText}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (selectedBlock.type === "divider") {
|
|
const dividerProps = selectedBlock.props as DividerBlockProps;
|
|
|
|
return (
|
|
<DividerPropertiesPanel
|
|
dividerProps={dividerProps}
|
|
selectedBlockId={selectedBlockId}
|
|
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (selectedBlock.type === "image") {
|
|
const imageProps = selectedBlock.props as ImageBlockProps;
|
|
|
|
return (
|
|
<ImagePropertiesPanel
|
|
imageProps={imageProps}
|
|
selectedBlockId={selectedBlockId}
|
|
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (selectedBlock.type === "section") {
|
|
const sectionProps = selectedBlock.props as SectionBlockProps;
|
|
|
|
return (
|
|
<SectionPropertiesPanel
|
|
sectionProps={sectionProps}
|
|
selectedBlockId={selectedBlockId}
|
|
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (selectedBlock.type === "list") {
|
|
const listProps = selectedBlock.props as ListBlockProps;
|
|
|
|
return (
|
|
<ListPropertiesPanel
|
|
listProps={listProps}
|
|
selectedBlockId={selectedBlockId}
|
|
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (selectedBlock.type === "button") {
|
|
const buttonProps = selectedBlock.props as ButtonBlockProps;
|
|
|
|
return (
|
|
<ButtonPropertiesPanel
|
|
buttonProps={buttonProps}
|
|
selectedBlockId={selectedBlockId}
|
|
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (selectedBlock.type === "form") {
|
|
const formProps = selectedBlock.props as FormBlockProps;
|
|
|
|
return (
|
|
<div className="space-y-3 text-xs">
|
|
<p className="text-slate-400">
|
|
이 폼은 퍼블릭 페이지에서 /api/forms/submit 엔드포인트로 전송됩니다.
|
|
</p>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">성공 메시지</span>
|
|
<input
|
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
value={formProps.successMessage ?? "성공적으로 전송되었습니다."}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
successMessage: e.target.value,
|
|
} as any)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">실패 메시지</span>
|
|
<input
|
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
value={formProps.errorMessage ?? "전송 중 오류가 발생했습니다."}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
errorMessage: e.target.value,
|
|
} as any)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
})()}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-slate-500">
|
|
캔버스에서 블록을 선택하면 이곳에서 내용을 수정하고 스타일을 바꿀 수 있습니다.
|
|
</p>
|
|
)}
|
|
</aside>
|
|
</section>
|
|
|
|
{activeModal === "project" && (
|
|
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
|
<div className="w-full max-w-md rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium">프로젝트 저장 / 불러오기</h3>
|
|
<button
|
|
type="button"
|
|
className="text-slate-400 hover:text-slate-100 text-sm"
|
|
onClick={() => setActiveModal(null)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">프로젝트 제목</span>
|
|
<input
|
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
value={projectTitle}
|
|
onChange={(e) => setProjectTitle(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">프로젝트 주소 (예: my-landing)</span>
|
|
<input
|
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
value={projectSlug}
|
|
onChange={(e) => setProjectSlug(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded border border-sky-700 bg-sky-950 px-2 py-1 text-sky-100 hover:bg-sky-900"
|
|
onClick={handleSaveProject}
|
|
>
|
|
저장 (로컬 + 서버)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
|
onClick={handleLoadProject}
|
|
>
|
|
불러오기
|
|
</button>
|
|
</div>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">불러올 주소 (비우면 위 주소 사용)</span>
|
|
<input
|
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
value={loadSlug}
|
|
onChange={(e) => setLoadSlug(e.target.value)}
|
|
/>
|
|
</label>
|
|
{projectMessage && (
|
|
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeModal === "json" && (
|
|
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
|
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium">JSON Export / Import</h3>
|
|
<button
|
|
type="button"
|
|
className="text-slate-400 hover:text-slate-100 text-sm"
|
|
onClick={() => setActiveModal(null)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2 mb-1">
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 hover:bg-slate-800"
|
|
onClick={handleExportJson}
|
|
>
|
|
JSON 내보내기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-red-700 bg-red-950 px-2 py-1 text-red-100 hover:bg-red-900"
|
|
onClick={handleClearCanvas}
|
|
>
|
|
캔버스 초기화
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">에디터 상태 JSON</span>
|
|
<textarea
|
|
className="w-full h-48 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
|
|
value={exportJson}
|
|
readOnly
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">JSON에서 불러오기</span>
|
|
<textarea
|
|
className="w-full h-48 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
|
|
value={importJson}
|
|
onChange={(e) => setImportJson(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="mt-1 w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 hover:bg-slate-800"
|
|
onClick={handleApplyImportJson}
|
|
>
|
|
JSON 적용하기
|
|
</button>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
interface TextInlineToolbarProps {
|
|
align: TextBlockProps["align"];
|
|
size: TextBlockProps["size"];
|
|
onChangeAlign: (value: TextBlockProps["align"]) => void;
|
|
onChangeSize: (value: TextBlockProps["size"]) => void;
|
|
}
|
|
|
|
function TextInlineToolbar({ align, size, onChangeAlign, onChangeSize }: TextInlineToolbarProps) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-2 text-[10px] text-slate-300">
|
|
<div className="inline-flex items-center gap-1">
|
|
<span className="text-slate-500">정렬</span>
|
|
<button
|
|
type="button"
|
|
className={`px-1 py-0.5 rounded border text-[10px] ${
|
|
align === "left"
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
}`}
|
|
aria-label="왼쪽 정렬"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onChangeAlign("left")}
|
|
>
|
|
좌
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`px-1 py-0.5 rounded border text-[10px] ${
|
|
align === "center"
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
}`}
|
|
aria-label="가운데 정렬"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onChangeAlign("center")}
|
|
>
|
|
중
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`px-1 py-0.5 rounded border text-[10px] ${
|
|
align === "right"
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
}`}
|
|
aria-label="오른쪽 정렬"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onChangeAlign("right")}
|
|
>
|
|
우
|
|
</button>
|
|
</div>
|
|
<div className="inline-flex items-center gap-1">
|
|
<span className="text-slate-500">크기</span>
|
|
<button
|
|
type="button"
|
|
className={`px-1 py-0.5 rounded border text-[10px] ${
|
|
size === "sm"
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
}`}
|
|
aria-label="글자 작게"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onChangeSize("sm")}
|
|
>
|
|
작게
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`px-1 py-0.5 rounded border text-[10px] ${
|
|
size === "base"
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
}`}
|
|
aria-label="글자 보통"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onChangeSize("base")}
|
|
>
|
|
보통
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`px-1 py-0.5 rounded border text-[10px] ${
|
|
size === "lg"
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
}`}
|
|
aria-label="글자 크게"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onChangeSize("lg")}
|
|
>
|
|
크게
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SortableEditorBlockProps {
|
|
block: Block;
|
|
isSelected: boolean;
|
|
isEditing: boolean;
|
|
editingText: string;
|
|
startEditing: (id: string, initialText: string) => void;
|
|
commitEditing: () => void;
|
|
cancelEditing: () => void;
|
|
setEditingText: (value: string) => void;
|
|
selectBlock: (id: string) => void;
|
|
allBlocks: Block[];
|
|
renderBlocks: (targetBlocks: Block[]) => ReactNode;
|
|
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps & FormBlockProps>) => void;
|
|
}
|
|
|
|
function SortableEditorBlock({
|
|
block,
|
|
isSelected,
|
|
isEditing,
|
|
editingText,
|
|
startEditing,
|
|
commitEditing,
|
|
cancelEditing,
|
|
setEditingText,
|
|
selectBlock,
|
|
allBlocks,
|
|
renderBlocks,
|
|
updateBlock,
|
|
}: SortableEditorBlockProps) {
|
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
|
id: block.id,
|
|
});
|
|
|
|
const baseStyle: CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
let alignClass = "";
|
|
let sizeClass = "";
|
|
let leadingClass = "";
|
|
let weightClass = "";
|
|
let colorClass = "";
|
|
let maxWidthClass = "";
|
|
let decorationClass = "";
|
|
const textStyleOverrides: CSSProperties = {};
|
|
|
|
if (block.type === "text") {
|
|
const textProps = block.props as TextBlockProps;
|
|
|
|
// 정렬: pb-text-*
|
|
alignClass =
|
|
textProps.align === "center"
|
|
? "pb-text-center"
|
|
: textProps.align === "right"
|
|
? "pb-text-right"
|
|
: "pb-text-left";
|
|
|
|
// 폰트 크기: scale/custom + 기존 size 값 fallback
|
|
const fontSizeMode = textProps.fontSizeMode ?? "scale";
|
|
const fallbackScale =
|
|
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
|
|
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
|
|
|
|
if (fontSizeMode === "scale") {
|
|
sizeClass = `pb-text-${fontSizeScale}`;
|
|
} else if (fontSizeMode === "custom" && textProps.fontSizeCustom) {
|
|
textStyleOverrides.fontSize = textProps.fontSizeCustom;
|
|
}
|
|
|
|
// 줄 간격: scale/custom
|
|
const lineHeightMode = textProps.lineHeightMode ?? "scale";
|
|
const lineHeightScale = textProps.lineHeightScale ?? "normal";
|
|
if (lineHeightMode === "scale") {
|
|
leadingClass = `pb-leading-${lineHeightScale}`;
|
|
} else if (lineHeightMode === "custom" && textProps.lineHeightCustom) {
|
|
textStyleOverrides.lineHeight = textProps.lineHeightCustom;
|
|
}
|
|
|
|
// 굵기: scale/custom
|
|
const fontWeightMode = textProps.fontWeightMode ?? "scale";
|
|
const fontWeightScale = textProps.fontWeightScale ?? "normal";
|
|
if (fontWeightMode === "scale") {
|
|
weightClass = `pb-font-${fontWeightScale}`;
|
|
} else if (fontWeightMode === "custom" && textProps.fontWeightCustom) {
|
|
textStyleOverrides.fontWeight = textProps.fontWeightCustom as CSSProperties["fontWeight"];
|
|
}
|
|
|
|
// 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트
|
|
const colorPalette = textProps.colorPalette ?? "default";
|
|
if (textProps.colorCustom && textProps.colorCustom.trim() !== "") {
|
|
textStyleOverrides.color = textProps.colorCustom;
|
|
} else {
|
|
colorClass = `pb-text-color-${colorPalette}`;
|
|
}
|
|
|
|
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
|
|
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
|
if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") {
|
|
textStyleOverrides.maxWidth = textProps.maxWidthCustom;
|
|
} else if (maxWidthScale === "prose") {
|
|
maxWidthClass = "pb-text-maxw-prose";
|
|
} else if (maxWidthScale === "narrow") {
|
|
maxWidthClass = "pb-text-maxw-narrow";
|
|
}
|
|
|
|
// 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용
|
|
if (textProps.letterSpacingCustom && textProps.letterSpacingCustom.trim() !== "") {
|
|
textStyleOverrides.letterSpacing = textProps.letterSpacingCustom;
|
|
}
|
|
|
|
// 텍스트 장식: 밑줄/가운데줄/이탤릭
|
|
if (textProps.underline) {
|
|
decorationClass += " pb-underline";
|
|
}
|
|
if (textProps.strike) {
|
|
decorationClass += " pb-line-through";
|
|
}
|
|
if (textProps.italic) {
|
|
decorationClass += " pb-italic";
|
|
}
|
|
} else if (block.type === "button") {
|
|
const buttonProps = block.props as ButtonBlockProps;
|
|
const align = buttonProps.align ?? "left";
|
|
alignClass =
|
|
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
|
} else if (block.type === "image") {
|
|
alignClass = "pb-text-left";
|
|
} else if (block.type === "divider") {
|
|
const dividerProps = block.props as DividerBlockProps;
|
|
alignClass =
|
|
dividerProps.align === "center"
|
|
? "pb-text-center"
|
|
: dividerProps.align === "right"
|
|
? "pb-text-right"
|
|
: "pb-text-left";
|
|
} else if (block.type === "list") {
|
|
const listProps = block.props as ListBlockProps;
|
|
alignClass =
|
|
listProps.align === "center"
|
|
? "pb-text-center"
|
|
: listProps.align === "right"
|
|
? "pb-text-right"
|
|
: "pb-text-left";
|
|
} else if (block.type === "section") {
|
|
// 섹션 블록: 텍스트 크기 대신 섹션 자체에 패딩/배경을 입히므로 텍스트 클래스는 기본값만
|
|
alignClass = "pb-text-left";
|
|
} else if (block.type === "form") {
|
|
// 폼 블록은 텍스트 클래스 대신 기본 정렬만 사용한다.
|
|
alignClass = "pb-text-left";
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{ ...baseStyle, ...textStyleOverrides }}
|
|
data-testid="editor-block"
|
|
data-selected={isSelected ? "true" : "false"}
|
|
aria-selected={isSelected ? "true" : "false"}
|
|
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${
|
|
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
|
|
}`}
|
|
onClick={() => selectBlock(block.id)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<button
|
|
type="button"
|
|
className="mt-0.5 h-4 w-4 rounded border border-slate-700 bg-slate-900 text-[10px] flex items-center justify-center text-slate-400 hover:bg-slate-800"
|
|
aria-label="블록 드래그 핸들"
|
|
{...listeners}
|
|
{...attributes}
|
|
>
|
|
≡
|
|
</button>
|
|
<div className="flex-1">
|
|
{block.type === "text" && (
|
|
isEditing ? (
|
|
(() => {
|
|
const textProps = block.props as TextBlockProps;
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<textarea
|
|
className="w-full bg-transparent outline-none resize-none text-sm"
|
|
aria-label="텍스트 블록 내용 편집"
|
|
value={editingText}
|
|
onChange={(e) => setEditingText(e.target.value)}
|
|
onBlur={commitEditing}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
commitEditing();
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
cancelEditing();
|
|
}
|
|
}}
|
|
rows={1}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
);
|
|
})()
|
|
) : (
|
|
<div className="whitespace-pre-wrap">{(block.props as TextBlockProps).text}</div>
|
|
)
|
|
)}
|
|
{block.type === "button" && (() => {
|
|
const buttonProps = block.props as ButtonBlockProps;
|
|
const size = buttonProps.size ?? "md";
|
|
const variant = buttonProps.variant ?? "solid";
|
|
const colorPalette = buttonProps.colorPalette ?? "primary";
|
|
const radius = buttonProps.borderRadius ?? "md";
|
|
const fullWidth = buttonProps.fullWidth ?? false;
|
|
|
|
const sizeClassBtn =
|
|
size === "xs"
|
|
? "pb-btn-size-xs"
|
|
: size === "sm"
|
|
? "pb-btn-size-sm"
|
|
: size === "lg"
|
|
? "pb-btn-size-lg"
|
|
: size === "xl"
|
|
? "pb-btn-size-xl"
|
|
: "pb-btn-size-md";
|
|
const radiusClassBtn =
|
|
radius === "none"
|
|
? "pb-btn-radius-none"
|
|
: radius === "sm"
|
|
? "pb-btn-radius-sm"
|
|
: radius === "lg"
|
|
? "pb-btn-radius-lg"
|
|
: radius === "full"
|
|
? "pb-btn-radius-full"
|
|
: "pb-btn-radius-md";
|
|
const variantClassBtn = `pb-btn-variant-${variant}-${colorPalette}`;
|
|
|
|
const buttonStyle: CSSProperties = {};
|
|
if (buttonProps.fontSizeCustom && buttonProps.fontSizeCustom.trim() !== "") {
|
|
buttonStyle.fontSize = buttonProps.fontSizeCustom;
|
|
}
|
|
if (buttonProps.lineHeightCustom && buttonProps.lineHeightCustom.trim() !== "") {
|
|
buttonStyle.lineHeight = buttonProps.lineHeightCustom;
|
|
}
|
|
if (buttonProps.letterSpacingCustom && buttonProps.letterSpacingCustom.trim() !== "") {
|
|
buttonStyle.letterSpacing = buttonProps.letterSpacingCustom;
|
|
}
|
|
if (buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== "") {
|
|
buttonStyle.backgroundColor = buttonProps.fillColorCustom;
|
|
}
|
|
if (buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== "") {
|
|
buttonStyle.borderColor = buttonProps.strokeColorCustom;
|
|
}
|
|
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
|
|
buttonStyle.color = buttonProps.textColorCustom;
|
|
}
|
|
|
|
return (
|
|
<div className={fullWidth ? "w-full" : "inline-block"}>
|
|
<button
|
|
type="button"
|
|
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
|
|
style={buttonStyle}
|
|
aria-label={buttonProps.label}
|
|
onClick={(e) => {
|
|
// 에디터 내 버튼 클릭은 선택만 유지하고 실제 이동은 막는다.
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<span className="whitespace-pre-wrap">{buttonProps.label}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "image" && (() => {
|
|
const imageProps = block.props as ImageBlockProps;
|
|
const hasSrc = imageProps.src.trim().length > 0;
|
|
return (
|
|
<div className="w-full border border-dashed border-slate-700 bg-slate-900/40 rounded flex items-center justify-center overflow-hidden">
|
|
{hasSrc ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={imageProps.src}
|
|
alt={imageProps.alt}
|
|
className="max-w-full h-auto object-contain"
|
|
/>
|
|
) : (
|
|
<span className="text-[10px] text-slate-500 px-2 py-4">
|
|
이미지 URL을 속성 패널에서 입력하세요.
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "divider" && (() => {
|
|
const dividerProps = block.props as DividerBlockProps;
|
|
const thicknessClass = dividerProps.thickness === "medium" ? "border-t-2" : "border-t";
|
|
|
|
return (
|
|
<div className="w-full py-2">
|
|
<div className={`border-slate-700 ${thicknessClass}`} />
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "list" && (() => {
|
|
const listProps = block.props as ListBlockProps;
|
|
const items = listProps.items && listProps.items.length > 0 ? listProps.items : ["리스트 아이템 1"];
|
|
const ListTag = (listProps.ordered ? "ol" : "ul") as "ol" | "ul";
|
|
|
|
return (
|
|
<div className="w-full py-1">
|
|
<ListTag className="list-inside list-disc space-y-1 text-xs">
|
|
{items.map((item, index) => (
|
|
<li key={index}>{item}</li>
|
|
))}
|
|
</ListTag>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "section" && (() => {
|
|
const sectionProps = block.props as SectionBlockProps;
|
|
|
|
const bgClass =
|
|
sectionProps.background === "muted"
|
|
? "bg-slate-950/40"
|
|
: sectionProps.background === "primary"
|
|
? "bg-sky-950/40 border-sky-900/60"
|
|
: "bg-slate-900/60";
|
|
|
|
const pyClass =
|
|
sectionProps.paddingY === "sm"
|
|
? "py-4"
|
|
: sectionProps.paddingY === "lg"
|
|
? "py-10"
|
|
: "py-6";
|
|
|
|
const columns = sectionProps.columns && sectionProps.columns.length > 0
|
|
? sectionProps.columns
|
|
: [{ id: `${block.id}_col_fallback`, span: 12 }];
|
|
|
|
return (
|
|
<div className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700`}
|
|
>
|
|
<div className="flex gap-3">
|
|
{columns.map((col) => {
|
|
const basis = `${(col.span / 12) * 100}%`;
|
|
const columnBlocks = allBlocks.filter(
|
|
(b) => b.sectionId === block.id && b.columnId === col.id,
|
|
);
|
|
const droppableId = `column:${block.id}:${col.id}`;
|
|
return (
|
|
<ColumnDroppable
|
|
key={col.id}
|
|
id={droppableId}
|
|
sectionId={block.id}
|
|
columnId={col.id}
|
|
>
|
|
<div
|
|
className="border border-slate-800/80 bg-slate-950/40 rounded flex flex-col gap-2 items-stretch justify-start px-2 py-2"
|
|
style={{ flexBasis: basis }}
|
|
>
|
|
{columnBlocks.length === 0 ? (
|
|
<span className="text-[11px] text-slate-500 px-2 text-center">
|
|
컬럼 영역 (span {col.span}/12)
|
|
</span>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{renderBlocks(columnBlocks)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ColumnDroppable>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ColumnDroppableProps {
|
|
id: string;
|
|
sectionId: string;
|
|
columnId: string;
|
|
children: ReactNode;
|
|
}
|
|
|
|
function ColumnDroppable({ id, sectionId, columnId, children }: ColumnDroppableProps) {
|
|
const { setNodeRef } = useDroppable({ id });
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
data-section-id={sectionId}
|
|
data-column-id={columnId}
|
|
className="flex-1"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|