화면관리ui수정

This commit is contained in:
kjs
2025-10-22 17:19:47 +09:00
parent 96df465a7d
commit 2dd96f5a74
16 changed files with 455 additions and 463 deletions
+62 -54
View File
@@ -10,8 +10,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components"; import { initializeComponents } from "@/lib/registry/components";
import { EditModal } from "@/components/screen/EditModal"; import { EditModal } from "@/components/screen/EditModal";
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
import { useBreakpoint } from "@/hooks/useBreakpoint";
export default function ScreenViewPage() { export default function ScreenViewPage() {
const params = useParams(); const params = useParams();
@@ -22,13 +21,9 @@ export default function ScreenViewPage() {
const [layout, setLayout] = useState<LayoutData | null>(null); const [layout, setLayout] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [formData, setFormData] = useState<Record<string, unknown>>({}); const [formData, setFormData] = useState<Record<string, unknown>>({});
// 화면 너비에 따라 Y좌표 유지 여부 결정
const [preserveYPosition, setPreserveYPosition] = useState(true);
const breakpoint = useBreakpoint();
// 편집 모달 상태 // 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{ const [editModalConfig, setEditModalConfig] = useState<{
@@ -124,24 +119,6 @@ export default function ScreenViewPage() {
} }
}, [screenId]); }, [screenId]);
// 윈도우 크기 변경 감지 - layout이 로드된 후에만 실행
useEffect(() => {
if (!layout) return;
const screenWidth = layout?.screenResolution?.width || 1200;
const handleResize = () => {
const shouldPreserve = window.innerWidth >= screenWidth - 100;
setPreserveYPosition(shouldPreserve);
};
window.addEventListener("resize", handleResize);
// 초기 값도 설정
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, [layout]);
if (loading) { if (loading) {
return ( return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100"> <div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
@@ -172,39 +149,70 @@ export default function ScreenViewPage() {
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용 // 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
const screenWidth = layout?.screenResolution?.width || 1200; const screenWidth = layout?.screenResolution?.width || 1200;
const screenHeight = layout?.screenResolution?.height || 800;
return ( return (
<div className="h-full w-full bg-white"> <div className="bg-background h-full w-full">
<div style={{ padding: "16px 0" }}> {/* 절대 위치 기반 렌더링 */}
{/* 항상 반응형 모드로 렌더링 */} {layout && layout.components.length > 0 ? (
{layout && layout.components.length > 0 ? ( <div
<ResponsiveLayoutEngine className="bg-background relative mx-auto"
components={layout?.components || []} style={{
breakpoint={breakpoint} width: screenWidth,
containerWidth={window.innerWidth} minHeight: screenHeight,
screenWidth={screenWidth} }}
preserveYPosition={preserveYPosition} >
isDesignMode={false} {/* 최상위 컴포넌트들 렌더링 */}
formData={formData} {layout.components
onFormDataChange={(fieldName: string, value: unknown) => { .filter((component) => !component.parentId)
console.log("📝 page.tsx formData 업데이트:", fieldName, value); .map((component) => (
setFormData((prev) => ({ ...prev, [fieldName]: value })); <RealtimePreview
}} key={component.id}
screenInfo={{ id: screenId, tableName: screen?.tableName }} component={component}
/> isSelected={false}
) : ( isDesignMode={false}
// 빈 화면일 때 onClick={() => {}}
<div className="flex items-center justify-center bg-white" style={{ minHeight: "600px" }}> >
<div className="text-center"> {/* 자식 컴포넌트들 */}
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm"> {(component.type === "group" || component.type === "container" || component.type === "area") &&
<span className="text-2xl">📄</span> layout.components
</div> .filter((child) => child.parentId === component.id)
<h2 className="mb-2 text-xl font-semibold text-gray-900"> </h2> .map((child) => {
<p className="text-gray-600"> .</p> // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
const relativeChildComponent = {
...child,
position: {
x: child.position.x - component.position.x,
y: child.position.y - component.position.y,
z: child.position.z || 1,
},
};
return (
<RealtimePreview
key={child.id}
component={relativeChildComponent}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
/>
);
})}
</RealtimePreview>
))}
</div>
) : (
// 빈 화면일 때
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
<div className="text-center">
<div className="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full shadow-sm">
<span className="text-2xl">📄</span>
</div> </div>
<h2 className="text-foreground mb-2 text-xl font-semibold"> </h2>
<p className="text-muted-foreground"> .</p>
</div> </div>
)} </div>
</div> )}
{/* 편집 모달 */} {/* 편집 모달 */}
<EditModal <EditModal
@@ -12,7 +12,6 @@ import {
Save, Save,
Undo, Undo,
Redo, Redo,
Play,
ArrowLeft, ArrowLeft,
Cog, Cog,
Layout, Layout,
@@ -28,7 +27,6 @@ interface DesignerToolbarProps {
onSave: () => void; onSave: () => void;
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
onPreview: () => void;
onTogglePanel: (panelId: string) => void; onTogglePanel: (panelId: string) => void;
panelStates: Record<string, { isOpen: boolean }>; panelStates: Record<string, { isOpen: boolean }>;
canUndo: boolean; canUndo: boolean;
@@ -45,7 +43,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
onSave, onSave,
onUndo, onUndo,
onRedo, onRedo,
onPreview,
onTogglePanel, onTogglePanel,
panelStates, panelStates,
canUndo, canUndo,
@@ -229,11 +226,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
<div className="h-6 w-px bg-gray-300" /> <div className="h-6 w-px bg-gray-300" />
<Button variant="outline" size="sm" onClick={onPreview} className="flex items-center space-x-2">
<Play className="h-4 w-4" />
<span></span>
</Button>
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2"> <Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span> <span>{isSaving ? "저장 중..." : "저장"}</span>
@@ -123,7 +123,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
left: `${position.x}px`, left: `${position.x}px`,
top: `${position.y}px`, top: `${position.y}px`,
width: getWidth(), width: getWidth(),
height: getHeight(), height: getHeight(), // 모든 컴포넌트 고정 높이로 변경
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상 zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
...componentStyle, ...componentStyle,
// style.width와 style.height는 이미 getWidth/getHeight에서 처리했으므로 중복 적용됨 // style.width와 style.height는 이미 getWidth/getHeight에서 처리했으므로 중복 적용됨
@@ -162,7 +162,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{/* 동적 컴포넌트 렌더링 */} {/* 동적 컴포넌트 렌더링 */}
<div <div
className={`h-full w-full max-w-full ${ className={`h-full w-full max-w-full ${
component.componentConfig?.type === "table-list" ? "overflow-hidden" : "overflow-hidden" component.componentConfig?.type === "table-list" ? "overflow-hidden" : "overflow-visible"
}`} }`}
> >
<DynamicComponentRenderer <DynamicComponentRenderer
+104 -152
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database } from "lucide-react"; import { Database, Cog } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
ScreenDefinition, ScreenDefinition,
ComponentData, ComponentData,
@@ -57,7 +58,6 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel"; import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel"; import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
import { ResponsivePreviewModal } from "./ResponsivePreviewModal";
// 새로운 통합 UI 컴포넌트 // 새로운 통합 UI 컴포넌트
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar"; import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
@@ -74,17 +74,9 @@ interface ScreenDesignerProps {
onBackToList: () => void; onBackToList: () => void;
} }
// 패널 설정 (간소화: 템플릿, 격자 제거) // 패널 설정 (컴포넌트와 편집 2개)
const panelConfigs: PanelConfig[] = [ const panelConfigs: PanelConfig[] = [
// 좌측 그룹: 입력/소스 // 컴포넌트 패널 (테이블 + 컴포넌트 탭)
{
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
defaultWidth: 400,
defaultHeight: 700,
shortcutKey: "t",
},
{ {
id: "components", id: "components",
title: "컴포넌트", title: "컴포넌트",
@@ -93,31 +85,15 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "c", shortcutKey: "c",
}, },
// 좌측 그룹: 편집/설정 // 편집 패널 (속성 + 스타일 & 해상도 탭)
{ {
id: "properties", id: "properties",
title: "속성", title: "편집",
defaultPosition: "left", defaultPosition: "left",
defaultWidth: 400, defaultWidth: 400,
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "p", shortcutKey: "p",
}, },
{
id: "styles",
title: "스타일",
defaultPosition: "left",
defaultWidth: 400,
defaultHeight: 700,
shortcutKey: "s",
},
{
id: "resolution",
title: "해상도",
defaultPosition: "left",
defaultWidth: 400,
defaultHeight: 700,
shortcutKey: "e",
},
]; ];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
@@ -145,9 +121,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null); const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
// 반응형 미리보기 모달 상태
const [showResponsivePreview, setShowResponsivePreview] = useState(false);
// 해상도 설정 상태 // 해상도 설정 상태
const [screenResolution, setScreenResolution] = useState<ScreenResolution>( const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
SCREEN_RESOLUTIONS[0], // 기본값: Full HD SCREEN_RESOLUTIONS[0], // 기본값: Full HD
@@ -198,8 +171,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isPanning: false, isPanning: false,
startX: 0, startX: 0,
startY: 0, startY: 0,
scrollLeft: 0, outerScrollLeft: 0,
scrollTop: 0, outerScrollTop: 0,
innerScrollLeft: 0,
innerScrollTop: 0,
}); });
const canvasContainerRef = useRef<HTMLDivElement>(null); const canvasContainerRef = useRef<HTMLDivElement>(null);
@@ -1061,14 +1036,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
if (isPanMode && canvasContainerRef.current) { if (isPanMode) {
e.preventDefault(); e.preventDefault();
// 외부와 내부 스크롤 컨테이너 모두 저장
setPanState({ setPanState({
isPanning: true, isPanning: true,
startX: e.pageX, startX: e.pageX,
startY: e.pageY, startY: e.pageY,
scrollLeft: canvasContainerRef.current.scrollLeft, outerScrollLeft: canvasContainerRef.current?.scrollLeft || 0,
scrollTop: canvasContainerRef.current.scrollTop, outerScrollTop: canvasContainerRef.current?.scrollTop || 0,
innerScrollLeft: canvasRef.current?.scrollLeft || 0,
innerScrollTop: canvasRef.current?.scrollTop || 0,
}); });
// 드래그 중 커서 변경 // 드래그 중 커서 변경
document.body.style.cursor = "grabbing"; document.body.style.cursor = "grabbing";
@@ -1076,12 +1054,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (isPanMode && panState.isPanning && canvasContainerRef.current) { if (isPanMode && panState.isPanning) {
e.preventDefault(); e.preventDefault();
const dx = e.pageX - panState.startX; const dx = e.pageX - panState.startX;
const dy = e.pageY - panState.startY; const dy = e.pageY - panState.startY;
canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx;
canvasContainerRef.current.scrollTop = panState.scrollTop - dy; // 외부 컨테이너 스크롤
if (canvasContainerRef.current) {
canvasContainerRef.current.scrollLeft = panState.outerScrollLeft - dx;
canvasContainerRef.current.scrollTop = panState.outerScrollTop - dy;
}
// 내부 캔버스 스크롤
if (canvasRef.current) {
canvasRef.current.scrollLeft = panState.innerScrollLeft - dx;
canvasRef.current.scrollTop = panState.innerScrollTop - dy;
}
} }
}; };
@@ -1106,7 +1094,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("mouseup", handleMouseUp);
}; };
}, [isPanMode, panState.isPanning, panState.startX, panState.startY, panState.scrollLeft, panState.scrollTop]); }, [
isPanMode,
panState.isPanning,
panState.startX,
panState.startY,
panState.outerScrollLeft,
panState.outerScrollTop,
panState.innerScrollLeft,
panState.innerScrollTop,
]);
// 마우스 휠로 줌 제어 // 마우스 휠로 줌 제어
useEffect(() => { useEffect(() => {
@@ -3875,18 +3872,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
if (!selectedScreen) { if (!selectedScreen) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="bg-background flex h-full items-center justify-center">
<div className="text-center"> <div className="space-y-4 text-center">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" /> <div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
<h3 className="text-lg font-medium text-gray-900"> </h3> <Database className="text-muted-foreground h-8 w-8" />
<p className="text-gray-500"> .</p> </div>
<h3 className="text-foreground text-lg font-semibold"> </h3>
<p className="text-muted-foreground max-w-sm text-sm"> .</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="flex h-full w-full flex-col bg-gradient-to-br from-gray-50 to-slate-100"> <div className="bg-background flex h-full w-full flex-col">
{/* 상단 슬림 툴바 */} {/* 상단 슬림 툴바 */}
<SlimToolbar <SlimToolbar
screenName={selectedScreen?.screenName} screenName={selectedScreen?.screenName}
@@ -3895,7 +3894,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onBack={onBackToList} onBack={onBackToList}
onSave={handleSave} onSave={handleSave}
isSaving={isSaving} isSaving={isSaving}
onPreview={() => setShowResponsivePreview(true)}
/> />
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */} {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
@@ -3903,20 +3901,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<LeftUnifiedToolbar buttons={defaultToolbarButtons} panelStates={panelStates} onTogglePanel={togglePanel} /> <LeftUnifiedToolbar buttons={defaultToolbarButtons} panelStates={panelStates} onTogglePanel={togglePanel} />
{/* 열린 패널들 (좌측에서 우측으로 누적) */} {/* 열린 패널들 (좌측에서 우측으로 누적) */}
{panelStates.tables?.isOpen && ( {panelStates.components?.isOpen && (
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg"> <div className="border-border bg-card flex h-full w-[400px] flex-col border-r shadow-sm">
<div className="flex items-center justify-between border-b border-gray-200 p-3"> <div className="border-border flex items-center justify-between border-b px-6 py-4">
<h3 className="font-semibold text-gray-900"> </h3> <h3 className="text-foreground text-lg font-semibold"></h3>
<button onClick={() => closePanel("tables")} className="text-gray-400 hover:text-gray-600"> <button
onClick={() => closePanel("components")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-hidden">
<TablesPanel <ComponentsPanel
tables={filteredTables} tables={filteredTables}
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => { onTableDragStart={(e, table, column) => {
const dragData = { const dragData = {
type: column ? "column" : "table", type: column ? "column" : "table",
table, table,
@@ -3930,25 +3931,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</div> </div>
)} )}
{panelStates.components?.isOpen && (
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-3">
<h3 className="font-semibold text-gray-900"></h3>
<button onClick={() => closePanel("components")} className="text-gray-400 hover:text-gray-600">
</button>
</div>
<div className="flex-1 overflow-y-auto">
<ComponentsPanel />
</div>
</div>
)}
{panelStates.properties?.isOpen && ( {panelStates.properties?.isOpen && (
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg"> <div className="border-border bg-card flex h-full w-[400px] flex-col border-r shadow-sm">
<div className="flex items-center justify-between border-b border-gray-200 p-3"> <div className="border-border flex items-center justify-between border-b px-6 py-4">
<h3 className="font-semibold text-gray-900"></h3> <h3 className="text-foreground text-lg font-semibold"></h3>
<button onClick={() => closePanel("properties")} className="text-gray-400 hover:text-gray-600"> <button
onClick={() => closePanel("properties")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
</button> </button>
</div> </div>
@@ -3962,85 +3952,49 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
currentTable={tables.length > 0 ? tables[0] : undefined} currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName} currentTableName={selectedScreen?.tableName}
dragState={dragState} dragState={dragState}
onStyleChange={(style) => {
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, "style", style);
}
}}
currentResolution={screenResolution}
onResolutionChange={handleResolutionChange}
/> />
</div> </div>
</div> </div>
)} )}
{panelStates.styles?.isOpen && ( {/* 스타일과 해상도 패널은 속성 패널의 탭으로 통합됨 */}
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-3">
<h3 className="font-semibold text-gray-900"></h3>
<button onClick={() => closePanel("styles")} className="text-gray-400 hover:text-gray-600">
</button>
</div>
<div className="flex-1 overflow-y-auto">
{selectedComponent ? (
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(style) => {
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, "style", style);
}
}}
/>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
</div>
)}
</div>
</div>
)}
{panelStates.resolution?.isOpen && (
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-3">
<h3 className="font-semibold text-gray-900"></h3>
<button onClick={() => closePanel("resolution")} className="text-gray-400 hover:text-gray-600">
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<ResolutionPanel currentResolution={screenResolution} onResolutionChange={handleResolutionChange} />
</div>
</div>
)}
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */} {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
<div <div ref={canvasContainerRef} className="bg-muted relative flex-1 overflow-auto px-16 py-6">
ref={canvasContainerRef}
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
>
{/* Pan 모드 안내 - 제거됨 */} {/* Pan 모드 안내 - 제거됨 */}
{/* 줌 레벨 표시 */} {/* 줌 레벨 표시 */}
<div className="pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-lg ring-1 ring-gray-200"> <div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
🔍 {Math.round(zoomLevel * 100)}% 🔍 {Math.round(zoomLevel * 100)}%
</div> </div>
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
<div <div
className="mx-auto" className="flex justify-center"
style={{ style={{
width: screenResolution.width * zoomLevel, width: "100%",
height: Math.max(screenResolution.height, 800) * zoomLevel, minHeight: Math.max(screenResolution.height, 800) * zoomLevel,
}} }}
> >
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */} {/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */}
<div <div
className="bg-white shadow-lg" className="bg-background border-border border shadow-lg"
style={{ style={{
width: screenResolution.width, width: screenResolution.width,
height: Math.max(screenResolution.height, 800), // 최소 높이 보장 height: Math.max(screenResolution.height, 800), // 최소 높이 보장
minHeight: screenResolution.height, minHeight: screenResolution.height,
transform: `scale(${zoomLevel})`, // 줌 레벨에 따라 시각적으로 확대/축소 transform: `scale(${zoomLevel})`, // 줌 레벨에 따라 시각적으로 확대/축소
transformOrigin: "top center", transformOrigin: "top center",
transition: "transform 0.1s ease-out",
}} }}
> >
<div <div
ref={canvasRef} ref={canvasRef}
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20" className="bg-background relative h-full w-full overflow-auto"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
setSelectedComponent(null); setSelectedComponent(null);
@@ -4067,14 +4021,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{gridLines.map((line, index) => ( {gridLines.map((line, index) => (
<div <div
key={index} key={index}
className="pointer-events-none absolute" className="bg-border pointer-events-none absolute"
style={{ style={{
left: line.type === "vertical" ? `${line.position}px` : 0, left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0, top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%", width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%", height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#d1d5db", opacity: layout.gridSettings?.gridOpacity || 0.3,
opacity: layout.gridSettings?.gridOpacity || 0.5,
}} }}
/> />
))} ))}
@@ -4286,15 +4239,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 드래그 선택 영역 */} {/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && ( {selectionDrag.isSelecting && (
<div <div
className="pointer-events-none absolute" className="border-primary bg-primary/5 pointer-events-none absolute rounded-md border-2 border-dashed"
style={{ style={{
left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`, left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`,
top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`, top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`,
width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`, width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`,
height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`, height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`,
border: "2px dashed #3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.05)", // 매우 투명한 배경 (5%)
borderRadius: "4px",
}} }}
/> />
)} )}
@@ -4302,19 +4252,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 빈 캔버스 안내 */} {/* 빈 캔버스 안내 */}
{layout.components.length === 0 && ( {layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400"> <div className="max-w-2xl space-y-4 px-6 text-center">
<Database className="mx-auto mb-4 h-16 w-16" /> <div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
<h3 className="mb-2 text-xl font-medium"> </h3> <Database className="text-muted-foreground h-8 w-8" />
<p className="text-sm"> / 릿 </p> </div>
<p className="mt-2 text-xs"> <h3 className="text-foreground text-xl font-semibold"> </h3>
단축키: T(), M(릿), P(), S(), R(), D(), E() <p className="text-muted-foreground text-sm">
</p> / 릿
<p className="mt-1 text-xs">
편집: Ctrl+C(), Ctrl+V(), Ctrl+S(), Ctrl+Z(), Delete()
</p>
<p className="mt-1 text-xs text-amber-600">
</p> </p>
<div className="text-muted-foreground space-y-2 text-xs">
<p>
<span className="font-medium">:</span> T(), M(릿), P(), S(),
R(), D(), E()
</p>
<p>
<span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(),
Ctrl+Z(), Delete()
</p>
<p className="text-warning flex items-center justify-center gap-2">
<span></span>
<span> </span>
</p>
</div>
</div> </div>
</div> </div>
)} )}
@@ -4352,13 +4311,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
screenId={selectedScreen.screenId} screenId={selectedScreen.screenId}
/> />
)} )}
{/* 반응형 미리보기 모달 */}
<ResponsivePreviewModal
isOpen={showResponsivePreview}
onClose={() => setShowResponsivePreview(false)}
components={layout.components}
screenWidth={screenResolution.width}
/>
</div> </div>
); );
} }
+18 -18
View File
@@ -255,45 +255,45 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5"> <div className="max-w-[140px] space-y-0.5">
<Label htmlFor="fontWeight" className="text-xs font-medium"> <Label htmlFor="fontWeight" className="text-[10px] font-medium">
</Label> </Label>
<Select <Select
value={localStyle.fontWeight || "normal"} value={localStyle.fontWeight || "normal"}
onValueChange={(value) => handleStyleChange("fontWeight", value)} onValueChange={(value) => handleStyleChange("fontWeight", value)}
> >
<SelectTrigger className="h-8"> <SelectTrigger className="h-6 w-full text-[10px] px-2">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="normal"></SelectItem> <SelectItem value="normal" className="text-[10px]"></SelectItem>
<SelectItem value="bold"></SelectItem> <SelectItem value="bold" className="text-[10px]"></SelectItem>
<SelectItem value="100">100</SelectItem> <SelectItem value="100" className="text-[10px]">100</SelectItem>
<SelectItem value="400">400</SelectItem> <SelectItem value="400" className="text-[10px]">400</SelectItem>
<SelectItem value="500">500</SelectItem> <SelectItem value="500" className="text-[10px]">500</SelectItem>
<SelectItem value="600">600</SelectItem> <SelectItem value="600" className="text-[10px]">600</SelectItem>
<SelectItem value="700">700</SelectItem> <SelectItem value="700" className="text-[10px]">700</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <div className="max-w-[140px] space-y-0.5">
<Label htmlFor="textAlign" className="text-xs font-medium"> <Label htmlFor="textAlign" className="text-[10px] font-medium">
</Label> </Label>
<Select <Select
value={localStyle.textAlign || "left"} value={localStyle.textAlign || "left"}
onValueChange={(value) => handleStyleChange("textAlign", value)} onValueChange={(value) => handleStyleChange("textAlign", value)}
> >
<SelectTrigger className="h-8"> <SelectTrigger className="h-6 w-full text-[10px] px-2">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="left"></SelectItem> <SelectItem value="left" className="text-[10px]"></SelectItem>
<SelectItem value="center"></SelectItem> <SelectItem value="center" className="text-[10px]"></SelectItem>
<SelectItem value="right"></SelectItem> <SelectItem value="right" className="text-[10px]"></SelectItem>
<SelectItem value="justify"></SelectItem> <SelectItem value="justify" className="text-[10px]"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -6,13 +6,28 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component"; import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3 } from "lucide-react"; import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database } from "lucide-react";
import { TableInfo, ColumnInfo } from "@/types/screen";
import TablesPanel from "./TablesPanel";
interface ComponentsPanelProps { interface ComponentsPanelProps {
className?: string; className?: string;
// 테이블 관련 props
tables?: TableInfo[];
searchTerm?: string;
onSearchChange?: (value: string) => void;
onTableDragStart?: (e: React.DragEvent, table: TableInfo, column?: ColumnInfo) => void;
selectedTableName?: string;
} }
export function ComponentsPanel({ className }: ComponentsPanelProps) { export function ComponentsPanel({
className,
tables = [],
searchTerm = "",
onSearchChange,
onTableDragStart,
selectedTableName
}: ComponentsPanelProps) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// 레지스트리에서 모든 컴포넌트 조회 // 레지스트리에서 모든 컴포넌트 조회
@@ -160,7 +175,11 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
{/* 카테고리 탭 */} {/* 카테고리 탭 */}
<Tabs defaultValue="input" className="flex flex-1 flex-col"> <Tabs defaultValue="input" className="flex flex-1 flex-col">
<TabsList className="mb-3 grid h-8 w-full grid-cols-4"> <TabsList className="mb-3 grid h-8 w-full grid-cols-5">
<TabsTrigger value="tables" className="flex items-center gap-1 px-1 text-xs">
<Database className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="input" className="flex items-center gap-1 px-1 text-xs"> <TabsTrigger value="input" className="flex items-center gap-1 px-1 text-xs">
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
<span className="hidden sm:inline"></span> <span className="hidden sm:inline"></span>
@@ -179,6 +198,26 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* 테이블 탭 */}
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
{tables.length > 0 && onTableDragStart ? (
<TablesPanel
tables={tables}
searchTerm={searchTerm}
onSearchChange={onSearchChange || (() => {})}
onDragStart={onTableDragStart}
selectedTableName={selectedTableName}
/>
) : (
<div className="flex h-32 items-center justify-center text-center">
<div className="p-6">
<Database className="text-muted-foreground/40 mx-auto mb-2 h-10 w-10" />
<p className="text-muted-foreground text-xs font-medium"> </p>
</div>
</div>
)}
</TabsContent>
{/* 입력 컴포넌트 */} {/* 입력 컴포넌트 */}
<TabsContent value="input" className="mt-0 flex-1 space-y-2 overflow-y-auto"> <TabsContent value="input" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("input").length > 0 {getFilteredComponents("input").length > 0
@@ -926,7 +926,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
<div className="col-span-2"> <div className="col-span-2">
<Label htmlFor="height" className="text-sm font-medium"> <Label htmlFor="height" className="text-sm font-medium">
(40px ) (40px )
</Label> </Label>
<div className="mt-1 flex items-center space-x-2"> <div className="mt-1 flex items-center space-x-2">
<Input <Input
@@ -946,7 +946,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
<span className="text-sm text-gray-500"> = {localInputs.height || 40}px</span> <span className="text-sm text-gray-500"> = {localInputs.height || 40}px</span>
</div> </div>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
1 = 40px ( {Math.round((localInputs.height || 40) / 40)}) 1 = 40px ( {Math.round((localInputs.height || 40) / 40)}) -
</p> </p>
</div> </div>
</> </>
@@ -80,17 +80,6 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 현재 해상도 표시 */}
<div className="rounded-lg border bg-gray-50 p-3">
<div className="flex items-center space-x-2">
{getCategoryIcon(currentResolution.category)}
<span className="text-sm font-medium">{currentResolution.name}</span>
</div>
<div className="mt-1 text-xs text-gray-500">
{currentResolution.width} × {currentResolution.height}
</div>
</div>
{/* 프리셋 선택 */} {/* 프리셋 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>
@@ -1,7 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -10,7 +9,8 @@ import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown, Settings, Info, Database, Trash2, Copy } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette, Monitor } from "lucide-react";
import { import {
ComponentData, ComponentData,
WebType, WebType,
@@ -48,10 +48,11 @@ import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel"; import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel"; import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel"; import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
import { ResponsiveConfigPanel } from "./ResponsiveConfigPanel";
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel"; import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel"; import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import ResolutionPanel from "./ResolutionPanel";
interface UnifiedPropertiesPanelProps { interface UnifiedPropertiesPanelProps {
selectedComponent?: ComponentData; selectedComponent?: ComponentData;
@@ -62,6 +63,11 @@ interface UnifiedPropertiesPanelProps {
currentTable?: TableInfo; currentTable?: TableInfo;
currentTableName?: string; currentTableName?: string;
dragState?: any; dragState?: any;
// 스타일 관련
onStyleChange?: (style: any) => void;
// 해상도 관련
currentResolution?: { name: string; width: number; height: number };
onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void;
} }
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
@@ -73,9 +79,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
currentTable, currentTable,
currentTableName, currentTableName,
dragState, dragState,
onStyleChange,
currentResolution,
onResolutionChange,
}) => { }) => {
const { webTypes } = useWebTypes({ active: "Y" }); const { webTypes } = useWebTypes({ active: "Y" });
const [activeTab, setActiveTab] = useState("basic");
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>(""); const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
// 새로운 컴포넌트 시스템의 webType 동기화 // 새로운 컴포넌트 시스템의 webType 동기화
@@ -91,10 +99,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 컴포넌트가 선택되지 않았을 때 // 컴포넌트가 선택되지 않았을 때
if (!selectedComponent) { if (!selectedComponent) {
return ( return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center"> <div className="flex h-full flex-col items-center justify-center p-4 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-300" /> <Settings className="mb-2 h-8 w-8 text-gray-300" />
<p className="text-sm text-gray-500"> </p> <p className="text-[10px] text-gray-500"> </p>
<p className="text-sm text-gray-500"> </p> <p className="text-[10px] text-gray-500"> </p>
</div> </div>
); );
} }
@@ -164,119 +172,92 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const area = selectedComponent as AreaComponent; const area = selectedComponent as AreaComponent;
return ( return (
<div className="space-y-4"> <div className="space-y-1.5">
{/* 컴포넌트 정보 */} {/* 컴포넌트 정보 - 간소화 */}
<div className="rounded-lg bg-slate-50 p-3"> <div className="flex items-center justify-between rounded bg-muted px-2 py-1">
<div className="flex items-center justify-between"> <div className="flex items-center gap-1">
<div className="flex items-center space-x-2"> <Info className="h-2.5 w-2.5 text-muted-foreground" />
<Info className="h-4 w-4 text-slate-500" /> <span className="text-[10px] font-medium text-foreground">{selectedComponent.type}</span>
<span className="text-sm font-medium text-slate-700"> </span>
</div>
<Badge variant="secondary" className="text-xs">
{selectedComponent.type}
</Badge>
</div>
<div className="mt-2 space-y-1 text-xs text-slate-600">
<div>ID: {selectedComponent.id}</div>
{widget.widgetType && <div>: {widget.widgetType}</div>}
</div> </div>
<span className="text-[9px] text-muted-foreground">{selectedComponent.id.slice(0, 8)}</span>
</div> </div>
{/* 라벨 */} {/* 라벨 + 최소 높이 (같은 행) */}
<div> <div className="grid grid-cols-2 gap-1.5">
<Label></Label> <div className="space-y-0.5">
<Input <Label className="text-[10px]"></Label>
value={widget.label || ""} <Input
onChange={(e) => handleUpdate("label", e.target.value)} value={widget.label || ""}
placeholder="컴포넌트 라벨" onChange={(e) => handleUpdate("label", e.target.value)}
/> placeholder="라벨"
className="h-6 text-[10px]"
/>
</div>
<div className="space-y-0.5">
<Label className="text-[10px]"></Label>
<Input
type="number"
value={selectedComponent.size?.height || 0}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
const roundedValue = Math.max(40, Math.round(value / 40) * 40);
handleUpdate("size.height", roundedValue);
}}
step={40}
placeholder="40"
className="h-6 text-[10px]"
/>
</div>
</div> </div>
{/* Placeholder (widget만) */} {/* Placeholder (widget만) */}
{selectedComponent.type === "widget" && ( {selectedComponent.type === "widget" && (
<div> <div className="space-y-0.5">
<Label>Placeholder</Label> <Label className="text-[10px]">Placeholder</Label>
<Input <Input
value={widget.placeholder || ""} value={widget.placeholder || ""}
onChange={(e) => handleUpdate("placeholder", e.target.value)} onChange={(e) => handleUpdate("placeholder", e.target.value)}
placeholder="입력 안내 텍스트" placeholder="입력 안내 텍스트"
className="h-6 text-[10px]"
/> />
</div> </div>
)} )}
{/* Title (group/area) */} {/* Title (group/area) */}
{(selectedComponent.type === "group" || selectedComponent.type === "area") && ( {(selectedComponent.type === "group" || selectedComponent.type === "area") && (
<div> <div className="space-y-0.5">
<Label></Label> <Label className="text-[10px]"></Label>
<Input <Input
value={group.title || area.title || ""} value={group.title || area.title || ""}
onChange={(e) => handleUpdate("title", e.target.value)} onChange={(e) => handleUpdate("title", e.target.value)}
placeholder="제목" placeholder="제목"
className="h-6 text-[10px]"
/> />
</div> </div>
)} )}
{/* Description (area만) */} {/* Description (area만) */}
{selectedComponent.type === "area" && ( {selectedComponent.type === "area" && (
<div> <div className="space-y-0.5">
<Label></Label> <Label className="text-[10px]"></Label>
<Input <Input
value={area.description || ""} value={area.description || ""}
onChange={(e) => handleUpdate("description", e.target.value)} onChange={(e) => handleUpdate("description", e.target.value)}
placeholder="설명" placeholder="설명"
className="h-6 text-[10px]"
/> />
</div> </div>
)} )}
{/* 크기 */}
<div>
<Label> (px)</Label>
<Input
type="number"
value={selectedComponent.size?.height || 0}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
// 40 단위로 반올림
const roundedValue = Math.max(40, Math.round(value / 40) * 40);
handleUpdate("size.height", roundedValue);
}}
step={40}
placeholder="40 단위로 입력"
/>
<p className="mt-1 text-xs text-gray-500">40 </p>
</div>
{/* 컬럼 스팬 */}
{widget.columnSpan !== undefined && (
<div>
<Label> </Label>
<Select
value={widget.columnSpan?.toString() || "12"}
onValueChange={(value) => handleUpdate("columnSpan", parseInt(value))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_NUMBERS.map((span) => (
<SelectItem key={span} value={span.toString()}>
{span} ({Math.round((span / 12) * 100)}%)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Grid Columns */} {/* Grid Columns */}
{(selectedComponent as any).gridColumns !== undefined && ( {(selectedComponent as any).gridColumns !== undefined && (
<div> <div className="space-y-0.5">
<Label>Grid Columns</Label> <Label className="text-[10px]">Grid Columns</Label>
<Select <Select
value={((selectedComponent as any).gridColumns || 12).toString()} value={((selectedComponent as any).gridColumns || 12).toString()}
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))} onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
> >
<SelectTrigger> <SelectTrigger className="h-6 text-[10px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -432,8 +413,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<DataTableConfigPanel <DataTableConfigPanel
component={selectedComponent as DataTableComponent} component={selectedComponent as DataTableComponent}
tables={tables} tables={tables}
activeTab={activeTab}
onTabChange={setActiveTab}
onUpdateComponent={(updates) => { onUpdateComponent={(updates) => {
Object.entries(updates).forEach(([key, value]) => { Object.entries(updates).forEach(([key, value]) => {
handleUpdate(key, value); handleUpdate(key, value);
@@ -613,99 +592,80 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
); );
}; };
// 데이터 바인딩 탭
const renderDataTab = () => {
if (selectedComponent.type !== "widget") {
return (
<div className="flex h-full items-center justify-center p-8 text-center">
<p className="text-sm text-gray-500"> </p>
</div>
);
}
const widget = selectedComponent as WidgetComponent;
return (
<div className="space-y-4">
<div className="rounded-lg bg-blue-50 p-3">
<div className="flex items-center space-x-2">
<Database className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900"> </span>
</div>
</div>
{/* 테이블 컬럼 */}
<div>
<Label> </Label>
<Input
value={widget.columnName || ""}
onChange={(e) => handleUpdate("columnName", e.target.value)}
placeholder="컬럼명 입력"
/>
</div>
{/* 기본값 */}
<div>
<Label></Label>
<Input
value={widget.defaultValue || ""}
onChange={(e) => handleUpdate("defaultValue", e.target.value)}
placeholder="기본값 입력"
/>
</div>
</div>
);
};
return ( return (
<div className="flex h-full flex-col bg-white"> <div className="flex h-full flex-col bg-white">
{/* 헤더 */} {/* 헤더 - 간소화 */}
<div className="border-b border-gray-200 p-4"> <div className="border-b border-gray-200 px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-900"> </h3>
</div>
<Badge variant="outline">{selectedComponent.type}</Badge>
</div>
{selectedComponent.type === "widget" && ( {selectedComponent.type === "widget" && (
<div className="mt-2 text-xs text-gray-600"> <div className="text-[10px] text-gray-600 truncate">
{(selectedComponent as WidgetComponent).label || selectedComponent.id} {(selectedComponent as WidgetComponent).label || selectedComponent.id}
</div> </div>
)} )}
</div> </div>
{/* 탭 컨텐츠 */} {/* 탭 컨텐츠 */}
<div className="flex-1 overflow-hidden"> <Tabs defaultValue="properties" className="flex flex-1 flex-col overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col"> <TabsList className="grid h-7 w-full flex-shrink-0 grid-cols-2">
<TabsList className="w-full justify-start rounded-none border-b px-4"> <TabsTrigger value="properties" className="text-[10px]">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="detail"></TabsTrigger> </TabsTrigger>
<TabsTrigger value="data"></TabsTrigger> <TabsTrigger value="styles" className="text-[10px]">
<TabsTrigger value="responsive"></TabsTrigger> <Palette className="mr-0.5 h-2.5 w-2.5" />
</TabsList> &
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto"> {/* 속성 탭 */}
<TabsContent value="basic" className="m-0 p-4"> <TabsContent value="properties" className="mt-0 flex-1 overflow-y-auto p-2">
{renderBasicTab()} <div className="space-y-2 text-xs">
</TabsContent> {/* 기본 설정 */}
<TabsContent value="detail" className="m-0 p-4"> {renderBasicTab()}
{renderDetailTab()}
</TabsContent> {/* 상세 설정 통합 */}
<TabsContent value="data" className="m-0 p-4"> <Separator className="my-2" />
{renderDataTab()} {renderDetailTab()}
</TabsContent>
<TabsContent value="responsive" className="m-0 p-4">
<ResponsiveConfigPanel
component={selectedComponent}
onUpdate={(config) => {
onUpdateProperty(selectedComponent.id, "responsiveConfig", config);
}}
/>
</TabsContent>
</div> </div>
</Tabs> </TabsContent>
</div>
{/* 스타일 & 해상도 탭 */}
<TabsContent value="styles" className="mt-0 flex-1 overflow-y-auto">
<div className="space-y-2">
{/* 해상도 설정 */}
{currentResolution && onResolutionChange && (
<div className="border-b pb-2 px-2">
<ResolutionPanel
currentResolution={currentResolution}
onResolutionChange={onResolutionChange}
/>
</div>
)}
{/* 스타일 설정 */}
{selectedComponent ? (
<div>
<div className="mb-1.5 flex items-center gap-1.5 px-2">
<Palette className="h-3 w-3 text-primary" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(style) => {
if (onStyleChange) {
onStyleChange(style);
} else {
handleUpdate("style", style);
}
}}
/>
</div>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
</div>
)}
</div>
</TabsContent>
</Tabs>
</div> </div>
); );
}; };
@@ -65,51 +65,27 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
); );
}; };
// 기본 버튼 설정 // 기본 버튼 설정 (컴포넌트와 편집 2개)
export const defaultToolbarButtons: ToolbarButton[] = [ export const defaultToolbarButtons: ToolbarButton[] = [
// 입력/소스 그룹 // 컴포넌트 그룹 (테이블 + 컴포넌트 탭)
{
id: "tables",
label: "테이블",
icon: <Database className="h-5 w-5" />,
shortcut: "T",
group: "source",
panelWidth: 380,
},
{ {
id: "components", id: "components",
label: "컴포넌트", label: "컴포넌트",
icon: <Cog className="h-5 w-5" />, icon: <Layout className="h-5 w-5" />,
shortcut: "C", shortcut: "C",
group: "source", group: "source",
panelWidth: 350, panelWidth: 400,
}, },
// 편집/설정 그룹 // 편집 그룹 (속성 + 스타일 & 해상도 탭)
{ {
id: "properties", id: "properties",
label: "속성", label: "편집",
icon: <Settings className="h-5 w-5" />, icon: <Settings className="h-5 w-5" />,
shortcut: "P", shortcut: "P",
group: "editor", group: "editor",
panelWidth: 400, panelWidth: 400,
}, },
{
id: "styles",
label: "스타일",
icon: <Palette className="h-5 w-5" />,
shortcut: "S",
group: "editor",
panelWidth: 360,
},
{
id: "resolution",
label: "해상도",
icon: <Monitor className="h-5 w-5" />,
shortcut: "E",
group: "editor",
panelWidth: 300,
},
]; ];
export default LeftUnifiedToolbar; export default LeftUnifiedToolbar;
@@ -176,8 +176,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onSelectedRowsChange, onSelectedRowsChange,
refreshKey, refreshKey,
onConfigChange, onConfigChange,
...safeProps isPreview,
autoGeneration,
...restProps
} = props; } = props;
// DOM 안전한 props만 필터링
const safeProps = filterDOMProps(restProps);
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id; const fieldName = (component as any).columnName || component.id;
@@ -232,6 +237,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}; };
// 렌더러 props 구성 // 렌더러 props 구성
// component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리)
const { height: _height, ...styleWithoutHeight } = component.style || {};
const rendererProps = { const rendererProps = {
component, component,
isSelected, isSelected,
@@ -240,7 +248,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onDragEnd, onDragEnd,
size: component.size || newComponent.defaultSize, size: component.size || newComponent.defaultSize,
position: component.position, position: component.position,
style: component.style, style: styleWithoutHeight,
config: component.componentConfig, config: component.componentConfig,
componentConfig: component.componentConfig, componentConfig: component.componentConfig,
value: currentValue, // formData에서 추출한 현재 값 전달 value: currentValue, // formData에서 추출한 현재 값 전달
@@ -5,6 +5,7 @@ import { WebTypeRegistry } from "./WebTypeRegistry";
import { DynamicComponentProps } from "./types"; import { DynamicComponentProps } from "./types";
// import { getWidgetComponentByWebType, getWidgetComponentByName } from "@/components/screen/widgets/types"; // 임시 비활성화 // import { getWidgetComponentByWebType, getWidgetComponentByName } from "@/components/screen/widgets/types"; // 임시 비활성화
import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
/** /**
* 동적 웹타입 렌더러 컴포넌트 * 동적 웹타입 렌더러 컴포넌트
@@ -160,8 +161,10 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 기본 폴백: Input 컴포넌트 사용 // 기본 폴백: Input 컴포넌트 사용
const { Input } = require("@/components/ui/input"); const { Input } = require("@/components/ui/input");
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`); console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...props} />; const safeFallbackProps = filterDOMProps(props);
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />;
} catch (error) { } catch (error) {
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error); console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
return ( return (
@@ -597,8 +597,27 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
formData: _formData, formData: _formData,
onFormDataChange: _onFormDataChange, onFormDataChange: _onFormDataChange,
componentConfig: _componentConfig, componentConfig: _componentConfig,
...domProps autoGeneration: _autoGeneration,
hidden: _hidden,
isInModal: _isInModal,
isPreview: _isPreview,
originalData: _originalData,
allComponents: _allComponents,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,
refreshKey: _refreshKey,
onUpdateLayout: _onUpdateLayout,
onSelectedRowsChange: _onSelectedRowsChange,
onConfigChange: _onConfigChange,
onZoneClick: _onZoneClick,
selectedScreen: _selectedScreen,
onZoneComponentDrop: _onZoneComponentDrop,
...restProps
} = props; } = props;
// filterDOMProps import 추가 필요
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
const domProps = filterDOMProps(restProps);
// 사용할 아이템들 결정 (우선순위: 데이터소스 > 정적아이템 > 기본아이템) // 사용할 아이템들 결정 (우선순위: 데이터소스 > 정적아이템 > 기본아이템)
const finalItems = (() => { const finalItems = (() => {
@@ -186,7 +186,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// selectedRowsData, // selectedRowsData,
// }); // });
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) // 스타일 계산
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
const componentStyle: React.CSSProperties = { const componentStyle: React.CSSProperties = {
width: "100%", width: "100%",
height: "100%", height: "100%",
@@ -194,9 +195,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...style, ...style,
}; };
// 디자인 모드 스타일
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
if (isDesignMode) { if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderWidth = "1px";
componentStyle.borderStyle = "dashed";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
} }
@@ -483,8 +486,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
style={{ style={{
width: "100%", width: "100%",
height: "100%", height: "100%",
minHeight: "100%", minHeight: "40px",
maxHeight: "100%",
border: "none", border: "none",
borderRadius: "0.5rem", borderRadius: "0.5rem",
background: componentConfig.disabled background: componentConfig.disabled
@@ -179,6 +179,18 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
tableName: _tableName, tableName: _tableName,
onRefresh: _onRefresh, onRefresh: _onRefresh,
onClose: _onClose, onClose: _onClose,
autoGeneration: _autoGeneration,
hidden: _hidden,
isInModal: _isInModal,
isPreview: _isPreview,
originalData: _originalData,
allComponents: _allComponents,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,
refreshKey: _refreshKey,
onUpdateLayout: _onUpdateLayout,
onSelectedRowsChange: _onSelectedRowsChange,
onConfigChange: _onConfigChange,
...domProps ...domProps
} = props; } = props;
@@ -303,10 +315,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 이메일 타입 전용 UI // 이메일 타입 전용 UI
if (webType === "email") { if (webType === "email") {
return ( return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}> <div className={`flex w-full flex-col ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600"> <label className="mb-1.5 text-sm font-medium text-slate-600">
{component.label} {component.label}
{component.required && <span className="text-red-500">*</span>} {component.required && <span className="text-red-500">*</span>}
</label> </label>
@@ -405,10 +417,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 전화번호 타입 전용 UI // 전화번호 타입 전용 UI
if (webType === "tel") { if (webType === "tel") {
return ( return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}> <div className={`flex w-full flex-col ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600"> <label className="mb-1.5 text-sm font-medium text-slate-600">
{component.label} {component.label}
{component.required && <span className="text-red-500">*</span>} {component.required && <span className="text-red-500">*</span>}
</label> </label>
@@ -486,10 +498,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// URL 타입 전용 UI // URL 타입 전용 UI
if (webType === "url") { if (webType === "url") {
return ( return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}> <div className={`flex w-full flex-col ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600"> <label className="mb-1.5 text-sm font-medium text-slate-600">
{component.label} {component.label}
{component.required && <span className="text-red-500">*</span>} {component.required && <span className="text-red-500">*</span>}
</label> </label>
@@ -541,10 +553,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// textarea 타입인 경우 별도 렌더링 // textarea 타입인 경우 별도 렌더링
if (webType === "textarea") { if (webType === "textarea") {
return ( return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}> <div className={`flex w-full flex-col ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600"> <label className="mb-1.5 text-sm font-medium text-slate-600">
{component.label} {component.label}
{component.required && <span className="text-red-500">*</span>} {component.required && <span className="text-red-500">*</span>}
</label> </label>
@@ -582,10 +594,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
} }
return ( return (
<div className={`relative w-full max-w-full overflow-hidden ${className || ""}`} {...safeDomProps}> <div className={`flex w-full max-w-full flex-col ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600"> <label className="mb-1.5 text-sm font-medium text-slate-600">
{component.label} {component.label}
{component.required && <span className="text-red-500">*</span>} {component.required && <span className="text-red-500">*</span>}
</label> </label>
+32
View File
@@ -40,6 +40,7 @@ const REACT_ONLY_PROPS = new Set([
// 상태 관련 // 상태 관련
"mode", "mode",
"isInModal", "isInModal",
"isPreview",
// 테이블 관련 // 테이블 관련
"selectedRows", "selectedRows",
@@ -48,6 +49,9 @@ const REACT_ONLY_PROPS = new Set([
// 컴포넌트 기능 관련 // 컴포넌트 기능 관련
"autoGeneration", "autoGeneration",
"hidden", // 이미 SAFE_DOM_PROPS에 있지만 커스텀 구현을 위해 제외 "hidden", // 이미 SAFE_DOM_PROPS에 있지만 커스텀 구현을 위해 제외
// 필터링할 특수 속성
"readonly", // readOnly로 변환되므로 원본은 제거
]); ]);
// DOM에 안전하게 전달할 수 있는 표준 HTML 속성들 // DOM에 안전하게 전달할 수 있는 표준 HTML 속성들
@@ -67,6 +71,28 @@ const SAFE_DOM_PROPS = new Set([
"hidden", "hidden",
"spellCheck", "spellCheck",
"translate", "translate",
// 폼 관련 속성
"readOnly",
"disabled",
"required",
"placeholder",
"value",
"defaultValue",
"checked",
"defaultChecked",
"name",
"type",
"accept",
"autoComplete",
"autoFocus",
"multiple",
"pattern",
"min",
"max",
"step",
"minLength",
"maxLength",
// ARIA 속성 (aria-로 시작) // ARIA 속성 (aria-로 시작)
// data 속성 (data-로 시작) // data 속성 (data-로 시작)
@@ -115,6 +141,12 @@ export function filterDOMProps<T extends Record<string, any>>(props: T): Partial
continue; continue;
} }
// readonly → readOnly 변환 (React의 camelCase 규칙)
if (key === "readonly") {
filtered["readOnly" as keyof T] = value;
continue;
}
// aria- 또는 data- 속성은 안전하게 포함 // aria- 또는 data- 속성은 안전하게 포함
if (key.startsWith("aria-") || key.startsWith("data-")) { if (key.startsWith("aria-") || key.startsWith("data-")) {
filtered[key as keyof T] = value; filtered[key as keyof T] = value;