2399 lines
94 KiB
TypeScript
2399 lines
94 KiB
TypeScript
"use client";
|
|
|
|
import type { CSSProperties, ReactNode } from "react";
|
|
import { Fragment, Suspense, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
|
|
import {
|
|
DndContext,
|
|
PointerSensor,
|
|
closestCenter,
|
|
rectIntersection,
|
|
useSensor,
|
|
useSensors,
|
|
DragEndEvent,
|
|
DragStartEvent,
|
|
DragOverlay,
|
|
useDroppable,
|
|
useDndContext,
|
|
} 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,
|
|
VideoBlockProps,
|
|
SectionBlockProps,
|
|
DividerBlockProps,
|
|
ListBlockProps,
|
|
FormBlockProps,
|
|
FormFieldConfig,
|
|
FormInputBlockProps,
|
|
FormSelectBlockProps,
|
|
FormCheckboxBlockProps,
|
|
FormRadioBlockProps,
|
|
ListItemNode,
|
|
ProjectConfig,
|
|
} from "@/features/editor/state/editorStore";
|
|
import {
|
|
normalizeVideoSourceUrl,
|
|
resolveVideoPlatform,
|
|
buildVideoEmbedUrl,
|
|
computeVideoEditorTokens,
|
|
} from "@/features/editor/utils/videoHelpers";
|
|
import { computeTextEditorTokens } from "@/features/editor/utils/textHelpers";
|
|
import { computeButtonPbTokens, computeButtonEditorTokens } from "@/features/editor/utils/buttonHelpers";
|
|
import { computeDividerEditorTokens } from "@/features/editor/utils/dividerHelpers";
|
|
import { computeListEditorTokens } from "@/features/editor/utils/listHelpers";
|
|
import { computeImageEditorTokens } from "@/features/editor/utils/imageHelpers";
|
|
import { computeSectionEditorTokens } from "@/features/editor/utils/sectionHelpers";
|
|
import {
|
|
computeFormInputEditorTokens,
|
|
computeFormSelectEditorTokens,
|
|
computeFormOptionGroupEditorTokens,
|
|
} from "@/features/editor/utils/formHelpers";
|
|
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";
|
|
import { BlocksSidebar } from "./panels/BlocksSidebar";
|
|
import { PropertiesSidebar } from "./panels/PropertiesSidebar";
|
|
import { EditorCanvas } from "./EditorCanvas";
|
|
|
|
export default function EditorPage() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<EditorPageInner />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function EditorPageInner() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const blocks = useEditorStore((state) => state.blocks);
|
|
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
|
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
|
|
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 addFormInputBlock = useEditorStore((state) => (state as any).addFormInputBlock);
|
|
const addFormSelectBlock = useEditorStore((state) => (state as any).addFormSelectBlock);
|
|
const addFormCheckboxBlock = useEditorStore((state) => (state as any).addFormCheckboxBlock);
|
|
const addFormRadioBlock = useEditorStore((state) => (state as any).addFormRadioBlock);
|
|
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 selectListItem = useEditorStore((state) => (state as any).selectListItem as (id: string | null) => void);
|
|
const indentSelectedListItem = useEditorStore(
|
|
(state) => (state as any).indentSelectedListItem as (blockId: string) => void,
|
|
);
|
|
const outdentSelectedListItem = useEditorStore(
|
|
(state) => (state as any).outdentSelectedListItem as (blockId: string) => void,
|
|
);
|
|
const moveSelectedListItemUp = useEditorStore(
|
|
(state) => (state as any).moveSelectedListItemUp as (blockId: string) => void,
|
|
);
|
|
const moveSelectedListItemDown = useEditorStore(
|
|
(state) => (state as any).moveSelectedListItemDown as (blockId: string) => void,
|
|
);
|
|
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 projectConfig = useEditorStore(
|
|
(state) => (state as any).projectConfig as ProjectConfig,
|
|
);
|
|
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
|
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
|
|
|
|
const [authUserId, setAuthUserId] = useState<string | null>(null);
|
|
const [authChecked, setAuthChecked] = useState(false);
|
|
|
|
const [initialSlugFromQuery, setInitialSlugFromQuery] = useState<string | null>(null);
|
|
const [hasLoadedInitialProjectFromSlug, setHasLoadedInitialProjectFromSlug] = useState(false);
|
|
|
|
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
|
const [editingText, setEditingText] = useState("");
|
|
|
|
// 드래그 중인 블록 ID를 추적하여 DragOverlay에 일관된 고스트 카드를 렌더링한다.
|
|
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
|
|
|
const [exportJson, setExportJson] = useState("");
|
|
const [importJson, setImportJson] = useState("");
|
|
|
|
const [projectMessage, setProjectMessage] = useState("");
|
|
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
|
|
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
|
|
const [initializedNewProjectFromQuery, setInitializedNewProjectFromQuery] = useState(false);
|
|
|
|
const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0);
|
|
const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0);
|
|
const canUndo = historyLength > 0;
|
|
const canRedo = futureLength > 0;
|
|
|
|
useEffect(() => {
|
|
if (!searchParams) return;
|
|
|
|
const slugParam = searchParams.get("slug");
|
|
if (slugParam && !initialSlugFromQuery) {
|
|
setInitialSlugFromQuery(slugParam);
|
|
}
|
|
}, [searchParams, initialSlugFromQuery]);
|
|
|
|
useEffect(() => {
|
|
if (!initialSlugFromQuery) return;
|
|
const trimmed = initialSlugFromQuery.trim();
|
|
if (!trimmed) return;
|
|
|
|
const currentSlug = (projectConfig?.slug ?? "").trim();
|
|
if (currentSlug !== trimmed) {
|
|
updateProjectConfig({ slug: trimmed });
|
|
}
|
|
}, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]);
|
|
|
|
useEffect(() => {
|
|
if (!searchParams) return;
|
|
|
|
if (initializedNewProjectFromQuery) return;
|
|
|
|
const newParam = searchParams.get("new");
|
|
if (newParam !== "1") return;
|
|
|
|
if (blocks.length > 0) {
|
|
replaceBlocks([]);
|
|
}
|
|
|
|
updateProjectConfig({
|
|
title: "새 페이지",
|
|
slug: "my-landing",
|
|
canvasPreset: "full",
|
|
canvasBgColorHex: "#020617",
|
|
bodyBgColorHex: "#020617",
|
|
headHtml: "",
|
|
trackingScript: "",
|
|
seoTitle: "",
|
|
seoDescription: "",
|
|
seoOgImageUrl: "",
|
|
seoCanonicalUrl: "",
|
|
seoNoIndex: false,
|
|
} as ProjectConfig);
|
|
|
|
resetHistory();
|
|
setInitializedNewProjectFromQuery(true);
|
|
}, [searchParams, initializedNewProjectFromQuery, blocks.length, replaceBlocks, updateProjectConfig, resetHistory]);
|
|
|
|
useEffect(() => {
|
|
if (!searchParams) return;
|
|
|
|
const slugParam = searchParams.get("slug");
|
|
if (slugParam && slugParam.trim().length > 0) {
|
|
// URL 에 slug 쿼리 파라미터가 있으면 기존 프로젝트 편집 중이므로,
|
|
// 자동 슬러그 생성은 동작시키지 않는다.
|
|
return;
|
|
}
|
|
|
|
const currentSlugRaw = projectConfig?.slug;
|
|
const currentSlug = typeof currentSlugRaw === "string" ? currentSlugRaw.trim() : "";
|
|
|
|
// 초기 상태("my-landing") 또는 비어 있는 경우에만 자동 슬러그를 한 번 생성한다.
|
|
if (currentSlug && currentSlug !== "my-landing") {
|
|
return;
|
|
}
|
|
|
|
const randomSlug = Array.from({ length: 12 }, () =>
|
|
Math.floor(Math.random() * 36).toString(36),
|
|
).join("");
|
|
|
|
updateProjectConfig({ slug: randomSlug });
|
|
}, [searchParams, projectConfig?.slug, updateProjectConfig]);
|
|
|
|
useEffect(() => {
|
|
if (!authChecked) return;
|
|
if (!initialSlugFromQuery) return;
|
|
if (hasLoadedInitialProjectFromSlug) return;
|
|
|
|
const slug = initialSlugFromQuery.trim();
|
|
if (!slug) return;
|
|
|
|
// 이미 해당 slug 에 대한 autosave 스냅샷이 있으면,
|
|
// 서버에서 프로젝트를 다시 불러와서 현재 로컬 상태를 덮어쓰지 않는다.
|
|
if (typeof window !== "undefined") {
|
|
try {
|
|
const key = `pb:autosave:${slug}`;
|
|
const raw = window.localStorage.getItem(key);
|
|
if (raw) {
|
|
setHasLoadedInitialProjectFromSlug(true);
|
|
return;
|
|
}
|
|
} catch {
|
|
// localStorage 접근 실패 시에는 서버 로드를 계속 시도한다.
|
|
}
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const loadFromServer = async () => {
|
|
try {
|
|
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`);
|
|
if (!res.ok || cancelled) {
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (data && Array.isArray(data.contentJson)) {
|
|
replaceBlocks(data.contentJson as any);
|
|
if (data.title && typeof data.title === "string") {
|
|
updateProjectConfig({ title: data.title, slug });
|
|
} else {
|
|
updateProjectConfig({ slug });
|
|
}
|
|
// 다른 slug/사용자에서 불러온 뒤에는 이전 프로젝트의 undo/redo 히스토리를 끊어준다.
|
|
resetHistory();
|
|
}
|
|
} catch {
|
|
// 서버 로드 실패는 조용히 무시하고, 사용자가 수동으로 불러오기 할 수 있도록 둔다.
|
|
} finally {
|
|
if (!cancelled) {
|
|
setHasLoadedInitialProjectFromSlug(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
void loadFromServer();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks, updateProjectConfig, resetHistory]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const checkAuth = async () => {
|
|
try {
|
|
const res = await fetch("/api/auth/me");
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
if (res.ok) {
|
|
let data: any = null;
|
|
try {
|
|
data = await res.json();
|
|
} catch {
|
|
data = null;
|
|
}
|
|
|
|
if (data && typeof data.id === "string") {
|
|
setAuthUserId(data.id);
|
|
} else {
|
|
setAuthUserId(null);
|
|
}
|
|
} else if (res.status === 401) {
|
|
router.push("/login");
|
|
setAuthUserId(null);
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setAuthUserId(null);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setAuthChecked(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
void checkAuth();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [router]);
|
|
|
|
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 handleExportZip = async () => {
|
|
try {
|
|
const payload = {
|
|
blocks,
|
|
projectConfig,
|
|
};
|
|
|
|
const response = await fetch("/api/export", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => "");
|
|
const truncated = text.length > 500 ? `${text.slice(0, 500)}...` : text;
|
|
console.error(
|
|
"정적 파일 내보내기 실패",
|
|
response.status,
|
|
response.statusText,
|
|
truncated,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const slug = (projectConfig.slug ?? "").trim() || "page-builder-export";
|
|
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${slug}.zip`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error("정적 파일 내보내기 중 오류", error);
|
|
}
|
|
};
|
|
|
|
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 snapshot = {
|
|
blocks,
|
|
projectConfig,
|
|
};
|
|
const payload = JSON.stringify(snapshot, 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) as {
|
|
blocks?: Block[];
|
|
projectConfig?: ProjectConfig;
|
|
};
|
|
|
|
if (!parsed || typeof parsed !== "object") return;
|
|
|
|
if (!Array.isArray(parsed.blocks)) return;
|
|
|
|
replaceBlocks(parsed.blocks as any);
|
|
|
|
if (parsed.projectConfig && typeof parsed.projectConfig === "object") {
|
|
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
|
}
|
|
|
|
setExportJson(JSON.stringify(parsed, null, 2));
|
|
} catch {
|
|
// 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능)
|
|
}
|
|
};
|
|
|
|
const handleSaveProject = async () => {
|
|
const slug = (projectConfig.slug ?? "").trim();
|
|
if (!slug) {
|
|
setProjectMessage("프로젝트 주소를 입력해 주세요.");
|
|
return;
|
|
}
|
|
|
|
const title = (projectConfig.title ?? "").trim() || "제목 없음";
|
|
|
|
try {
|
|
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
|
|
if (typeof window !== "undefined") {
|
|
const localKey = `pb:project:${slug}`;
|
|
const payload = {
|
|
title,
|
|
contentJson: blocks,
|
|
};
|
|
window.localStorage.setItem(localKey, JSON.stringify(payload));
|
|
|
|
// 1-1) autosave 스냅샷도 현재 저장된 상태로 동기화해,
|
|
// 이후 /editor?slug 또는 /preview?slug 진입 시 서버 저장본과 동일한 블록들이 로드되도록 맞춘다.
|
|
try {
|
|
const autosaveKey = `pb:autosave:${slug}`;
|
|
const autosavePayload = {
|
|
blocks,
|
|
projectConfig,
|
|
savedByUserId: authUserId ?? null,
|
|
};
|
|
window.localStorage.setItem(autosaveKey, JSON.stringify(autosavePayload));
|
|
} catch {
|
|
// autosave 저장 실패는 치명적이지 않으므로 무시한다.
|
|
}
|
|
}
|
|
|
|
// 2) 서버에도 저장 (백업/공유용)
|
|
const response = await fetch("/api/projects", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
title,
|
|
slug,
|
|
contentJson: blocks,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 409) {
|
|
try {
|
|
const data = await response.json();
|
|
if (data && typeof (data as any).message === "string") {
|
|
setProjectMessage((data as any).message as string);
|
|
return;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
setProjectMessage("프로젝트 저장에 실패했습니다.");
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.location.href = "/projects";
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleLoadProject = async () => {
|
|
const slug = (projectConfig.slug ?? "").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);
|
|
if (parsed.title && typeof parsed.title === "string") {
|
|
updateProjectConfig({ title: parsed.title, slug });
|
|
} else {
|
|
updateProjectConfig({ slug });
|
|
}
|
|
|
|
// 다른 프로젝트(slug)를 불러왔으므로 이전 history/future 를 초기화한다.
|
|
resetHistory();
|
|
|
|
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);
|
|
if (data.title && typeof data.title === "string") {
|
|
updateProjectConfig({ title: data.title, slug });
|
|
} else {
|
|
updateProjectConfig({ slug });
|
|
}
|
|
|
|
// 서버에서 다른 프로젝트를 불러온 뒤에는 이전 history/future 를 모두 비운다.
|
|
resetHistory();
|
|
|
|
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
|
|
} else {
|
|
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
setProjectMessage("프로젝트 불러오기 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleDeleteProject = async () => {
|
|
const slug = (projectConfig.slug ?? "").trim();
|
|
if (!slug) {
|
|
setProjectMessage("삭제할 프로젝트 주소가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
const confirmed = window.confirm(
|
|
"현재 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
setProjectMessage("프로젝트 삭제에 실패했습니다.");
|
|
return;
|
|
}
|
|
|
|
// 프로젝트 저장 및 자동저장 스냅샷을 정리한다.
|
|
window.localStorage.removeItem(`pb:project:${slug}`);
|
|
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
|
|
|
setProjectMessage("프로젝트가 삭제되었습니다.");
|
|
|
|
window.location.href = "/projects";
|
|
} catch (error) {
|
|
console.error(error);
|
|
setProjectMessage("프로젝트 삭제 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
const { active } = event;
|
|
setActiveDragId(String(active.id));
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
// 드래그가 끝나면 고스트 카드를 숨긴다.
|
|
setActiveDragId(null);
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
const activeId = String(active.id);
|
|
const overId = String(over.id);
|
|
|
|
// 드롭존(블록 사이/최하단 가이드 영역)에 드롭한 경우: 컨테이너 기준으로 위치를 조정한다.
|
|
if (overId.startsWith("dropzone-before:")) {
|
|
const [, targetId] = overId.split(":");
|
|
if (targetId) {
|
|
const activeBlock = blocks.find((b) => b.id === activeId);
|
|
const targetBlock = blocks.find((b) => b.id === targetId);
|
|
|
|
if (!activeBlock || !targetBlock) {
|
|
reorderBlocks(activeId, targetId);
|
|
return;
|
|
}
|
|
|
|
const activeSectionId = activeBlock.sectionId ?? null;
|
|
const activeColumnId = activeBlock.columnId ?? null;
|
|
const targetSectionId = targetBlock.sectionId ?? null;
|
|
const targetColumnId = targetBlock.columnId ?? null;
|
|
|
|
// 섹션 안 블록을 루트 드롭존으로 끌어낸 경우: 루트로 이동 후 순서 재배치
|
|
if (activeSectionId && !targetSectionId && !targetColumnId && targetBlock.type !== "section") {
|
|
moveBlock(activeId, null, null);
|
|
reorderBlocks(activeId, targetId);
|
|
return;
|
|
}
|
|
|
|
// 같은 컨테이너(루트 또는 동일 섹션/컬럼) 내에서는 순서만 변경
|
|
if (
|
|
(!activeSectionId && !activeColumnId && !targetSectionId && !targetColumnId) ||
|
|
(activeSectionId === targetSectionId && activeColumnId === targetColumnId)
|
|
) {
|
|
reorderBlocks(activeId, targetId);
|
|
return;
|
|
}
|
|
|
|
// 다른 섹션/컬럼으로의 이동: 컨테이너를 변경한 뒤 순서 재배치
|
|
moveBlock(activeId, targetSectionId, targetColumnId);
|
|
reorderBlocks(activeId, targetId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 최하단 드롭존에 드롭한 경우: 컨테이너의 맨 아래로 이동한다.
|
|
if (overId === "dropzone-after-root" || overId.startsWith("dropzone-after:")) {
|
|
const activeBlock = blocks.find((b) => b.id === activeId);
|
|
if (!activeBlock) return;
|
|
|
|
// 루트 최하단 드롭존
|
|
if (overId === "dropzone-after-root") {
|
|
const rootBlocksForDrop = blocks.filter((b) => !b.sectionId);
|
|
const lastRoot = rootBlocksForDrop[rootBlocksForDrop.length - 1];
|
|
|
|
if (!lastRoot) {
|
|
// 아직 루트 블록이 없다면, 섹션 안 블록을 루트로만 이동시킨다.
|
|
if (activeBlock.sectionId || activeBlock.columnId) {
|
|
moveBlock(activeId, null, null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 섹션 안에서 루트 최하단으로 끌어낸 경우: 먼저 루트로 이동 후 마지막 루트 블록 뒤로 재배치
|
|
if (activeBlock.sectionId || activeBlock.columnId) {
|
|
moveBlock(activeId, null, null);
|
|
}
|
|
|
|
if (lastRoot.id !== activeId) {
|
|
reorderBlocks(activeId, lastRoot.id);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// 섹션 컬럼 최하단 드롭존: id 형식은 dropzone-after:sectionId:columnId
|
|
const [, sectionId, columnId] = overId.split(":");
|
|
const columnBlocks = blocks.filter(
|
|
(b) => b.sectionId === sectionId && b.columnId === columnId,
|
|
);
|
|
const lastInColumn = columnBlocks[columnBlocks.length - 1];
|
|
|
|
// 다른 컨테이너에서 이 컬럼으로 이동하는 경우: 먼저 컨테이너 이동
|
|
if (activeBlock.sectionId !== sectionId || activeBlock.columnId !== columnId) {
|
|
moveBlock(activeId, sectionId || null, columnId || null);
|
|
}
|
|
|
|
if (lastInColumn && lastInColumn.id !== activeId) {
|
|
reorderBlocks(activeId, lastInColumn.id);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// 1) 컬럼 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);
|
|
|
|
// 2) 드롭 대상 블록을 찾지 못한 경우: 단순 순서 변경만 시도한다.
|
|
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;
|
|
|
|
// B-1) 루트 블록을 섹션(박스) 안으로 집어넣기:
|
|
// 섹션 블록 위로 드랍했고, 드래그 중인 블록은 루트(섹션/컬럼이 없음)인 경우
|
|
if (overBlock.type === "section" && !activeSectionId && !activeColumnId) {
|
|
const sectionProps = overBlock.props as SectionBlockProps;
|
|
const targetColumnId = sectionProps.columns?.[0]?.id ?? null;
|
|
moveBlock(activeId, overBlock.id, targetColumnId);
|
|
// 섹션 블록 주변에서 순서가 자연스럽게 보이도록 배열 순서도 함께 재배치
|
|
reorderBlocks(activeId, overId);
|
|
return;
|
|
}
|
|
|
|
// B-2) 섹션(박스) 안 블록을 루트로 꺼내기:
|
|
// 드래그 중인 블록은 섹션 안에 있고, 드랍 대상은 루트 블록(섹션/컬럼이 없음)인 경우
|
|
if (activeSectionId && !overSectionId && !overColumnId && overBlock.type !== "section") {
|
|
moveBlock(activeId, null, null);
|
|
reorderBlocks(activeId, overId);
|
|
return;
|
|
}
|
|
|
|
// 3) 루트 블록들끼리 드래그하는 경우: 순서만 변경한다.
|
|
if (!activeSectionId && !activeColumnId && !overSectionId && !overColumnId) {
|
|
reorderBlocks(activeId, overId);
|
|
return;
|
|
}
|
|
|
|
// 4) 같은 섹션/컬럼 내에서는 순서만 변경한다.
|
|
if (activeSectionId === overSectionId && activeColumnId === overColumnId) {
|
|
reorderBlocks(activeId, overId);
|
|
return;
|
|
}
|
|
|
|
// 5) 다른 컬럼/섹션으로 이동: 위치를 변경한 뒤, 블록 기준 드롭이면 순서 재배치까지 진행한다.
|
|
moveBlock(activeId, overSectionId, overColumnId);
|
|
reorderBlocks(activeId, overId);
|
|
};
|
|
|
|
const handleDragCancel = () => {
|
|
// 드래그가 취소된 경우에도 고스트 카드를 정리한다.
|
|
setActiveDragId(null);
|
|
};
|
|
|
|
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
|
|
|
const projectSlugRaw = projectConfig?.slug?.trim?.();
|
|
const projectSlug =
|
|
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
|
|
const previewHref = projectSlug
|
|
? `/preview?slug=${encodeURIComponent(projectSlug)}`
|
|
: "/preview";
|
|
|
|
useEffect(() => {
|
|
if (!authChecked) return;
|
|
if (typeof window === "undefined") return;
|
|
|
|
const slug = projectConfig?.slug?.trim();
|
|
if (!slug) return;
|
|
|
|
const key = `pb:autosave:${slug}`;
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as {
|
|
blocks?: Block[];
|
|
projectConfig?: ProjectConfig;
|
|
savedByUserId?: string | null;
|
|
};
|
|
|
|
const savedByUserId = parsed.savedByUserId ?? null;
|
|
|
|
if (authUserId) {
|
|
if (savedByUserId && savedByUserId !== authUserId) {
|
|
return;
|
|
}
|
|
} else if (savedByUserId) {
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(parsed.blocks)) {
|
|
replaceBlocks(parsed.blocks as any);
|
|
}
|
|
if (parsed.projectConfig) {
|
|
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
|
}
|
|
|
|
// autosave 로 전체 프로젝트를 복원한 경우에도 이전 history/future 는 끊어준다.
|
|
resetHistory();
|
|
} catch {
|
|
return;
|
|
}
|
|
}, [authChecked, authUserId, projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const slug = projectConfig?.slug?.trim();
|
|
if (!slug) return;
|
|
|
|
const key = `pb:autosave:${slug}`;
|
|
|
|
try {
|
|
const existing = window.localStorage.getItem(key);
|
|
if (existing && !authChecked) {
|
|
// 초기 렌더 시 이미 autosave 스냅샷이 있는 경우,
|
|
// 인증 체크 및 복원(useEffect)이 끝나기 전까지는 기존 스냅샷을 덮어쓰지 않는다.
|
|
return;
|
|
}
|
|
} catch {
|
|
// 기존 값 조회 에러는 무시하고, 아래 저장 시도는 계속 진행한다.
|
|
}
|
|
|
|
const payload = {
|
|
blocks,
|
|
projectConfig,
|
|
savedByUserId: authUserId ?? null,
|
|
};
|
|
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(payload));
|
|
} catch {
|
|
return;
|
|
}
|
|
}, [blocks, projectConfig, authUserId, authChecked]);
|
|
|
|
useEffect(() => {
|
|
if (!authChecked) return;
|
|
if (!authUserId) return;
|
|
if (typeof window === "undefined") return;
|
|
|
|
const slugRaw = projectConfig?.slug;
|
|
const slug = typeof slugRaw === "string" ? slugRaw.trim() : "";
|
|
if (!slug || slug === "my-landing") return;
|
|
|
|
const titleRaw = projectConfig?.title;
|
|
const title = (typeof titleRaw === "string" ? titleRaw.trim() : "") || "제목 없음";
|
|
|
|
const intervalEnv = process.env.NEXT_PUBLIC_EDITOR_SERVER_AUTOSAVE_MS;
|
|
const intervalMs = intervalEnv ? Number(intervalEnv) || 20000 : 20000;
|
|
let cancelled = false;
|
|
|
|
const saveOnce = async () => {
|
|
if (cancelled) return;
|
|
|
|
try {
|
|
await fetch("/api/projects", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
title,
|
|
slug,
|
|
contentJson: blocks,
|
|
}),
|
|
});
|
|
} catch {
|
|
// 서버 자동 저장 실패는 치명적이지 않으므로 조용히 무시한다.
|
|
}
|
|
};
|
|
|
|
const id = window.setInterval(() => {
|
|
void saveOnce();
|
|
}, intervalMs);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(id);
|
|
};
|
|
}, [authChecked, authUserId, projectConfig?.slug, projectConfig?.title, blocks]);
|
|
|
|
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
|
|
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
|
|
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (target) {
|
|
const tagName = target.tagName;
|
|
const isInputLike =
|
|
tagName === "INPUT" ||
|
|
tagName === "TEXTAREA" ||
|
|
(target as HTMLElement).isContentEditable;
|
|
if (isInputLike) {
|
|
// 입력 포커스 상태에서는 에디터 전역 단축키를 비활성화하고,
|
|
// 기본 브라우저 편집 동작(삭제/커서 이동 등)을 그대로 둔다.
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동)
|
|
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") {
|
|
// 선택이 없거나(자동 선택 상태를 무시) 0보다 큰 인덱스에서 시작하면 첫 번째 블록으로 이동한다.
|
|
if (currentIndex === -1 || currentIndex > 0) {
|
|
nextIndex = 0;
|
|
} else {
|
|
// 이미 0번을 선택 중이면 1번으로 한 칸 내려간다.
|
|
nextIndex = currentBlocks.length > 1 ? 1 : 0;
|
|
}
|
|
} else if (event.key === "ArrowUp") {
|
|
// 선택이 없을 때는 마지막 블록을 선택한다.
|
|
if (currentIndex === -1) {
|
|
nextIndex = currentBlocks.length - 1;
|
|
} else {
|
|
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, blocks: currentBlocks } = (useEditorStore as any).getState();
|
|
|
|
// 멀티 선택이 존재하면 멀티 삭제를 우선 처리한다.
|
|
if (selectedBlockIds.length > 1) {
|
|
event.preventDefault();
|
|
const selectedSet = new Set(selectedBlockIds);
|
|
const nextBlocks = (currentBlocks as Block[]).filter((b) => !selectedSet.has(b.id));
|
|
replaceBlocks(nextBlocks);
|
|
return;
|
|
}
|
|
|
|
// 단일 선택만 있는 경우 기존 removeBlock 로직 사용
|
|
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, selectedBlockIds, replaceBlocks, removeBlock]);
|
|
|
|
const renderBlocks = (targetBlocks: Block[]) =>
|
|
targetBlocks.map((block) => {
|
|
const isSelected =
|
|
(selectedBlockId != null && block.id === selectedBlockId) || selectedBlockIds.includes(block.id);
|
|
const isEditing = block.id === editingBlockId;
|
|
|
|
return (
|
|
<Fragment key={block.id}>
|
|
<BlockDropzone id={`dropzone-before:${block.id}`} />
|
|
<SortableEditorBlock
|
|
block={block}
|
|
isSelected={isSelected}
|
|
selectedBlockId={selectedBlockId ?? null}
|
|
selectedBlockIds={selectedBlockIds}
|
|
setSelectedBlockIds={setSelectedBlockIds}
|
|
isEditing={isEditing}
|
|
editingText={editingText}
|
|
startEditing={startEditing}
|
|
commitEditing={commitEditing}
|
|
cancelEditing={cancelEditing}
|
|
setEditingText={setEditingText}
|
|
selectBlock={selectBlock}
|
|
selectedListItemId={selectedListItemId ?? null}
|
|
selectListItem={selectListItem}
|
|
allBlocks={blocks}
|
|
renderBlocks={renderBlocks}
|
|
updateBlock={updateBlock}
|
|
/>
|
|
</Fragment>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<main className="h-screen flex flex-col overflow-hidden bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
|
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between relative z-20 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
|
|
<div className="flex items-center gap-3">
|
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-900 border border-sky-700 shadow-sm">
|
|
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
|
</span>
|
|
<div className="flex flex-col">
|
|
<h1 className="text-xl font-semibold">Page Editor</h1>
|
|
<p className="text-xs text-slate-400">빌더 MVP용 에디터 페이지 골격</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Link
|
|
href="/projects"
|
|
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"
|
|
>
|
|
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
|
<span>프로젝트 목록</span>
|
|
</Link>
|
|
<Link
|
|
href={previewHref}
|
|
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"
|
|
>
|
|
<Eye className="w-3 h-3" aria-hidden="true" />
|
|
<span>프리뷰 열기</span>
|
|
</Link>
|
|
<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 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
onClick={undo}
|
|
disabled={!canUndo}
|
|
>
|
|
<Undo2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>실행 취소</span>
|
|
</button>
|
|
<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 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
onClick={redo}
|
|
disabled={!canRedo}
|
|
>
|
|
<Redo2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>다시 실행</span>
|
|
</button>
|
|
<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)}
|
|
>
|
|
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>메뉴</span>
|
|
<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");
|
|
}}
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>프로젝트 저장/불러오기</span>
|
|
</span>
|
|
</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");
|
|
}}
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>JSON 내보내기/불러오기</span>
|
|
</span>
|
|
</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);
|
|
void handleExportZip();
|
|
}}
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>페이지 파일로 내보내기 (ZIP)</span>
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
if (window.confirm("캔버스를 모두 초기화할까요? 이 작업은 실행 취소로만 되돌릴 수 있습니다.")) {
|
|
handleClearCanvas();
|
|
}
|
|
}}
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>캔버스 초기화</span>
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
void handleDeleteProject();
|
|
}}
|
|
>
|
|
<span className="inline-flex items-center gap-2 text-red-500">
|
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
|
<span>프로젝트 삭제</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<section className="flex flex-1 overflow-hidden">
|
|
<BlocksSidebar />
|
|
<EditorCanvas
|
|
blocks={blocks}
|
|
rootBlocks={rootBlocks}
|
|
selectedBlockId={selectedBlockId}
|
|
editingBlockId={editingBlockId}
|
|
editingText={editingText}
|
|
activeDragId={activeDragId}
|
|
sensors={sensors}
|
|
onCanvasEmptyClick={() => selectBlock(null)}
|
|
renderBlocks={renderBlocks}
|
|
handleDragStart={handleDragStart}
|
|
handleDragEnd={handleDragEnd}
|
|
handleDragCancel={handleDragCancel}
|
|
projectConfig={projectConfig}
|
|
/>
|
|
<PropertiesSidebar
|
|
blocks={blocks}
|
|
selectedBlockId={selectedBlockId}
|
|
selectedListItemId={selectedListItemId ?? null}
|
|
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
removeBlock={removeBlock}
|
|
duplicateBlock={duplicateBlock}
|
|
editingBlockId={editingBlockId}
|
|
setEditingText={setEditingText}
|
|
onMoveSelectedItemUp={(blockId) => moveSelectedListItemUp(blockId)}
|
|
onMoveSelectedItemDown={(blockId) => moveSelectedListItemDown(blockId)}
|
|
onIndentSelectedItem={(blockId) => indentSelectedListItem(blockId)}
|
|
onOutdentSelectedItem={(blockId) => outdentSelectedListItem(blockId)}
|
|
/>
|
|
</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-200 bg-white p-4 text-xs text-slate-900 shadow-xl dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
|
<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-700 text-sm dark:hover:text-slate-100"
|
|
onClick={() => setActiveModal(null)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-600 dark:text-slate-400">프로젝트 제목</span>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={projectConfig.title ?? ""}
|
|
onChange={(e) => updateProjectConfig({ title: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-600 dark:text-slate-400">프로젝트 주소 (예: my-landing)</span>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={projectConfig.slug ?? ""}
|
|
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
|
|
/>
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
|
|
onClick={handleSaveProject}
|
|
>
|
|
저장 (로컬 + 서버)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
|
onClick={handleLoadProject}
|
|
>
|
|
불러오기
|
|
</button>
|
|
</div>
|
|
{projectMessage && (
|
|
<p className="text-[11px] text-slate-600 mt-1 dark:text-slate-300">{projectMessage}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Export 미리보기 기능은 제거되었습니다. */}
|
|
|
|
{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-200 bg-white p-4 text-xs text-slate-900 shadow-xl dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium">
|
|
JSON Export / Import
|
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
|
* 에디터 내용을 가져오거나 내보낼 수 있습니다.
|
|
</span>
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
className="text-slate-400 hover:text-slate-700 text-sm dark:hover:text-slate-100"
|
|
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-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
|
onClick={handleExportJson}
|
|
>
|
|
JSON 내보내기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-red-300 bg-white px-2 py-1 text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-red-950 dark:text-red-100 dark: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-600 dark:text-slate-400">에디터 상태 JSON</span>
|
|
<textarea
|
|
className="w-full h-48 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={exportJson}
|
|
readOnly
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-600 dark:text-slate-400">JSON에서 불러오기</span>
|
|
<textarea
|
|
className="w-full h-48 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
value={importJson}
|
|
onChange={(e) => setImportJson(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="mt-1 w-full rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
|
onClick={handleApplyImportJson}
|
|
>
|
|
JSON 적용하기
|
|
</button>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
interface DragPreviewProps {
|
|
block: Block | null;
|
|
}
|
|
|
|
function DragPreview({ block }: DragPreviewProps) {
|
|
if (!block) return null;
|
|
|
|
// 블록 타입별로 간단한 고스트 카드 UI를 렌더링한다.
|
|
return (
|
|
<div className="pointer-events-none rounded border border-sky-500 bg-slate-900/80 px-3 py-2 text-xs text-slate-100 shadow-lg opacity-90 min-w-[160px] max-w-[260px]">
|
|
<div className="text-[10px] uppercase tracking-wide text-sky-300 mb-1">
|
|
{block.type === "text"
|
|
? "Text"
|
|
: block.type === "button"
|
|
? "Button"
|
|
: block.type === "image"
|
|
? "Image"
|
|
: block.type === "divider"
|
|
? "Divider"
|
|
: "Section"}
|
|
</div>
|
|
<div className="truncate">
|
|
{block.type === "text"
|
|
? (block.props as TextBlockProps).text || "텍스트 블록"
|
|
: block.type === "button"
|
|
? (block.props as ButtonBlockProps).label || "버튼 블록"
|
|
: block.type === "image"
|
|
? (block.props as ImageBlockProps).alt || "이미지 블록"
|
|
: block.type === "list"
|
|
? "리스트 블록"
|
|
: block.type === "divider"
|
|
? "구분선 블록"
|
|
: "섹션 블록"}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TextInlineToolbarProps {
|
|
align: TextBlockProps["align"];
|
|
size: TextBlockProps["size"];
|
|
onChangeAlign: (value: TextBlockProps["align"]) => void;
|
|
onChangeSize: (value: TextBlockProps["size"]) => void;
|
|
}
|
|
|
|
interface SortableEditorBlockProps {
|
|
block: Block;
|
|
isSelected: boolean;
|
|
selectedBlockId: string | null;
|
|
selectedBlockIds: string[];
|
|
setSelectedBlockIds: React.Dispatch<React.SetStateAction<string[]>>;
|
|
isEditing: boolean;
|
|
editingText: string;
|
|
startEditing: (id: string, initialText: string) => void;
|
|
commitEditing: () => void;
|
|
cancelEditing: () => void;
|
|
setEditingText: (value: string) => void;
|
|
selectBlock: (id: string) => void;
|
|
selectedListItemId: string | null;
|
|
selectListItem: (id: string | null) => void;
|
|
allBlocks: Block[];
|
|
renderBlocks: (targetBlocks: Block[]) => ReactNode;
|
|
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps & FormBlockProps>) => void;
|
|
}
|
|
|
|
function SortableEditorBlock({
|
|
block,
|
|
isSelected,
|
|
selectedBlockId,
|
|
selectedBlockIds,
|
|
setSelectedBlockIds,
|
|
isEditing,
|
|
editingText,
|
|
startEditing,
|
|
commitEditing,
|
|
cancelEditing,
|
|
setEditingText,
|
|
selectBlock,
|
|
selectedListItemId,
|
|
selectListItem,
|
|
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;
|
|
const textTokens = computeTextEditorTokens(textProps);
|
|
|
|
alignClass = textTokens.alignClass;
|
|
sizeClass = textTokens.sizeClass;
|
|
leadingClass = textTokens.leadingClass;
|
|
weightClass = textTokens.weightClass;
|
|
colorClass = textTokens.colorClass;
|
|
maxWidthClass = textTokens.maxWidthClass;
|
|
decorationClass = textTokens.decorationClass;
|
|
Object.assign(textStyleOverrides, textTokens.styleOverrides);
|
|
} 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 === "video") {
|
|
const videoProps = block.props as VideoBlockProps;
|
|
const align = videoProps.align ?? "center";
|
|
alignClass =
|
|
align === "center" ? "pb-text-center" : 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-block-id={block.id}
|
|
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-sky-50 text-slate-900 dark:bg-slate-900/80 dark:text-slate-100"
|
|
: "border-slate-200 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
}`}
|
|
onClick={(event) => {
|
|
// 멀티 선택(MVP): Cmd/Ctrl+클릭 시 선택 토글, 그 외에는 단일 선택으로 전환한다.
|
|
const isMeta = event.metaKey || event.ctrlKey;
|
|
if (isMeta) {
|
|
event.stopPropagation();
|
|
setSelectedBlockIds((prev) => {
|
|
if (prev.includes(block.id)) {
|
|
return prev.filter((id) => id !== block.id);
|
|
}
|
|
return [...prev, block.id];
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 일반 클릭: 현재 블록만 단일 선택으로 유지한다.
|
|
event.stopPropagation();
|
|
setSelectedBlockIds([block.id]);
|
|
selectBlock(block.id);
|
|
}}
|
|
onDoubleClick={(event) => {
|
|
if (block.type === "text") {
|
|
// 더블클릭 시 텍스트 블록 인라인 편집 모드로 진입한다.
|
|
event.stopPropagation();
|
|
startEditing(block.id, (block.props as TextBlockProps).text);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<button
|
|
type="button"
|
|
className="mt-0.5 h-4 w-4 rounded border border-slate-300 bg-slate-50 text-[10px] flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
|
|
aria-label="블록 드래그 핸들"
|
|
{...listeners}
|
|
{...attributes}
|
|
>
|
|
≡
|
|
</button>
|
|
<div className="flex-1">
|
|
{block.type === "text" && (
|
|
isEditing ? (
|
|
<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
|
|
className={`whitespace-pre-wrap ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass}`}
|
|
style={textStyleOverrides}
|
|
>
|
|
{(block.props as TextBlockProps).text}
|
|
</div>
|
|
)
|
|
)}
|
|
{block.type === "formInput" && (() => {
|
|
const inputProps = block.props as FormInputBlockProps;
|
|
const inputType = inputProps.inputType ?? "text";
|
|
const fieldName = inputProps.formFieldName ?? block.id;
|
|
|
|
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
|
|
const inputTokens = computeFormInputEditorTokens(inputProps);
|
|
const fieldStyle = inputTokens.fieldStyle;
|
|
|
|
const labelLayout = inputProps.labelLayout ?? "stacked";
|
|
const isInline = labelLayout === "inline";
|
|
|
|
const inputAlignClass = inputTokens.inputAlignClass;
|
|
const labelDisplay = (inputProps as any).labelDisplay ?? "visible";
|
|
|
|
// 프리뷰/퍼블릭과 동일한 pb-input/pb-textarea 클래스를 사용해 높이/패딩을 통일한다.
|
|
const baseInputClass = `pb-input w-full text-xs outline-none ${inputAlignClass}`;
|
|
const baseTextareaClass = `pb-textarea w-full text-xs outline-none ${inputAlignClass}`;
|
|
|
|
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
|
|
return (
|
|
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
|
|
{labelDisplay === "visible" && (
|
|
inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={inputProps.labelImageUrl}
|
|
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
|
|
className="h-4 w-auto"
|
|
/>
|
|
) : (
|
|
<span className="shrink-0">{inputProps.label}</span>
|
|
)
|
|
)}
|
|
{(() => {
|
|
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
|
|
const widthClass = inputTokens.widthClass;
|
|
|
|
if (labelDisplay === "floating") {
|
|
const placeholder = " ";
|
|
|
|
return (
|
|
<div className={`${widthClass} relative`}>
|
|
<span
|
|
data-testid="editor-form-input-floating-label"
|
|
className="absolute left-2 pointer-events-none"
|
|
style={{
|
|
top: "-0.3rem",
|
|
fontSize: "0.725rem",
|
|
color: "#e5e7eb",
|
|
backgroundColor: "#020617",
|
|
padding: "0 0.25rem",
|
|
}}
|
|
>
|
|
{inputProps.label}
|
|
</span>
|
|
{inputType === "textarea" ? (
|
|
<textarea
|
|
data-testid="form-input-field"
|
|
className={`${baseTextareaClass} pt-6`}
|
|
style={fieldStyle}
|
|
name={fieldName}
|
|
placeholder={placeholder}
|
|
aria-label={inputProps.label}
|
|
readOnly
|
|
/>
|
|
) : (
|
|
<input
|
|
data-testid="form-input-field"
|
|
className={`${baseInputClass} pt-6`}
|
|
style={fieldStyle}
|
|
type={inputType === "email" ? "email" : "text"}
|
|
name={fieldName}
|
|
placeholder={placeholder}
|
|
aria-label={inputProps.label}
|
|
readOnly
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={widthClass}>
|
|
{inputType === "textarea" ? (
|
|
<textarea
|
|
data-testid="form-input-field"
|
|
className={baseTextareaClass}
|
|
style={fieldStyle}
|
|
name={fieldName}
|
|
placeholder={inputProps.placeholder || inputProps.label}
|
|
aria-label={inputProps.label}
|
|
readOnly
|
|
/>
|
|
) : (
|
|
<input
|
|
data-testid="form-input-field"
|
|
className={baseInputClass}
|
|
style={fieldStyle}
|
|
type={inputType === "email" ? "email" : "text"}
|
|
name={fieldName}
|
|
placeholder={inputProps.placeholder || inputProps.label}
|
|
aria-label={inputProps.label}
|
|
readOnly
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "formSelect" && (() => {
|
|
const selectProps = block.props as FormSelectBlockProps;
|
|
const fieldName = selectProps.formFieldName ?? block.id;
|
|
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
|
|
? selectProps.options
|
|
: [
|
|
{ label: "옵션 1", value: "option_1" },
|
|
{ label: "옵션 2", value: "option_2" },
|
|
];
|
|
|
|
const selectTokens = computeFormSelectEditorTokens(selectProps);
|
|
const fieldStyle = selectTokens.fieldStyle;
|
|
|
|
// 에디터에서는 폼 셀렉트 블록이 라벨 + 셀렉트 박스로 보이도록 렌더링한다.
|
|
return (
|
|
<div className="flex flex-col gap-1 text-xs">
|
|
{selectProps.labelMode === "image" && selectProps.labelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={selectProps.labelImageUrl}
|
|
alt={selectProps.labelImageAlt || selectProps.label || "폼 라벨"}
|
|
className="h-4 w-auto"
|
|
/>
|
|
) : (
|
|
<span>{selectProps.label}</span>
|
|
)}
|
|
<div className="w-full">
|
|
<select
|
|
data-testid="form-select-field"
|
|
className="pb-select w-full text-xs outline-none"
|
|
style={fieldStyle}
|
|
name={fieldName}
|
|
aria-label={selectProps.label}
|
|
defaultValue={options[0]?.value}
|
|
>
|
|
{options.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "formRadio" && (() => {
|
|
const radioProps = block.props as FormRadioBlockProps;
|
|
const options = Array.isArray(radioProps.options) && radioProps.options.length > 0
|
|
? radioProps.options
|
|
: [
|
|
{ label: "옵션 A", value: "option_a" },
|
|
{ label: "옵션 B", value: "option_b" },
|
|
];
|
|
|
|
const groupLabelMode = radioProps.groupLabelMode ?? "text";
|
|
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
|
|
const labelLayout = radioProps.labelLayout ?? "stacked";
|
|
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
|
|
|
const radioTokens = computeFormOptionGroupEditorTokens(radioProps);
|
|
const fieldStyle = radioTokens.fieldStyle;
|
|
|
|
const optionLayout = radioProps.optionLayout ?? "stacked";
|
|
const optionsLayoutClass = [
|
|
"pb-form-options",
|
|
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
|
].join(" ");
|
|
|
|
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
|
|
const optionGapPx =
|
|
typeof radioProps.optionGapPx === "number" && radioProps.optionGapPx >= 0
|
|
? radioProps.optionGapPx
|
|
: undefined;
|
|
const optionsStyle: CSSProperties =
|
|
optionGapPx !== undefined
|
|
? optionLayout === "inline"
|
|
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
|
|
: { rowGap: `${optionGapPx}px` }
|
|
: {};
|
|
|
|
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
|
|
const inlineGapPx = typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8;
|
|
const groupContainerStyle: CSSProperties = {
|
|
...fieldStyle,
|
|
...(isInlineLayout ? { columnGap: `${inlineGapPx}px` } : {}),
|
|
};
|
|
const groupContainerClassName = isInlineLayout
|
|
? "flex flex-row items-center text-xs"
|
|
: "flex flex-col gap-1 text-xs";
|
|
|
|
return (
|
|
<div className={groupContainerClassName} style={groupContainerStyle}>
|
|
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
|
|
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={radioProps.groupLabelImageUrl}
|
|
alt={radioProps.groupLabel || "폼 그룹 타이틀"}
|
|
className="inline-block max-w-full h-auto"
|
|
/>
|
|
) : (
|
|
<span>{radioProps.groupLabel}</span>
|
|
)}
|
|
<div style={optionsStyle} className={optionsLayoutClass}>
|
|
{options.map((opt) => (
|
|
<label key={opt.value} className="pb-form-option">
|
|
<input
|
|
type="radio"
|
|
className="h-4 w-4"
|
|
name={radioProps.formFieldName}
|
|
value={opt.value}
|
|
readOnly
|
|
/>
|
|
{/* 옵션 옆 라벨은 옵션에 개별 이미지 URL 이 있는 경우 이미지로 렌더링한다. */}
|
|
{opt.labelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={opt.labelImageUrl}
|
|
alt={opt.label || radioProps.groupLabel || "폼 라벨"}
|
|
className="h-4 w-auto"
|
|
/>
|
|
) : (
|
|
<span>{opt.label}</span>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "formCheckbox" && (() => {
|
|
const checkboxProps = block.props as FormCheckboxBlockProps;
|
|
const options = Array.isArray(checkboxProps.options) && checkboxProps.options.length > 0
|
|
? checkboxProps.options
|
|
: [
|
|
{ label: "체크 1", value: "check_1" },
|
|
{ label: "체크 2", value: "check_2" },
|
|
];
|
|
|
|
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
|
|
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
|
|
const labelLayout = checkboxProps.labelLayout ?? "stacked";
|
|
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
|
|
|
const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps);
|
|
const fieldStyle = checkboxTokens.fieldStyle;
|
|
|
|
const optionLayout = checkboxProps.optionLayout ?? "stacked";
|
|
const optionsLayoutClass = [
|
|
"pb-form-options",
|
|
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
|
].join(" ");
|
|
|
|
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
|
|
const inlineGapPx = typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8;
|
|
const groupContainerStyle: CSSProperties = {
|
|
...fieldStyle,
|
|
...(isInlineLayout ? { columnGap: `${inlineGapPx}px` } : {}),
|
|
};
|
|
|
|
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
|
|
const optionGapPx =
|
|
typeof checkboxProps.optionGapPx === "number" && checkboxProps.optionGapPx >= 0
|
|
? checkboxProps.optionGapPx
|
|
: undefined;
|
|
const optionsStyle: CSSProperties =
|
|
optionGapPx !== undefined
|
|
? optionLayout === "inline"
|
|
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
|
|
: { rowGap: `${optionGapPx}px` }
|
|
: {};
|
|
const groupContainerClassName = isInlineLayout
|
|
? "flex flex-row items-center text-xs"
|
|
: "flex flex-col gap-1 text-xs";
|
|
|
|
return (
|
|
<div className={groupContainerClassName} style={groupContainerStyle}>
|
|
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
|
|
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={checkboxProps.groupLabelImageUrl}
|
|
alt={checkboxProps.groupLabel || "폼 그룹 타이틀"}
|
|
className="inline-block max-w-full h-auto"
|
|
/>
|
|
) : (
|
|
<span>{checkboxProps.groupLabel}</span>
|
|
)}
|
|
<div className={optionsLayoutClass} style={optionsStyle}>
|
|
{options.map((opt) => (
|
|
<label key={opt.value} className="pb-form-option">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded"
|
|
name={checkboxProps.formFieldName}
|
|
value={opt.value}
|
|
readOnly
|
|
/>
|
|
{opt.labelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={opt.labelImageUrl}
|
|
alt={opt.label || checkboxProps.groupLabel || "폼 라벨"}
|
|
className="h-4 w-auto"
|
|
/>
|
|
) : (
|
|
<span>{opt.label}</span>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "button" && (() => {
|
|
const buttonProps = block.props as ButtonBlockProps;
|
|
|
|
const pbTokens = computeButtonPbTokens({
|
|
align: buttonProps.align ?? "left",
|
|
size: buttonProps.size ?? "md",
|
|
variant: buttonProps.variant ?? "solid",
|
|
colorPalette: buttonProps.colorPalette ?? "primary",
|
|
borderRadius: buttonProps.borderRadius ?? "md",
|
|
fullWidth: buttonProps.fullWidth ?? false,
|
|
widthMode: buttonProps.widthMode ?? undefined,
|
|
widthPx: typeof buttonProps.widthPx === "number" ? buttonProps.widthPx : undefined,
|
|
paddingX: typeof buttonProps.paddingX === "number" ? buttonProps.paddingX : undefined,
|
|
paddingY: typeof buttonProps.paddingY === "number" ? buttonProps.paddingY : undefined,
|
|
fillColorCustom: buttonProps.fillColorCustom ?? undefined,
|
|
strokeColorCustom: buttonProps.strokeColorCustom ?? undefined,
|
|
textColorCustom: buttonProps.textColorCustom ?? undefined,
|
|
});
|
|
|
|
const editorTokens = computeButtonEditorTokens(buttonProps);
|
|
|
|
return (
|
|
<div className={editorTokens.wrapperWidthClass}>
|
|
<button
|
|
type="button"
|
|
className={`pb-btn-base ${pbTokens.sizeClass} ${pbTokens.radiusClass} ${pbTokens.variantClass} w-full`}
|
|
style={editorTokens.buttonStyle}
|
|
aria-label={buttonProps.label}
|
|
onClick={(e) => {
|
|
// 에디터 내 버튼 클릭은 선택만 유지하고 실제 이동은 막는다.
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<span className="whitespace-pre-wrap">{buttonProps.label}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "video" && (() => {
|
|
const videoProps = block.props as VideoBlockProps;
|
|
const normalizedUrl = normalizeVideoSourceUrl(videoProps.sourceUrl ?? "");
|
|
const platform = resolveVideoPlatform(normalizedUrl, videoProps.platform ?? "auto");
|
|
const embedUrl = buildVideoEmbedUrl(normalizedUrl, platform, { enableVimeoEmbed: false });
|
|
const tokens = computeVideoEditorTokens(videoProps);
|
|
|
|
const startTimeSec =
|
|
typeof videoProps.startTimeSec === "number" && videoProps.startTimeSec >= 0
|
|
? videoProps.startTimeSec
|
|
: null;
|
|
const endTimeSec =
|
|
typeof videoProps.endTimeSec === "number" && videoProps.endTimeSec >= 0
|
|
? videoProps.endTimeSec
|
|
: null;
|
|
const isEmbed = platform === "youtube" || platform === "vimeo";
|
|
|
|
return (
|
|
<div className={`w-full flex ${tokens.justifyClass}`}>
|
|
<div>
|
|
<div className={`pb-video-wrapper${tokens.aspectClass}`} style={tokens.wrapperStyle}>
|
|
{isEmbed ? (
|
|
<iframe
|
|
title={videoProps.titleText && videoProps.titleText.trim() !== "" ? videoProps.titleText.trim() : "비디오"}
|
|
src={embedUrl}
|
|
className="pb-video-frame"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowFullScreen
|
|
/>
|
|
) : (
|
|
<video
|
|
data-testid="editor-video"
|
|
className="pb-video-frame"
|
|
src={normalizedUrl !== "" ? normalizedUrl : undefined}
|
|
style={tokens.videoStyle}
|
|
poster={videoProps.posterImageSrc && videoProps.posterImageSrc.trim() !== "" ? videoProps.posterImageSrc.trim() : undefined}
|
|
aria-label={videoProps.ariaLabel && videoProps.ariaLabel.trim() !== "" ? videoProps.ariaLabel.trim() : undefined}
|
|
onLoadedMetadata={(event) => {
|
|
if (startTimeSec != null) {
|
|
const el = event.currentTarget;
|
|
try {
|
|
el.currentTime = startTimeSec;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}}
|
|
onTimeUpdate={(event) => {
|
|
if (endTimeSec != null) {
|
|
const el = event.currentTarget;
|
|
if (el.currentTime >= endTimeSec) {
|
|
el.pause();
|
|
try {
|
|
if (!Number.isNaN(endTimeSec)) {
|
|
el.currentTime = endTimeSec;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
controls={videoProps.controls !== false}
|
|
autoPlay={!!videoProps.autoplay}
|
|
loop={!!videoProps.loop}
|
|
muted={!!videoProps.muted}
|
|
/>
|
|
)}
|
|
</div>
|
|
{videoProps.captionText && videoProps.captionText.trim() !== "" && (
|
|
<p
|
|
data-testid="editor-video-caption"
|
|
className="mt-2 text-xs text-slate-400 hidden"
|
|
>
|
|
{videoProps.captionText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "image" && (() => {
|
|
const imageProps = block.props as ImageBlockProps;
|
|
const hasSrc = imageProps.src.trim().length > 0;
|
|
const align = imageProps.align ?? "center";
|
|
const alignClass =
|
|
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
|
const tokens = computeImageEditorTokens({
|
|
widthMode: imageProps.widthMode,
|
|
widthPx: imageProps.widthPx,
|
|
borderRadius: imageProps.borderRadius,
|
|
borderRadiusPx: imageProps.borderRadiusPx,
|
|
});
|
|
|
|
const containerStyle: React.CSSProperties = {};
|
|
if (typeof tokens.widthPx === "number") {
|
|
containerStyle.width = `${tokens.widthPx}px`;
|
|
}
|
|
|
|
const imageStyle: React.CSSProperties = {
|
|
maxWidth: "100%",
|
|
height: "auto",
|
|
borderRadius: `${tokens.radiusPx}px`,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`w-full border border-dashed border-slate-700 bg-slate-500/40 rounded flex items-center overflow-hidden ${alignClass}`}
|
|
>
|
|
{hasSrc ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={imageProps.src}
|
|
alt={imageProps.alt}
|
|
className="object-contain"
|
|
style={{ ...containerStyle, ...imageStyle }}
|
|
/>
|
|
) : (
|
|
<span className="text-[10px] text-slate-900 dark:text-slate-500 px-2 py-4">
|
|
이미지 URL 을 입력하거나 파일을 업로드하면 여기에서 미리보기가 표시됩니다.
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "divider" && (() => {
|
|
const dividerProps = block.props as DividerBlockProps;
|
|
const tokens = computeDividerEditorTokens(dividerProps);
|
|
|
|
// 색상: colorHex 가 있으면 사용, 없으면 기본 슬레이트 색상
|
|
const borderColor = tokens.borderColor;
|
|
|
|
// 길이/너비: widthMode/widthPx 에 따라 가로 길이를 조정
|
|
const widthPx = tokens.widthPx;
|
|
|
|
// 위/아래 여백: marginYPx 가 있으면 px 값 그대로 사용, 없으면 토큰 기반 기본값
|
|
const margin = tokens.marginPx;
|
|
|
|
const innerStyle: React.CSSProperties = {
|
|
borderColor,
|
|
};
|
|
if (typeof widthPx === "number") {
|
|
innerStyle.width = `${widthPx}px`;
|
|
}
|
|
|
|
return (
|
|
<div className="w-full" style={{ marginTop: margin, marginBottom: margin }}>
|
|
<div
|
|
className={`${tokens.thicknessClass} ${tokens.innerWidthClass} border-t`}
|
|
style={innerStyle}
|
|
/>
|
|
</div>
|
|
);
|
|
})()}
|
|
{block.type === "list" && (() => {
|
|
const listProps = block.props as ListBlockProps;
|
|
const alignClass =
|
|
listProps.align === "center"
|
|
? "text-center"
|
|
: listProps.align === "right"
|
|
? "text-right"
|
|
: "text-left";
|
|
|
|
const tokens = computeListEditorTokens(listProps);
|
|
|
|
// 줄 간 간격: gapYPx 숫자 값을 우선 사용하고, 없으면 gapY 토큰을 space-y 클래스로 매핑
|
|
const gapPx = tokens.gapPx;
|
|
|
|
// 타이포/색상 스타일
|
|
const listStyle = tokens.listStyle;
|
|
|
|
// 불릿 스타일 (none 인 경우만 제거, decimal 은 숫자형으로 매핑)
|
|
const bulletStyle = tokens.bulletStyle;
|
|
|
|
const itemsTree: ListItemNode[] =
|
|
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
|
|
? (listProps.itemsTree as ListItemNode[])
|
|
: listProps.items && listProps.items.length > 0
|
|
? listProps.items.map((text, index) => ({
|
|
id: `${block.id}_item_${index + 1}`,
|
|
text,
|
|
children: [],
|
|
}))
|
|
: [
|
|
{
|
|
id: `${block.id}_item_1`,
|
|
text: "리스트 아이템 1",
|
|
children: [],
|
|
},
|
|
];
|
|
|
|
const renderListNodes = (nodes: ListItemNode[], level: number): React.ReactNode => {
|
|
const isOrderedLevel = listProps.ordered;
|
|
const ListTag = (isOrderedLevel ? "ol" : "ul") as "ol" | "ul";
|
|
|
|
return (
|
|
<ListTag
|
|
className={`text-xs ${alignClass}`}
|
|
style={{
|
|
...listStyle,
|
|
listStyleType:
|
|
bulletStyle === "none"
|
|
? "none"
|
|
: bulletStyle,
|
|
paddingLeft: 12 + level * 8,
|
|
}}
|
|
>
|
|
{nodes.map((node, index) => (
|
|
<li
|
|
key={node.id}
|
|
data-list-item-id={node.id}
|
|
className={
|
|
selectedListItemId === node.id
|
|
? "rounded bg-sky-900/40 text-sky-100"
|
|
: undefined
|
|
}
|
|
style={index < nodes.length - 1 ? { marginBottom: gapPx } : undefined}
|
|
>
|
|
<span className="break-words align-middle">{node.text}</span>
|
|
<span className="ml-2 inline-flex items-center gap-1 text-[10px] text-slate-900 dark:text-slate-300 align-middle">
|
|
<button
|
|
type="button"
|
|
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
selectBlock(block.id);
|
|
selectListItem(node.id);
|
|
(useEditorStore.getState() as any).moveSelectedListItemUp(block.id);
|
|
}}
|
|
>
|
|
위
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
selectBlock(block.id);
|
|
selectListItem(node.id);
|
|
(useEditorStore.getState() as any).moveSelectedListItemDown(block.id);
|
|
}}
|
|
>
|
|
아래
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
selectBlock(block.id);
|
|
selectListItem(node.id);
|
|
(useEditorStore.getState() as any).indentSelectedListItem(block.id);
|
|
}}
|
|
>
|
|
들여쓰기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
selectBlock(block.id);
|
|
selectListItem(node.id);
|
|
(useEditorStore.getState() as any).outdentSelectedListItem(block.id);
|
|
}}
|
|
>
|
|
내어쓰기
|
|
</button>
|
|
</span>
|
|
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
|
|
</li>
|
|
))}
|
|
</ListTag>
|
|
);
|
|
};
|
|
|
|
return <div className="w-full py-1">{renderListNodes(itemsTree, 0)}</div>;
|
|
})()}
|
|
{block.type === "section" && (() => {
|
|
const sectionProps = block.props as SectionBlockProps;
|
|
const isSectionSelected =
|
|
isSelected ||
|
|
(selectedBlockId != null &&
|
|
allBlocks.some(
|
|
(candidate) => candidate.id === selectedBlockId && candidate.sectionId === block.id,
|
|
));
|
|
const sectionTokens = computeSectionEditorTokens(sectionProps);
|
|
|
|
const columns = sectionProps.columns && sectionProps.columns.length > 0
|
|
? sectionProps.columns
|
|
: [{ id: `${block.id}_col_fallback`, span: 12 }];
|
|
|
|
return (
|
|
<div
|
|
data-testid="editor-section"
|
|
className={`w-full ${sectionTokens.bgClass} ${sectionTokens.pyClass} rounded border ${
|
|
isSectionSelected ? "border-sky-500" : "border-dashed border-slate-700"
|
|
}`}
|
|
style={sectionTokens.wrapperStyle}
|
|
>
|
|
{sectionTokens.hasBackgroundVideo && (
|
|
<video
|
|
data-testid="editor-section-bg-video"
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
src={sectionTokens.backgroundVideoSrc!}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
/>
|
|
)}
|
|
<div
|
|
className="mx-auto px-4"
|
|
style={sectionTokens.innerWrapperStyle}
|
|
>
|
|
<div
|
|
className={`flex ${sectionTokens.alignItemsClass}`}
|
|
style={sectionTokens.columnsContainerStyle}
|
|
>
|
|
{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}
|
|
basis={basis}
|
|
blocks={columnBlocks}
|
|
renderBlocks={renderBlocks}
|
|
span={col.span}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BlockDropzoneProps {
|
|
id: string;
|
|
testId?: string;
|
|
}
|
|
|
|
function BlockDropzone({ id, testId }: BlockDropzoneProps) {
|
|
const { setNodeRef, isOver } = useDroppable({ id });
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
data-testid={testId}
|
|
className={`h-2 w-full rounded-full transition-colors ${
|
|
isOver ? "bg-sky-500" : "bg-sky-500/40"
|
|
}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface ColumnDroppableProps {
|
|
id: string;
|
|
sectionId: string;
|
|
columnId: string;
|
|
basis: string;
|
|
blocks: Block[];
|
|
renderBlocks: (targetBlocks: Block[]) => ReactNode;
|
|
span: number;
|
|
}
|
|
|
|
function ColumnDroppable({ id, sectionId, columnId, basis, blocks, renderBlocks, span }: ColumnDroppableProps) {
|
|
const { setNodeRef } = useDroppable({ id });
|
|
const { over } = useDndContext();
|
|
|
|
// 현재 드롭 대상이 이 컬럼이거나, 이 컬럼 안 블록들의 dropzone-before / dropzone-after 인 경우
|
|
// 컬럼 박스를 강조해서 사용자가 어느 컬럼으로 이동 중인지 명확히 보여준다.
|
|
const overId = over?.id ? String(over.id) : null;
|
|
|
|
const isActiveColumn = (() => {
|
|
if (!overId) return false;
|
|
|
|
// 0) 섹션 블록 자체 위에 있는 경우: 해당 섹션의 컬럼 전체를 하이라이트한다.
|
|
if (overId === sectionId) return true;
|
|
|
|
// 1) 이 컬럼 droppable 자체 위에 있는 경우
|
|
if (overId === id) return true;
|
|
|
|
// 2) 이 컬럼 안 블록들 앞 드롭존 또는 섹션 바로 위 드롭존: dropzone-before:*
|
|
if (overId.startsWith("dropzone-before:")) {
|
|
const [, targetId] = overId.split(":");
|
|
// 섹션 바로 위 루트 드롭존(dropzone-before:sectionId)도 이 섹션의 컬럼을 활성화한다.
|
|
if (targetId === sectionId) {
|
|
return true;
|
|
}
|
|
|
|
if (targetId && blocks.some((b) => b.id === targetId)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 3) 이 컬럼 맨 아래 드롭존: dropzone-after:sectionId:columnId
|
|
if (overId === `dropzone-after:${sectionId}:${columnId}`) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
})();
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
data-section-id={sectionId}
|
|
data-column-id={columnId}
|
|
style={{ flexBasis: basis }}
|
|
>
|
|
<div
|
|
className={`border rounded flex flex-col gap-2 items-stretch justify-start px-2 py-2 bg-slate-100/40 dark:bg-slate-950/40 ${
|
|
isActiveColumn ? "border-sky-500" : "border-slate-800/80"
|
|
}`}
|
|
style={{}}
|
|
>
|
|
{blocks.length === 0 ? (
|
|
<span className="text-[11px] px-2 text-center text-slate-900 dark:text-slate-300">
|
|
{`컬럼 영역 (span ${span}/12)`}
|
|
</span>
|
|
) : (
|
|
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
|
|
<div className="flex flex-col gap-2">
|
|
{renderBlocks(blocks)}
|
|
{/* 컬럼 맨 아래 드롭존: 블록을 이 컬럼의 최하단으로 이동 */}
|
|
<BlockDropzone id={`dropzone-after:${sectionId}:${columnId}`} />
|
|
</div>
|
|
</SortableContext>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|