화면관리 ui개선 및 파일업로드 설정
This commit is contained in:
parent
5a8efa51af
commit
3d242c1c8e
|
|
@ -75,6 +75,8 @@ const getCorsOrigin = (): string[] | boolean => {
|
|||
"http://localhost:9771", // 로컬 개발 환경
|
||||
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
||||
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
||||
"https://v1.vexplor.com", // 운영 프론트엔드
|
||||
"https://api.vexplor.com", // 운영 백엔드
|
||||
];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ export default function ScreenViewPage() {
|
|||
webType={(() => {
|
||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||
if (isFileComponent(component)) {
|
||||
console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, {
|
||||
console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
originalWebType: component.webType,
|
||||
|
|
|
|||
|
|
@ -202,17 +202,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
setAssignmentSuccess(true);
|
||||
setAssignmentMessage(successMessage);
|
||||
|
||||
// 할당 완료 콜백 호출
|
||||
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
|
||||
if (onAssignmentComplete) {
|
||||
onAssignmentComplete();
|
||||
}
|
||||
|
||||
// 3초 후 자동으로 화면 목록으로 이동
|
||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||
setTimeout(() => {
|
||||
onClose(); // 모달 닫기
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
|
|
@ -232,17 +231,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
setAssignmentSuccess(true);
|
||||
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
|
||||
|
||||
// 할당 완료 콜백 호출
|
||||
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
|
||||
if (onAssignmentComplete) {
|
||||
onAssignmentComplete();
|
||||
}
|
||||
|
||||
// 3초 후 자동으로 화면 목록으로 이동
|
||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||
setTimeout(() => {
|
||||
onClose(); // 모달 닫기
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ const panelConfigs: PanelConfig[] = [
|
|||
id: "tables",
|
||||
title: "테이블 목록",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 380,
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "t",
|
||||
},
|
||||
|
|
@ -87,7 +87,7 @@ const panelConfigs: PanelConfig[] = [
|
|||
id: "components",
|
||||
title: "컴포넌트",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 350,
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "c",
|
||||
},
|
||||
|
|
@ -104,7 +104,7 @@ const panelConfigs: PanelConfig[] = [
|
|||
id: "styles",
|
||||
title: "스타일",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 360,
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "s",
|
||||
},
|
||||
|
|
@ -112,7 +112,7 @@ const panelConfigs: PanelConfig[] = [
|
|||
id: "resolution",
|
||||
title: "해상도",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 300,
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "e",
|
||||
},
|
||||
|
|
@ -129,7 +129,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
gap: 16,
|
||||
padding: 0,
|
||||
snapToGrid: true,
|
||||
showGrid: true,
|
||||
showGrid: false, // 기본값 false로 변경
|
||||
gridColor: "#d1d5db",
|
||||
gridOpacity: 0.5,
|
||||
},
|
||||
|
|
@ -955,7 +955,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
gap: 16,
|
||||
padding: 0, // padding은 항상 0으로 강제
|
||||
snapToGrid: true,
|
||||
showGrid: true,
|
||||
showGrid: false, // 기본값 false로 변경
|
||||
gridColor: "#d1d5db",
|
||||
gridOpacity: 0.5,
|
||||
},
|
||||
|
|
@ -989,7 +989,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, [selectedScreen?.screenId]);
|
||||
|
||||
// 스페이스바 키 이벤트 처리 (Pan 모드)
|
||||
// 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// 입력 필드에서는 스페이스바 무시
|
||||
|
|
@ -1001,10 +1001,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
|
||||
if (!isPanMode) {
|
||||
setIsPanMode(true);
|
||||
// 커서 변경
|
||||
if (canvasContainerRef.current) {
|
||||
canvasContainerRef.current.style.cursor = "grab";
|
||||
}
|
||||
// body에 커서 스타일 추가
|
||||
document.body.style.cursor = "grab";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1014,21 +1012,58 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
|
||||
setIsPanMode(false);
|
||||
setPanState((prev) => ({ ...prev, isPanning: false }));
|
||||
// 커서 복원
|
||||
if (canvasContainerRef.current) {
|
||||
canvasContainerRef.current.style.cursor = "default";
|
||||
}
|
||||
// body 커서 스타일 복원
|
||||
document.body.style.cursor = "default";
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (isPanMode && canvasContainerRef.current) {
|
||||
e.preventDefault();
|
||||
setPanState({
|
||||
isPanning: true,
|
||||
startX: e.pageX,
|
||||
startY: e.pageY,
|
||||
scrollLeft: canvasContainerRef.current.scrollLeft,
|
||||
scrollTop: canvasContainerRef.current.scrollTop,
|
||||
});
|
||||
// 드래그 중 커서 변경
|
||||
document.body.style.cursor = "grabbing";
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isPanMode && panState.isPanning && canvasContainerRef.current) {
|
||||
e.preventDefault();
|
||||
const dx = e.pageX - panState.startX;
|
||||
const dy = e.pageY - panState.startY;
|
||||
canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx;
|
||||
canvasContainerRef.current.scrollTop = panState.scrollTop - dy;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isPanMode) {
|
||||
setPanState((prev) => ({ ...prev, isPanning: false }));
|
||||
// 드래그 종료 시 커서 복원
|
||||
document.body.style.cursor = "grab";
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isPanMode]);
|
||||
}, [isPanMode, panState.isPanning, panState.startX, panState.startY, panState.scrollLeft, panState.scrollTop]);
|
||||
|
||||
// 마우스 휠로 줌 제어
|
||||
useEffect(() => {
|
||||
|
|
@ -1206,7 +1241,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
if (!selectedScreen?.screenId) {
|
||||
console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen);
|
||||
toast.error("화면 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
|
@ -1215,23 +1254,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
...layout,
|
||||
screenResolution: screenResolution,
|
||||
};
|
||||
console.log("💾 저장할 레이아웃 데이터:", {
|
||||
console.log("💾 저장 시작:", {
|
||||
screenId: selectedScreen.screenId,
|
||||
componentsCount: layoutWithResolution.components.length,
|
||||
gridSettings: layoutWithResolution.gridSettings,
|
||||
screenResolution: layoutWithResolution.screenResolution,
|
||||
});
|
||||
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
|
||||
console.log("✅ 저장 성공! 메뉴 할당 모달 열기");
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
|
||||
// 저장 성공 후 메뉴 할당 모달 열기
|
||||
setShowMenuAssignmentModal(true);
|
||||
} catch (error) {
|
||||
// console.error("저장 실패:", error);
|
||||
console.error("❌ 저장 실패:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [selectedScreen?.screenId, layout, screenResolution]);
|
||||
}, [selectedScreen, layout, screenResolution]);
|
||||
|
||||
// 템플릿 드래그 처리
|
||||
const handleTemplateDrop = useCallback(
|
||||
|
|
@ -1861,6 +1904,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
webType: component.webType,
|
||||
category: component.category,
|
||||
defaultConfig: component.defaultConfig,
|
||||
defaultSize: component.defaultSize,
|
||||
});
|
||||
|
||||
// 컴포넌트별 gridColumns 설정 및 크기 계산
|
||||
|
|
@ -1875,15 +1919,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
if (isCardDisplay) {
|
||||
gridColumns = 8;
|
||||
} else if (isTableList) {
|
||||
gridColumns = 1;
|
||||
gridColumns = 12; // 테이블은 전체 너비
|
||||
} else {
|
||||
// 일반 컴포넌트: defaultSize.width를 기준으로 그리드 컬럼 수 계산
|
||||
// 그리드가 활성화된 경우에만
|
||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||||
const columnWidth = gridInfo.columnWidth + gridInfo.gap;
|
||||
const estimatedColumns = Math.round(component.defaultSize.width / columnWidth);
|
||||
gridColumns = Math.max(1, Math.min(12, estimatedColumns)); // 1-12 범위로 제한
|
||||
}
|
||||
// 웹타입별 적절한 그리드 컬럼 수 설정
|
||||
const webType = component.webType;
|
||||
const componentId = component.id;
|
||||
|
||||
// 웹타입별 기본 컬럼 수 매핑
|
||||
const gridColumnsMap: Record<string, number> = {
|
||||
// 입력 컴포넌트 (INPUT 카테고리)
|
||||
"text-input": 4, // 텍스트 입력 (33%)
|
||||
"number-input": 2, // 숫자 입력 (16.67%)
|
||||
"email-input": 4, // 이메일 입력 (33%)
|
||||
"tel-input": 3, // 전화번호 입력 (25%)
|
||||
"date-input": 3, // 날짜 입력 (25%)
|
||||
"datetime-input": 4, // 날짜시간 입력 (33%)
|
||||
"time-input": 2, // 시간 입력 (16.67%)
|
||||
"textarea-basic": 6, // 텍스트 영역 (50%)
|
||||
"select-basic": 3, // 셀렉트 (25%)
|
||||
"checkbox-basic": 2, // 체크박스 (16.67%)
|
||||
"radio-basic": 3, // 라디오 (25%)
|
||||
"file-basic": 4, // 파일 (33%)
|
||||
|
||||
// 표시 컴포넌트 (DISPLAY 카테고리)
|
||||
"label-basic": 2, // 라벨 (16.67%)
|
||||
"text-display": 3, // 텍스트 표시 (25%)
|
||||
"card-display": 8, // 카드 (66.67%)
|
||||
"badge-basic": 1, // 배지 (8.33%)
|
||||
"alert-basic": 6, // 알림 (50%)
|
||||
"divider-basic": 12, // 구분선 (100%)
|
||||
|
||||
// 액션 컴포넌트 (ACTION 카테고리)
|
||||
"button-basic": 1, // 버튼 (8.33%)
|
||||
"button-primary": 1, // 프라이머리 버튼 (8.33%)
|
||||
"button-secondary": 1, // 세컨더리 버튼 (8.33%)
|
||||
"icon-button": 1, // 아이콘 버튼 (8.33%)
|
||||
|
||||
// 레이아웃 컴포넌트
|
||||
"container-basic": 6, // 컨테이너 (50%)
|
||||
"section-basic": 12, // 섹션 (100%)
|
||||
"panel-basic": 6, // 패널 (50%)
|
||||
|
||||
// 기타
|
||||
"image-basic": 4, // 이미지 (33%)
|
||||
"icon-basic": 1, // 아이콘 (8.33%)
|
||||
"progress-bar": 4, // 프로그레스 바 (33%)
|
||||
"chart-basic": 6, // 차트 (50%)
|
||||
};
|
||||
|
||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
||||
|
||||
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
||||
componentId,
|
||||
webType,
|
||||
gridColumns,
|
||||
});
|
||||
}
|
||||
|
||||
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
|
||||
|
|
@ -1914,6 +2005,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
});
|
||||
}
|
||||
|
||||
console.log("🎨 최종 컴포넌트 크기:", {
|
||||
componentId: component.id,
|
||||
componentName: component.name,
|
||||
defaultSize: component.defaultSize,
|
||||
finalSize: componentSize,
|
||||
gridColumns,
|
||||
});
|
||||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
|
|
@ -2098,6 +2197,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return defaultColumns;
|
||||
};
|
||||
|
||||
// 웹타입별 기본 높이 계산
|
||||
const getDefaultHeight = (widgetType: string): number => {
|
||||
const heightMap: Record<string, number> = {
|
||||
textarea: 120, // 텍스트 영역은 3줄 (40 * 3)
|
||||
checkbox: 80, // 체크박스 그룹 (40 * 2)
|
||||
radio: 80, // 라디오 버튼 (40 * 2)
|
||||
file: 240, // 파일 업로드 (40 * 6)
|
||||
};
|
||||
|
||||
return heightMap[widgetType] || 40; // 기본값 40
|
||||
};
|
||||
|
||||
// 웹타입별 기본 설정 생성
|
||||
const getDefaultWebTypeConfig = (widgetType: string) => {
|
||||
switch (widgetType) {
|
||||
|
|
@ -2282,7 +2393,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: 40 },
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
gridColumns: calculatedGridColumns,
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
|
|
@ -2345,7 +2456,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
readonly: false,
|
||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: 40 },
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
gridColumns: calculatedGridColumns,
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
|
|
@ -3088,7 +3199,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
gap: 16,
|
||||
padding: 0,
|
||||
snapToGrid: true,
|
||||
showGrid: true,
|
||||
showGrid: false,
|
||||
gridColor: "#d1d5db",
|
||||
gridOpacity: 0.5,
|
||||
},
|
||||
|
|
@ -3649,7 +3760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
{/* 열린 패널들 (좌측에서 우측으로 누적) */}
|
||||
{panelStates.tables?.isOpen && (
|
||||
<div className="flex h-full w-[380px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<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("tables")} className="text-gray-400 hover:text-gray-600">
|
||||
|
|
@ -3676,7 +3787,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
)}
|
||||
|
||||
{panelStates.components?.isOpen && (
|
||||
<div className="flex h-full w-[350px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<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">
|
||||
|
|
@ -3713,7 +3824,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
)}
|
||||
|
||||
{panelStates.styles?.isOpen && (
|
||||
<div className="flex h-full w-[360px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<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">
|
||||
|
|
@ -3740,7 +3851,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
)}
|
||||
|
||||
{panelStates.resolution?.isOpen && (
|
||||
<div className="flex h-full w-[300px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<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">
|
||||
|
|
@ -3757,38 +3868,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<div
|
||||
ref={canvasContainerRef}
|
||||
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
|
||||
style={{ cursor: isPanMode ? (panState.isPanning ? "grabbing" : "grab") : "default" }}
|
||||
onMouseDown={(e) => {
|
||||
if (isPanMode && canvasContainerRef.current) {
|
||||
e.preventDefault();
|
||||
setPanState({
|
||||
isPanning: true,
|
||||
startX: e.pageX,
|
||||
startY: e.pageY,
|
||||
scrollLeft: canvasContainerRef.current.scrollLeft,
|
||||
scrollTop: canvasContainerRef.current.scrollTop,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (isPanMode && panState.isPanning && canvasContainerRef.current) {
|
||||
e.preventDefault();
|
||||
const dx = e.pageX - panState.startX;
|
||||
const dy = e.pageY - panState.startY;
|
||||
canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx;
|
||||
canvasContainerRef.current.scrollTop = panState.scrollTop - dy;
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (isPanMode) {
|
||||
setPanState((prev) => ({ ...prev, isPanning: false }));
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (isPanMode) {
|
||||
setPanState((prev) => ({ ...prev, isPanning: false }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Pan 모드 안내 */}
|
||||
{isPanMode && (
|
||||
|
|
@ -3816,7 +3895,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20" // 미묘한 그라데이션 배경
|
||||
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
|
||||
setSelectedComponent(null);
|
||||
|
|
@ -4102,9 +4181,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
{/* 메뉴 할당 모달 */}
|
||||
{showMenuAssignmentModal && selectedScreen && (
|
||||
<MenuAssignmentModal
|
||||
screenId={selectedScreen.screenId}
|
||||
screenInfo={selectedScreen}
|
||||
isOpen={showMenuAssignmentModal}
|
||||
onClose={() => setShowMenuAssignmentModal(false)}
|
||||
onAssignmentComplete={() => {
|
||||
// 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함
|
||||
// setShowMenuAssignmentModal(false);
|
||||
// toast.success("메뉴에 화면이 할당되었습니다.");
|
||||
}}
|
||||
onBackToList={onBackToList}
|
||||
/>
|
||||
)}
|
||||
{/* 파일첨부 상세 모달 */}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Palette, Type, Square, Box, Eye, RotateCcw } from "lucide-react";
|
||||
import { Palette, Type, Square, Box } from "lucide-react";
|
||||
import { ComponentStyle } from "@/types/screen";
|
||||
|
||||
interface StyleEditorProps {
|
||||
|
|
@ -30,243 +27,218 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onStyleChange(newStyle);
|
||||
};
|
||||
|
||||
const resetStyle = () => {
|
||||
const resetStyle: ComponentStyle = {};
|
||||
setLocalStyle(resetStyle);
|
||||
onStyleChange(resetStyle);
|
||||
};
|
||||
|
||||
const applyStyle = () => {
|
||||
onStyleChange(localStyle);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Palette className="h-4 w-4" />
|
||||
스타일 편집
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={resetStyle}>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button size="sm" onClick={applyStyle}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
적용
|
||||
</Button>
|
||||
<div className={`space-y-6 p-4 ${className}`}>
|
||||
{/* 여백 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box className="h-4 w-4 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-900">여백</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin">외부 여백</Label>
|
||||
<Input
|
||||
id="margin"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.margin || ""}
|
||||
onChange={(e) => handleStyleChange("margin", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding">내부 여백</Label>
|
||||
<Input
|
||||
id="padding"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.padding || ""}
|
||||
onChange={(e) => handleStyleChange("padding", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="spacing" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="spacing">
|
||||
<Box className="mr-1 h-3 w-3" />
|
||||
여백
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="border">
|
||||
<Square className="mr-1 h-3 w-3" />
|
||||
테두리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="background">
|
||||
<Palette className="mr-1 h-3 w-3" />
|
||||
배경
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="typography">
|
||||
<Type className="mr-1 h-3 w-3" />
|
||||
텍스트
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 여백 탭 */}
|
||||
<TabsContent value="spacing" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin">외부 여백</Label>
|
||||
<Input
|
||||
id="margin"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.margin || ""}
|
||||
onChange={(e) => handleStyleChange("margin", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding">내부 여백</Label>
|
||||
<Input
|
||||
id="padding"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.padding || ""}
|
||||
onChange={(e) => handleStyleChange("padding", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gap">간격</Label>
|
||||
<Input
|
||||
id="gap"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.gap || ""}
|
||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gap">간격</Label>
|
||||
<Input
|
||||
id="gap"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.gap || ""}
|
||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{/* 테두리 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="h-4 w-4 text-green-600" />
|
||||
<h3 className="font-semibold text-gray-900">테두리</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderWidth">테두리 두께</Label>
|
||||
<Input
|
||||
id="borderWidth"
|
||||
type="text"
|
||||
placeholder="1px, 2px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderStyle">테두리 스타일</Label>
|
||||
<Select
|
||||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">파선</SelectItem>
|
||||
<SelectItem value="dotted">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테두리 탭 */}
|
||||
<TabsContent value="border" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderWidth">테두리 두께</Label>
|
||||
<Input
|
||||
id="borderWidth"
|
||||
type="text"
|
||||
placeholder="1px, 2px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderStyle">테두리 스타일</Label>
|
||||
<Select
|
||||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">파선</SelectItem>
|
||||
<SelectItem value="dotted">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderColor">테두리 색상</Label>
|
||||
<Input
|
||||
id="borderColor"
|
||||
type="color"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="text"
|
||||
placeholder="5px, 10px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderColor">테두리 색상</Label>
|
||||
<Input
|
||||
id="borderColor"
|
||||
type="color"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="text"
|
||||
placeholder="5px, 10px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{/* 배경 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="font-semibold text-gray-900">배경</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor">배경 색상</Label>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경 탭 */}
|
||||
<TabsContent value="background" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor">배경 색상</Label>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundImage">배경 이미지</Label>
|
||||
<Input
|
||||
id="backgroundImage"
|
||||
type="text"
|
||||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundImage">배경 이미지</Label>
|
||||
<Input
|
||||
id="backgroundImage"
|
||||
type="text"
|
||||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{/* 텍스트 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4 text-orange-600" />
|
||||
<h3 className="font-semibold text-gray-900">텍스트</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">텍스트 색상</Label>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontSize">글자 크기</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
type="text"
|
||||
placeholder="14px, 1rem"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 탭 */}
|
||||
<TabsContent value="typography" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">텍스트 색상</Label>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontSize">글자 크기</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
type="text"
|
||||
placeholder="14px, 1rem"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontWeight">글자 굵기</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="600">600</SelectItem>
|
||||
<SelectItem value="700">700</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="textAlign">텍스트 정렬</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
<SelectItem value="justify">양쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontWeight">글자 굵기</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="600">600</SelectItem>
|
||||
<SelectItem value="700">700</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="textAlign">텍스트 정렬</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
<SelectItem value="justify">양쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,23 +227,21 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={widget.width || 0}
|
||||
onChange={(e) => handleUpdate("width", parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={widget.height || 0}
|
||||
onChange={(e) => handleUpdate("height", parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 컬럼 스팬 */}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Database, Layout, Cog, Settings, Palette, Monitor, Square } from "lucide-react";
|
||||
import { Database, Layout, Cog, Settings, Palette, Monitor } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ToolbarButton {
|
||||
|
|
@ -110,14 +110,6 @@ export const defaultToolbarButtons: ToolbarButton[] = [
|
|||
group: "editor",
|
||||
panelWidth: 300,
|
||||
},
|
||||
{
|
||||
id: "zone",
|
||||
label: "구역",
|
||||
icon: <Square className="h-5 w-5" />,
|
||||
shortcut: "Z",
|
||||
group: "editor",
|
||||
panelWidth: 0, // 토글만 (패널 없음)
|
||||
},
|
||||
];
|
||||
|
||||
export default LeftUnifiedToolbar;
|
||||
|
|
|
|||
|
|
@ -58,27 +58,56 @@ export const usePanelState = (panels: PanelConfig[]) => {
|
|||
});
|
||||
}, [panels]);
|
||||
|
||||
// 패널 토글
|
||||
// 패널 토글 (다른 패널 자동 닫기)
|
||||
const togglePanel = useCallback((panelId: string) => {
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
isOpen: !prev[panelId]?.isOpen,
|
||||
},
|
||||
}));
|
||||
setPanelStates((prev) => {
|
||||
const isCurrentlyOpen = prev[panelId]?.isOpen;
|
||||
const newStates = { ...prev };
|
||||
|
||||
// 다른 모든 패널 닫기
|
||||
Object.keys(newStates).forEach((id) => {
|
||||
if (id !== panelId) {
|
||||
newStates[id] = {
|
||||
...newStates[id],
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 현재 패널 토글
|
||||
newStates[panelId] = {
|
||||
...newStates[panelId],
|
||||
isOpen: !isCurrentlyOpen,
|
||||
};
|
||||
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 패널 열기
|
||||
// 패널 열기 (다른 패널 자동 닫기)
|
||||
const openPanel = useCallback((panelId: string) => {
|
||||
// console.log("📂 패널 열기:", panelId);
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
setPanelStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
|
||||
// 다른 모든 패널 닫기
|
||||
Object.keys(newStates).forEach((id) => {
|
||||
if (id !== panelId) {
|
||||
newStates[id] = {
|
||||
...newStates[id],
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 현재 패널 열기
|
||||
newStates[panelId] = {
|
||||
...newStates[panelId],
|
||||
isOpen: true,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 패널 닫기
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
checked={checkedValues.includes(option.value)}
|
||||
onChange={(e) => handleGroupChange(option.value, e.target.checked)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
|
|
@ -146,13 +146,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
|
||||
// checkbox (기본 체크박스)
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"flex h-full w-full cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
<label className="flex h-full w-full cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const CheckboxBasicDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 150, height: 32 },
|
||||
defaultSize: { width: 150, height: 120 }, // 40 * 3 (3개 옵션)
|
||||
configPanel: CheckboxBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ import { API_BASE_URL } from "@/lib/api/client";
|
|||
|
||||
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
|
||||
const loadOfficeLibrariesFromCDN = async () => {
|
||||
if (typeof window === 'undefined') return { XLSX: null, mammoth: null };
|
||||
if (typeof window === "undefined") return { XLSX: null, mammoth: null };
|
||||
|
||||
try {
|
||||
// XLSX 라이브러리가 이미 로드되어 있는지 확인
|
||||
if (!(window as any).XLSX) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
|
|
@ -28,8 +28,8 @@ const loadOfficeLibrariesFromCDN = async () => {
|
|||
// mammoth 라이브러리가 이미 로드되어 있는지 확인
|
||||
if (!(window as any).mammoth) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js';
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
|
|
@ -38,10 +38,10 @@ const loadOfficeLibrariesFromCDN = async () => {
|
|||
|
||||
return {
|
||||
XLSX: (window as any).XLSX,
|
||||
mammoth: (window as any).mammoth
|
||||
mammoth: (window as any).mammoth,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Office 라이브러리 CDN 로드 실패:', error);
|
||||
console.error("Office 라이브러리 CDN 로드 실패:", error);
|
||||
return { XLSX: null, mammoth: null };
|
||||
}
|
||||
};
|
||||
|
|
@ -57,13 +57,7 @@ interface FileViewerModalProps {
|
|||
/**
|
||||
* 파일 뷰어 모달 컴포넌트
|
||||
*/
|
||||
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||
file,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}) => {
|
||||
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -86,7 +80,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
<div>
|
||||
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
|
||||
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
|
||||
${result.value || '내용을 읽을 수 없습니다.'}
|
||||
${result.value || "내용을 읽을 수 없습니다."}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -96,12 +90,12 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
|
||||
// Excel 문서 렌더링
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
const html = XLSX.utils.sheet_to_html(worksheet, {
|
||||
table: { className: 'excel-table' }
|
||||
table: { className: "excel-table" },
|
||||
});
|
||||
|
||||
const htmlContent = `
|
||||
|
|
@ -195,16 +189,31 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
// 미리보기 지원 파일 타입 정의
|
||||
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
|
||||
const documentExtensions = ["pdf","doc", "docx", "xls", "xlsx", "ppt", "pptx", "rtf", "odt", "ods", "odp", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"];
|
||||
const documentExtensions = [
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"rtf",
|
||||
"odt",
|
||||
"ods",
|
||||
"odp",
|
||||
"hwp",
|
||||
"hwpx",
|
||||
"hwpml",
|
||||
"hcdt",
|
||||
"hpt",
|
||||
"pages",
|
||||
"numbers",
|
||||
"keynote",
|
||||
];
|
||||
const textExtensions = ["txt", "md", "json", "xml", "csv"];
|
||||
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
|
||||
|
||||
const supportedExtensions = [
|
||||
...imageExtensions,
|
||||
...documentExtensions,
|
||||
...textExtensions,
|
||||
...mediaExtensions
|
||||
];
|
||||
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
|
||||
|
||||
if (supportedExtensions.includes(fileExt)) {
|
||||
// 이미지나 PDF는 인증된 요청으로 Blob 생성
|
||||
|
|
@ -213,7 +222,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
// 인증된 요청으로 파일 데이터 가져오기
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("authToken")}`,
|
||||
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -236,7 +245,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -306,24 +315,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
const renderPreview = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||
<p className="text-sm text-center">{previewError}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<div className="flex h-96 flex-col items-center justify-center">
|
||||
<AlertTriangle className="mb-4 h-16 w-16 text-yellow-500" />
|
||||
<p className="mb-2 text-lg font-medium">미리보기 불가</p>
|
||||
<p className="text-center text-sm">{previewError}</p>
|
||||
<Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
파일 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -335,11 +340,11 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
// 이미지 파일
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center max-h-96 overflow-hidden">
|
||||
<div className="flex max-h-96 items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={previewUrl || ""}
|
||||
alt={file.realFileName}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
|
||||
onError={(e) => {
|
||||
console.error("이미지 로드 오류:", previewUrl, e);
|
||||
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
|
||||
|
|
@ -358,100 +363,83 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
<div className="h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
className="h-full w-full rounded-lg border"
|
||||
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF 파일
|
||||
// PDF 파일 - 브라우저 기본 뷰어 사용
|
||||
if (fileExt === "pdf") {
|
||||
return (
|
||||
<div className="h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
onError={() => setPreviewError("PDF 파일을 불러올 수 없습니다.")}
|
||||
/>
|
||||
<div className="h-[600px] overflow-auto rounded-lg border bg-gray-50">
|
||||
<object
|
||||
data={previewUrl || ""}
|
||||
type="application/pdf"
|
||||
className="h-full w-full rounded-lg"
|
||||
title="PDF Viewer"
|
||||
>
|
||||
<iframe src={previewUrl || ""} className="h-full w-full rounded-lg" title="PDF Viewer Fallback">
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<FileText className="mb-4 h-16 w-16 text-gray-400" />
|
||||
<p className="mb-2 text-lg font-medium">PDF를 표시할 수 없습니다</p>
|
||||
<p className="mb-4 text-center text-sm text-gray-600">
|
||||
브라우저가 PDF 표시를 지원하지 않습니다. 다운로드하여 확인해주세요.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => onDownload?.(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</iframe>
|
||||
</object>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe)
|
||||
// Office 문서 - 모든 Office 문서는 다운로드 권장
|
||||
if (
|
||||
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"].includes(fileExt)
|
||||
[
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"hwp",
|
||||
"hwpx",
|
||||
"hwpml",
|
||||
"hcdt",
|
||||
"hpt",
|
||||
"pages",
|
||||
"numbers",
|
||||
"keynote",
|
||||
].includes(fileExt)
|
||||
) {
|
||||
// CDN 라이브러리로 렌더링된 콘텐츠가 있는 경우
|
||||
if (renderedContent) {
|
||||
return (
|
||||
<div className="relative h-96 overflow-auto">
|
||||
<div
|
||||
className="w-full h-full p-4 border rounded-lg bg-white"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// iframe 방식 (fallback)
|
||||
// Office 문서 안내 메시지 표시
|
||||
return (
|
||||
<div className="relative h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
onError={() => {
|
||||
console.log("iframe 오류 발생, fallback 옵션 제공");
|
||||
setPreviewError("이 Office 문서는 브라우저에서 직접 미리보기할 수 없습니다. 다운로드하여 확인해주세요.");
|
||||
}}
|
||||
title={`${file.realFileName} 미리보기`}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Office 문서를 처리하는 중...</p>
|
||||
<p className="text-xs text-gray-400 mt-1">잠시만 기다려주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오류 발생 시 fallback 옵션 */}
|
||||
{previewError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white">
|
||||
<FileText className="w-16 h-16 mb-4 text-orange-500" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 제한</p>
|
||||
<p className="text-sm text-center mb-4 text-gray-600">
|
||||
{previewError}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// 새 탭에서 파일 열기 시도
|
||||
const link = document.createElement('a');
|
||||
link.href = previewUrl || '';
|
||||
link.target = '_blank';
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
새 탭에서 열기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex h-96 flex-col items-center justify-center overflow-auto rounded-lg border bg-gradient-to-br from-blue-50 to-indigo-50 p-8">
|
||||
<FileText className="mb-6 h-20 w-20 text-blue-500" />
|
||||
<h3 className="mb-2 text-xl font-semibold text-gray-800">Office 문서</h3>
|
||||
<p className="mb-6 max-w-md text-center text-sm text-gray-600">
|
||||
{fileExt === "docx" || fileExt === "doc"
|
||||
? "Word 문서"
|
||||
: fileExt === "xlsx" || fileExt === "xls"
|
||||
? "Excel 문서"
|
||||
: fileExt === "pptx" || fileExt === "ppt"
|
||||
? "PowerPoint 문서"
|
||||
: "Office 문서"}
|
||||
는 브라우저에서 미리보기가 지원되지 않습니다.
|
||||
<br />
|
||||
다운로드하여 확인해주세요.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
다운로드하여 열기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -460,11 +448,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
if (["mp4", "webm", "ogg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<video
|
||||
controls
|
||||
className="w-full max-h-96"
|
||||
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
|
||||
>
|
||||
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
|
||||
<source src={previewUrl || ""} type={`video/${fileExt}`} />
|
||||
</video>
|
||||
</div>
|
||||
|
|
@ -474,9 +458,9 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
// 오디오 파일
|
||||
if (["mp3", "wav", "ogg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
||||
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<div className="flex h-96 flex-col items-center justify-center">
|
||||
<div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
|
||||
|
|
@ -484,11 +468,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
className="w-full max-w-md"
|
||||
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
|
||||
>
|
||||
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
|
||||
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
|
||||
</audio>
|
||||
</div>
|
||||
|
|
@ -497,17 +477,12 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
// 기타 파일 타입
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<FileText className="w-16 h-16 mb-4 text-gray-400" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||
<p className="text-sm text-center mb-4">
|
||||
{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<div className="flex h-96 flex-col items-center justify-center">
|
||||
<FileText className="mb-4 h-16 w-16 text-gray-400" />
|
||||
<p className="mb-2 text-lg font-medium">미리보기 불가</p>
|
||||
<p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.</p>
|
||||
<Button variant="outline" onClick={() => onDownload?.(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
파일 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -516,61 +491,49 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto [&>button]:hidden">
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<DialogTitle className="text-lg font-semibold truncate">
|
||||
{file.realFileName}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{file.fileExt.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
파일 크기: {formatFileSize(file.size)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||
파일 크기: {formatFileSize(file.fileSize || file.size || 0)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">{renderPreview()}</div>
|
||||
|
||||
{/* 파일 정보 및 액션 버튼 */}
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
|
||||
<span>크기: {formatFileSize(file.size)}</span>
|
||||
{file.uploadedAt && (
|
||||
<span>업로드: {new Date(file.uploadedAt).toLocaleString()}</span>
|
||||
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>크기: {formatFileSize(file.fileSize || file.size || 0)}</span>
|
||||
{(file.uploadedAt || file.regdate) && (
|
||||
<span>업로드: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<div className="flex justify-end space-x-2 border-t pt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => onDelete(file)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const FileUploadDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 350, height: 40 },
|
||||
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
|
||||
configPanel: FileUploadConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -84,13 +84,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
// radio-horizontal: 가로 배치
|
||||
if (webType === "radio-horizontal") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row gap-4 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-4">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
|
|
@ -100,7 +94,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
|
|
@ -112,13 +106,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
// radio-vertical: 세로 배치
|
||||
if (webType === "radio-vertical") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
|
|
@ -128,7 +116,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
|
|
@ -139,14 +127,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
|
||||
// radio (기본 라디오 - direction 설정 따름)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
componentConfig.direction === "horizontal" ? "flex-row" : "flex-col",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex gap-3", componentConfig.direction === "horizontal" ? "flex-row" : "flex-col")}>
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
|
|
@ -157,7 +138,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
required={componentConfig.required || false}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const RadioBasicDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 150, height: 32 },
|
||||
defaultSize: { width: 150, height: 80 }, // 40 * 2 (2개 옵션)
|
||||
configPanel: RadioBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
Loading…
Reference in New Issue