diff --git a/src/app/editor/layout.tsx b/src/app/editor/layout.tsx
new file mode 100644
index 0000000..71d931f
--- /dev/null
+++ b/src/app/editor/layout.tsx
@@ -0,0 +1,8 @@
+import type { ReactNode } from "react";
+import "../../styles/editor.css";
+
+export default function EditorLayout({ children }: { children: ReactNode }) {
+ // 에디터 전용 전역 스타일(editor.css)을 로드하기 위한 중첩 레이아웃.
+ // 실제 마크업 구조는 page.tsx 에서 정의하며, 여기서는 children 을 그대로 반환한다.
+ return children;
+}
diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx
index ebebc3e..1d4194f 100644
--- a/src/app/editor/page.tsx
+++ b/src/app/editor/page.tsx
@@ -184,6 +184,21 @@ function EditorPageInner() {
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 () => {
@@ -218,7 +233,7 @@ function EditorPageInner() {
return () => {
cancelled = true;
};
- }, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
+ }, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks, updateProjectConfig, resetHistory]);
useEffect(() => {
let cancelled = false;
@@ -707,6 +722,13 @@ function EditorPageInner() {
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;
@@ -951,7 +973,7 @@ function EditorPageInner() {
@@ -1443,6 +1465,7 @@ function SortableEditorBlock({
{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);
@@ -1452,47 +1475,100 @@ function SortableEditorBlock({
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 (
- {inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-

- ) : (
-
{inputProps.label}
+ {labelDisplay === "visible" && (
+ inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
{inputProps.label}
+ )
)}
{(() => {
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
const widthClass = inputTokens.widthClass;
+ if (labelDisplay === "floating") {
+ const placeholder = " ";
+
+ return (
+
+
+ {inputProps.label}
+
+ {inputType === "textarea" ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
return (
-
- {inputType === "textarea" ? (
-
- ) : (
-
- )}
-
+
+ {inputType === "textarea" ? (
+
+ ) : (
+
+ )}
+
);
})()}
@@ -1500,6 +1576,7 @@ function SortableEditorBlock({
})()}
{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
: [
@@ -1523,12 +1600,12 @@ function SortableEditorBlock({
) : (