이미지 파일 업로드 기능
CI / test (push) Failing after 10m34s
CI / pr_and_merge (push) Has been cancelled

This commit is contained in:
2025-11-23 19:07:41 +09:00
parent 8ea8a186a0
commit 7a8ad7c057
77 changed files with 3206 additions and 124 deletions
+96 -6
View File
@@ -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>