이미지 파일 업로드 기능
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { rectIntersection } from "@dnd-kit/core";
|
||||
|
||||
import type { Block, ButtonBlockProps, ImageBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, ButtonBlockProps, ImageBlockProps, ProjectConfig, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
interface EditorCanvasProps {
|
||||
blocks: Block[];
|
||||
@@ -25,6 +25,7 @@ interface EditorCanvasProps {
|
||||
handleDragStart: (event: DragStartEvent) => void;
|
||||
handleDragEnd: (event: DragEndEvent) => void;
|
||||
handleDragCancel: () => void;
|
||||
projectConfig: ProjectConfig;
|
||||
}
|
||||
|
||||
export function EditorCanvas(props: EditorCanvasProps) {
|
||||
@@ -38,8 +39,30 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragCancel,
|
||||
projectConfig,
|
||||
} = props;
|
||||
|
||||
const canvasInnerStyle: CSSProperties = {};
|
||||
const preset = projectConfig.canvasPreset ?? "full";
|
||||
const widthPx =
|
||||
typeof projectConfig.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
||||
? projectConfig.canvasWidthPx
|
||||
: null;
|
||||
|
||||
if (widthPx != null) {
|
||||
canvasInnerStyle.maxWidth = `${widthPx}px`;
|
||||
} else if (preset === "mobile") {
|
||||
canvasInnerStyle.maxWidth = "390px";
|
||||
} else if (preset === "tablet") {
|
||||
canvasInnerStyle.maxWidth = "768px";
|
||||
} else if (preset === "desktop") {
|
||||
canvasInnerStyle.maxWidth = "1200px";
|
||||
}
|
||||
|
||||
if (projectConfig.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
|
||||
canvasInnerStyle.backgroundColor = projectConfig.canvasBgColorHex;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
|
||||
@@ -50,31 +73,37 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
|
||||
왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={rectIntersection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext
|
||||
items={rootBlocks.map((b) => b.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<div
|
||||
data-testid="editor-canvas-inner"
|
||||
className="mx-auto w-full flex flex-col gap-2"
|
||||
style={canvasInnerStyle}
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
|
||||
왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={rectIntersection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{renderBlocks(rootBlocks)}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeDragId && (
|
||||
<DragPreview block={blocks.find((b) => b.id === activeDragId) ?? null} />
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
<SortableContext
|
||||
items={rootBlocks.map((b) => b.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{renderBlocks(rootBlocks)}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeDragId && (
|
||||
<DragPreview block={blocks.find((b) => b.id === activeDragId) ?? null} />
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,20 +48,91 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
/>
|
||||
</label>
|
||||
|
||||
{checkboxProps.groupLabelMode === "image" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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={checkboxProps.groupLabelImageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageUrl: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{checkboxProps.groupLabelMode === "image" && (() => {
|
||||
const source: "url" | "upload" =
|
||||
checkboxProps.groupLabelImageSource ??
|
||||
(checkboxProps.groupLabelImageUrl && checkboxProps.groupLabelImageUrl.startsWith("/api/image/")
|
||||
? "upload"
|
||||
: "url");
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={source}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageSource: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{source === "url" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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={checkboxProps.groupLabelImageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageUrl: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{source === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="그룹 타이틀 이미지 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("체크박스 그룹 라벨 이미지 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageUrl: servedUrl,
|
||||
groupLabelImageSource: "upload",
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("체크박스 그룹 라벨 이미지 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">전송 키</span>
|
||||
@@ -99,6 +170,27 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">옵션 이미지 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={(() => {
|
||||
if (checkboxProps.optionImageSource) return checkboxProps.optionImageSource;
|
||||
const first = (((checkboxProps as any).options ?? []) as any[])[0];
|
||||
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
|
||||
return "url";
|
||||
})()}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionImageSource: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(((checkboxProps as any).options ?? []) as any[]).map((opt, index) => (
|
||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -146,21 +238,81 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
labelImageUrl: e.target.value,
|
||||
};
|
||||
updateBlock(selectedBlockId, {
|
||||
options: next,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const source: "url" | "upload" =
|
||||
checkboxProps.optionImageSource ??
|
||||
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
|
||||
|
||||
return (
|
||||
<>
|
||||
{source === "url" && (
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
labelImageUrl: e.target.value,
|
||||
};
|
||||
updateBlock(selectedBlockId, {
|
||||
options: next,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{source === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">옵션 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="옵션 이미지 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("체크박스 옵션 라벨 이미지 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||
|
||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
labelImageUrl: servedUrl,
|
||||
};
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
options: next,
|
||||
optionImageSource: "upload",
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("체크박스 옵션 라벨 이미지 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -48,20 +48,91 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
/>
|
||||
</label>
|
||||
|
||||
{radioProps.groupLabelMode === "image" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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={radioProps.groupLabelImageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageUrl: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{radioProps.groupLabelMode === "image" && (() => {
|
||||
const source: "url" | "upload" =
|
||||
radioProps.groupLabelImageSource ??
|
||||
(radioProps.groupLabelImageUrl && radioProps.groupLabelImageUrl.startsWith("/api/image/")
|
||||
? "upload"
|
||||
: "url");
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={source}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageSource: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{source === "url" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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={radioProps.groupLabelImageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageUrl: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{source === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="그룹 타이틀 이미지 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("라디오 그룹 라벨 이미지 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelImageUrl: servedUrl,
|
||||
groupLabelImageSource: "upload",
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("라디오 그룹 라벨 이미지 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">전송 키</span>
|
||||
@@ -99,6 +170,27 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">옵션 이미지 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={(() => {
|
||||
if (radioProps.optionImageSource) return radioProps.optionImageSource;
|
||||
const first = (((radioProps as any).options ?? []) as any[])[0];
|
||||
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
|
||||
return "url";
|
||||
})()}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionImageSource: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(((radioProps as any).options ?? []) as any[]).map((opt, index) => (
|
||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -146,21 +238,81 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
labelImageUrl: e.target.value,
|
||||
};
|
||||
updateBlock(selectedBlockId, {
|
||||
options: next,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const source: "url" | "upload" =
|
||||
radioProps.optionImageSource ??
|
||||
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
|
||||
|
||||
return (
|
||||
<>
|
||||
{source === "url" && (
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
labelImageUrl: e.target.value,
|
||||
};
|
||||
updateBlock(selectedBlockId, {
|
||||
options: next,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{source === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">옵션 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="옵션 이미지 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("라디오 옵션 라벨 이미지 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||
|
||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
labelImageUrl: servedUrl,
|
||||
};
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
options: next,
|
||||
optionImageSource: "upload",
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("라디오 옵션 라벨 이미지 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+96
-6
@@ -38,6 +38,7 @@ import type {
|
||||
FormCheckboxBlockProps,
|
||||
FormRadioBlockProps,
|
||||
ListItemNode,
|
||||
ProjectConfig,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
|
||||
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
|
||||
@@ -95,6 +96,9 @@ export default function EditorPage() {
|
||||
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 [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
@@ -115,12 +119,53 @@ export default function EditorPage() {
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const editorMainStyle: CSSProperties = {};
|
||||
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
||||
editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error("정적 파일 내보내기 실패", await response.text().catch(() => ""));
|
||||
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 });
|
||||
@@ -545,7 +590,7 @@ export default function EditorPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
||||
<main className="min-h-screen flex flex-col" style={editorMainStyle}>
|
||||
<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>
|
||||
@@ -589,6 +634,16 @@ export default function EditorPage() {
|
||||
>
|
||||
JSON 내보내기/불러오기
|
||||
</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();
|
||||
}}
|
||||
>
|
||||
페이지 파일로 내보내기 (ZIP)
|
||||
</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"
|
||||
@@ -621,6 +676,7 @@ export default function EditorPage() {
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragCancel={handleDragCancel}
|
||||
projectConfig={projectConfig}
|
||||
/>
|
||||
<PropertiesSidebar
|
||||
blocks={blocks}
|
||||
@@ -1232,7 +1288,7 @@ function SortableEditorBlock({
|
||||
<img
|
||||
src={radioProps.groupLabelImageUrl}
|
||||
alt={radioProps.groupLabel || "폼 그룹 타이틀"}
|
||||
className="h-4 w-auto text-slate-200"
|
||||
className="inline-block max-w-full h-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200">{radioProps.groupLabel}</span>
|
||||
@@ -1309,7 +1365,7 @@ function SortableEditorBlock({
|
||||
<img
|
||||
src={checkboxProps.groupLabelImageUrl}
|
||||
alt={checkboxProps.groupLabel || "폼 그룹 타이틀"}
|
||||
className="h-4 w-auto text-slate-200"
|
||||
className="inline-block max-w-full h-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
|
||||
@@ -1416,18 +1472,52 @@ function SortableEditorBlock({
|
||||
{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 containerStyle: React.CSSProperties = {};
|
||||
const widthMode = imageProps.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof imageProps.widthPx === "number" && imageProps.widthPx > 0) {
|
||||
containerStyle.width = `${imageProps.widthPx}px`;
|
||||
}
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
};
|
||||
const radiusToken = imageProps.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
const radiusPx =
|
||||
typeof imageProps.borderRadiusPx === "number" && imageProps.borderRadiusPx >= 0
|
||||
? imageProps.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
imageStyle.borderRadius = `${radiusPx}px`;
|
||||
|
||||
return (
|
||||
<div className="w-full border border-dashed border-slate-700 bg-slate-900/40 rounded flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
className={`w-full border border-dashed border-slate-700 bg-slate-900/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="max-w-full h-auto object-contain"
|
||||
className="object-contain"
|
||||
style={{ ...containerStyle, ...imageStyle }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 px-2 py-4">
|
||||
이미지 URL을 속성 패널에서 입력하세요.
|
||||
이미지 URL 을 입력하거나 파일을 업로드하면 여기에서 미리보기가 표시됩니다.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,21 +10,106 @@ export type ImagePropertiesPanelProps = {
|
||||
};
|
||||
|
||||
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
|
||||
const source: "url" | "upload" =
|
||||
imageProps.sourceType === "asset" || (imageProps.src && imageProps.src.startsWith("/api/image/"))
|
||||
? "upload"
|
||||
: "url";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="이미지 URL"
|
||||
value={imageProps.src}
|
||||
<span>이미지 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="이미지 소스"
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { src: e.target.value } as any);
|
||||
const next = e.target.value as "url" | "upload";
|
||||
if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{source === "url" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="이미지 URL"
|
||||
value={imageProps.src}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
src: value,
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === "upload" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="이미지 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("이미지 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
src: servedUrl,
|
||||
sourceType: "asset",
|
||||
assetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("이미지 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>대체 텍스트</span>
|
||||
@@ -104,24 +189,30 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
<NumericPropertyControl
|
||||
label="모서리 둥글기"
|
||||
value={(() => {
|
||||
if (typeof imageProps.borderRadiusPx === "number") {
|
||||
return imageProps.borderRadiusPx;
|
||||
}
|
||||
const r = imageProps.borderRadius ?? "md";
|
||||
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
|
||||
// 토큰만 있는 기존 데이터의 경우, 토큰별 대표값으로 초기화한다.
|
||||
return r === "none" ? 0 : r === "sm" ? 20 : r === "lg" ? 120 : r === "full" ? 180 : 60;
|
||||
})()}
|
||||
min={0}
|
||||
max={8}
|
||||
max={200}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "none", label: "없음", value: 0 },
|
||||
{ id: "sm", label: "작게", value: 2 },
|
||||
{ id: "md", label: "보통", value: 4 },
|
||||
{ id: "lg", label: "크게", value: 6 },
|
||||
{ id: "full", label: "완전 둥글게", value: 8 },
|
||||
{ id: "sm", label: "작게", value: 20 },
|
||||
{ id: "md", label: "보통", value: 60 },
|
||||
{ id: "lg", label: "크게", value: 120 },
|
||||
{ id: "full", label: "완전 둥글게", value: 180 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
const next =
|
||||
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
|
||||
// 0~50 범위를 토큰으로 매핑한다.
|
||||
const nextToken =
|
||||
v <= 0 ? "none" : v <= 30 ? "sm" : v <= 90 ? "md" : v <= 150 ? "lg" : "full";
|
||||
updateBlock(selectedBlockId, {
|
||||
borderRadius: next,
|
||||
borderRadius: nextToken,
|
||||
borderRadiusPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||
import type { CanvasPreset, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
export function ProjectPropertiesPanel() {
|
||||
const projectConfig = useEditorStore((state) => (state as any).projectConfig as ProjectConfig);
|
||||
const updateProjectConfig = useEditorStore(
|
||||
(state) => (state as any).updateProjectConfig as (partial: Partial<ProjectConfig>) => void,
|
||||
);
|
||||
|
||||
const handleChangePreset = (preset: CanvasPreset) => {
|
||||
if (preset === "mobile") {
|
||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 390 });
|
||||
} else if (preset === "tablet") {
|
||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 768 });
|
||||
} else if (preset === "desktop") {
|
||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 1200 });
|
||||
} else if (preset === "full") {
|
||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: undefined });
|
||||
} else {
|
||||
updateProjectConfig({ canvasPreset: preset });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeCanvasWidth = (value: number) => {
|
||||
if (value === 390) {
|
||||
handleChangePreset("mobile");
|
||||
} else if (value === 768) {
|
||||
handleChangePreset("tablet");
|
||||
} else if (value === 1200) {
|
||||
handleChangePreset("desktop");
|
||||
} else {
|
||||
updateProjectConfig({ canvasPreset: "custom", canvasWidthPx: value });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-xs text-slate-200">
|
||||
<h3 className="text-sm font-medium text-slate-100">프로젝트 설정</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">프로젝트 제목</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="프로젝트 제목"
|
||||
value={projectConfig.title}
|
||||
onChange={(e) => updateProjectConfig({ title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">프로젝트 주소 (slug)</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="프로젝트 주소 (slug)"
|
||||
value={projectConfig.slug}
|
||||
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<NumericPropertyControl
|
||||
label="캔버스 너비"
|
||||
unitLabel="(px)"
|
||||
value={projectConfig.canvasWidthPx ?? 1024}
|
||||
min={320}
|
||||
max={1920}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "mobile", label: "모바일 (390px)", value: 390 },
|
||||
{ id: "tablet", label: "태블릿 (768px)", value: 768 },
|
||||
{ id: "desktop", label: "데스크톱 (1200px)", value: 1200 },
|
||||
]}
|
||||
onChangeValue={handleChangeCanvasWidth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="캔버스 배경색"
|
||||
ariaLabelColorInput="캔버스 배경색"
|
||||
ariaLabelHexInput="캔버스 배경색 HEX"
|
||||
value={projectConfig.canvasBgColorHex ?? "#020617"}
|
||||
onChange={(hex) => updateProjectConfig({ canvasBgColorHex: hex })}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="페이지 배경색"
|
||||
ariaLabelColorInput="페이지 배경색"
|
||||
ariaLabelHexInput="페이지 배경색 HEX"
|
||||
value={projectConfig.bodyBgColorHex ?? "#020617"}
|
||||
onChange={(hex) => updateProjectConfig({ bodyBgColorHex: hex })}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">페이지 head HTML</span>
|
||||
<textarea
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500 min-h-[72px]"
|
||||
aria-label="페이지 head HTML"
|
||||
value={projectConfig.headHtml ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ headHtml: e.target.value })}
|
||||
placeholder="예: <meta name="description" content="..." />"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">추적 스크립트</span>
|
||||
<textarea
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500 min-h-[72px]"
|
||||
aria-label="추적 스크립트"
|
||||
value={projectConfig.trackingScript ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ trackingScript: e.target.value })}
|
||||
placeholder="예: <script>/* GA, Pixel 코드 */</script>"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { FormSelectPropertiesPanel } from "../forms/FormSelectPropertiesPanel";
|
||||
import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPanel";
|
||||
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
|
||||
import { FormControllerPanel } from "../forms/FormControllerPanel";
|
||||
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
|
||||
|
||||
interface PropertiesSidebarProps {
|
||||
blocks: Block[];
|
||||
@@ -208,7 +209,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">왼쪽 캔버스에서 블록을 선택하면 속성을 편집할 수 있습니다.</p>
|
||||
<ProjectPropertiesPanel />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user