화면관리 ui개선 및 파일업로드 설정
This commit is contained in:
parent
5a8efa51af
commit
3d242c1c8e
|
|
@ -75,6 +75,8 @@ const getCorsOrigin = (): string[] | boolean => {
|
||||||
"http://localhost:9771", // 로컬 개발 환경
|
"http://localhost:9771", // 로컬 개발 환경
|
||||||
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
||||||
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
||||||
|
"https://v1.vexplor.com", // 운영 프론트엔드
|
||||||
|
"https://api.vexplor.com", // 운영 백엔드
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -340,7 +340,7 @@ export default function ScreenViewPage() {
|
||||||
webType={(() => {
|
webType={(() => {
|
||||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||||
if (isFileComponent(component)) {
|
if (isFileComponent(component)) {
|
||||||
console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, {
|
console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
componentType: component.type,
|
componentType: component.type,
|
||||||
originalWebType: component.webType,
|
originalWebType: component.webType,
|
||||||
|
|
|
||||||
|
|
@ -202,17 +202,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
setAssignmentSuccess(true);
|
setAssignmentSuccess(true);
|
||||||
setAssignmentMessage(successMessage);
|
setAssignmentMessage(successMessage);
|
||||||
|
|
||||||
// 할당 완료 콜백 호출
|
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
|
||||||
if (onAssignmentComplete) {
|
if (onAssignmentComplete) {
|
||||||
onAssignmentComplete();
|
onAssignmentComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3초 후 자동으로 화면 목록으로 이동
|
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
onClose(); // 모달 닫기
|
||||||
if (onBackToList) {
|
if (onBackToList) {
|
||||||
onBackToList();
|
onBackToList();
|
||||||
} else {
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -232,17 +231,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
setAssignmentSuccess(true);
|
setAssignmentSuccess(true);
|
||||||
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
|
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
|
||||||
|
|
||||||
// 할당 완료 콜백 호출
|
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
|
||||||
if (onAssignmentComplete) {
|
if (onAssignmentComplete) {
|
||||||
onAssignmentComplete();
|
onAssignmentComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3초 후 자동으로 화면 목록으로 이동
|
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
onClose(); // 모달 닫기
|
||||||
if (onBackToList) {
|
if (onBackToList) {
|
||||||
onBackToList();
|
onBackToList();
|
||||||
} else {
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ const panelConfigs: PanelConfig[] = [
|
||||||
id: "tables",
|
id: "tables",
|
||||||
title: "테이블 목록",
|
title: "테이블 목록",
|
||||||
defaultPosition: "left",
|
defaultPosition: "left",
|
||||||
defaultWidth: 380,
|
defaultWidth: 400,
|
||||||
defaultHeight: 700,
|
defaultHeight: 700,
|
||||||
shortcutKey: "t",
|
shortcutKey: "t",
|
||||||
},
|
},
|
||||||
|
|
@ -87,7 +87,7 @@ const panelConfigs: PanelConfig[] = [
|
||||||
id: "components",
|
id: "components",
|
||||||
title: "컴포넌트",
|
title: "컴포넌트",
|
||||||
defaultPosition: "left",
|
defaultPosition: "left",
|
||||||
defaultWidth: 350,
|
defaultWidth: 400,
|
||||||
defaultHeight: 700,
|
defaultHeight: 700,
|
||||||
shortcutKey: "c",
|
shortcutKey: "c",
|
||||||
},
|
},
|
||||||
|
|
@ -104,7 +104,7 @@ const panelConfigs: PanelConfig[] = [
|
||||||
id: "styles",
|
id: "styles",
|
||||||
title: "스타일",
|
title: "스타일",
|
||||||
defaultPosition: "left",
|
defaultPosition: "left",
|
||||||
defaultWidth: 360,
|
defaultWidth: 400,
|
||||||
defaultHeight: 700,
|
defaultHeight: 700,
|
||||||
shortcutKey: "s",
|
shortcutKey: "s",
|
||||||
},
|
},
|
||||||
|
|
@ -112,7 +112,7 @@ const panelConfigs: PanelConfig[] = [
|
||||||
id: "resolution",
|
id: "resolution",
|
||||||
title: "해상도",
|
title: "해상도",
|
||||||
defaultPosition: "left",
|
defaultPosition: "left",
|
||||||
defaultWidth: 300,
|
defaultWidth: 400,
|
||||||
defaultHeight: 700,
|
defaultHeight: 700,
|
||||||
shortcutKey: "e",
|
shortcutKey: "e",
|
||||||
},
|
},
|
||||||
|
|
@ -129,7 +129,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
gap: 16,
|
gap: 16,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
snapToGrid: true,
|
snapToGrid: true,
|
||||||
showGrid: true,
|
showGrid: false, // 기본값 false로 변경
|
||||||
gridColor: "#d1d5db",
|
gridColor: "#d1d5db",
|
||||||
gridOpacity: 0.5,
|
gridOpacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|
@ -955,7 +955,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
gap: 16,
|
gap: 16,
|
||||||
padding: 0, // padding은 항상 0으로 강제
|
padding: 0, // padding은 항상 0으로 강제
|
||||||
snapToGrid: true,
|
snapToGrid: true,
|
||||||
showGrid: true,
|
showGrid: false, // 기본값 false로 변경
|
||||||
gridColor: "#d1d5db",
|
gridColor: "#d1d5db",
|
||||||
gridOpacity: 0.5,
|
gridOpacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|
@ -989,7 +989,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId]);
|
}, [selectedScreen?.screenId]);
|
||||||
|
|
||||||
// 스페이스바 키 이벤트 처리 (Pan 모드)
|
// 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// 입력 필드에서는 스페이스바 무시
|
// 입력 필드에서는 스페이스바 무시
|
||||||
|
|
@ -1001,10 +1001,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
|
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
|
||||||
if (!isPanMode) {
|
if (!isPanMode) {
|
||||||
setIsPanMode(true);
|
setIsPanMode(true);
|
||||||
// 커서 변경
|
// body에 커서 스타일 추가
|
||||||
if (canvasContainerRef.current) {
|
document.body.style.cursor = "grab";
|
||||||
canvasContainerRef.current.style.cursor = "grab";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1014,21 +1012,58 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
|
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
|
||||||
setIsPanMode(false);
|
setIsPanMode(false);
|
||||||
setPanState((prev) => ({ ...prev, isPanning: false }));
|
setPanState((prev) => ({ ...prev, isPanning: false }));
|
||||||
// 커서 복원
|
// body 커서 스타일 복원
|
||||||
if (canvasContainerRef.current) {
|
document.body.style.cursor = "default";
|
||||||
canvasContainerRef.current.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("keydown", handleKeyDown);
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
window.addEventListener("mousedown", handleMouseDown);
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1206,7 +1241,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 저장
|
// 저장
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!selectedScreen?.screenId) return;
|
if (!selectedScreen?.screenId) {
|
||||||
|
console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen);
|
||||||
|
toast.error("화면 정보가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
@ -1215,23 +1254,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
...layout,
|
...layout,
|
||||||
screenResolution: screenResolution,
|
screenResolution: screenResolution,
|
||||||
};
|
};
|
||||||
console.log("💾 저장할 레이아웃 데이터:", {
|
console.log("💾 저장 시작:", {
|
||||||
|
screenId: selectedScreen.screenId,
|
||||||
componentsCount: layoutWithResolution.components.length,
|
componentsCount: layoutWithResolution.components.length,
|
||||||
gridSettings: layoutWithResolution.gridSettings,
|
gridSettings: layoutWithResolution.gridSettings,
|
||||||
screenResolution: layoutWithResolution.screenResolution,
|
screenResolution: layoutWithResolution.screenResolution,
|
||||||
});
|
});
|
||||||
|
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
|
|
||||||
|
console.log("✅ 저장 성공! 메뉴 할당 모달 열기");
|
||||||
toast.success("화면이 저장되었습니다.");
|
toast.success("화면이 저장되었습니다.");
|
||||||
|
|
||||||
// 저장 성공 후 메뉴 할당 모달 열기
|
// 저장 성공 후 메뉴 할당 모달 열기
|
||||||
setShowMenuAssignmentModal(true);
|
setShowMenuAssignmentModal(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("저장 실패:", error);
|
console.error("❌ 저장 실패:", error);
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId, layout, screenResolution]);
|
}, [selectedScreen, layout, screenResolution]);
|
||||||
|
|
||||||
// 템플릿 드래그 처리
|
// 템플릿 드래그 처리
|
||||||
const handleTemplateDrop = useCallback(
|
const handleTemplateDrop = useCallback(
|
||||||
|
|
@ -1861,6 +1904,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
webType: component.webType,
|
webType: component.webType,
|
||||||
category: component.category,
|
category: component.category,
|
||||||
defaultConfig: component.defaultConfig,
|
defaultConfig: component.defaultConfig,
|
||||||
|
defaultSize: component.defaultSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 컴포넌트별 gridColumns 설정 및 크기 계산
|
// 컴포넌트별 gridColumns 설정 및 크기 계산
|
||||||
|
|
@ -1875,15 +1919,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
if (isCardDisplay) {
|
if (isCardDisplay) {
|
||||||
gridColumns = 8;
|
gridColumns = 8;
|
||||||
} else if (isTableList) {
|
} else if (isTableList) {
|
||||||
gridColumns = 1;
|
gridColumns = 12; // 테이블은 전체 너비
|
||||||
} else {
|
} else {
|
||||||
// 일반 컴포넌트: defaultSize.width를 기준으로 그리드 컬럼 수 계산
|
// 웹타입별 적절한 그리드 컬럼 수 설정
|
||||||
// 그리드가 활성화된 경우에만
|
const webType = component.webType;
|
||||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
const componentId = component.id;
|
||||||
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 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에 맞춰 너비 재계산
|
// 그리드 시스템이 활성화된 경우 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 = {
|
const newComponent: ComponentData = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||||
|
|
@ -2098,6 +2197,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return defaultColumns;
|
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) => {
|
const getDefaultWebTypeConfig = (widgetType: string) => {
|
||||||
switch (widgetType) {
|
switch (widgetType) {
|
||||||
|
|
@ -2282,7 +2393,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: 40 },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
gridColumns: calculatedGridColumns,
|
gridColumns: calculatedGridColumns,
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
|
|
@ -2345,7 +2456,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
readonly: false,
|
readonly: false,
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: 40 },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
gridColumns: calculatedGridColumns,
|
gridColumns: calculatedGridColumns,
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
|
|
@ -3088,7 +3199,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
gap: 16,
|
gap: 16,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
snapToGrid: true,
|
snapToGrid: true,
|
||||||
showGrid: true,
|
showGrid: false,
|
||||||
gridColor: "#d1d5db",
|
gridColor: "#d1d5db",
|
||||||
gridOpacity: 0.5,
|
gridOpacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|
@ -3649,7 +3760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
{/* 열린 패널들 (좌측에서 우측으로 누적) */}
|
{/* 열린 패널들 (좌측에서 우측으로 누적) */}
|
||||||
{panelStates.tables?.isOpen && (
|
{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">
|
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||||
<h3 className="font-semibold text-gray-900">테이블 목록</h3>
|
<h3 className="font-semibold text-gray-900">테이블 목록</h3>
|
||||||
<button onClick={() => closePanel("tables")} className="text-gray-400 hover:text-gray-600">
|
<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 && (
|
{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">
|
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||||
<h3 className="font-semibold text-gray-900">컴포넌트</h3>
|
<h3 className="font-semibold text-gray-900">컴포넌트</h3>
|
||||||
<button onClick={() => closePanel("components")} className="text-gray-400 hover:text-gray-600">
|
<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 && (
|
{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">
|
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||||
<h3 className="font-semibold text-gray-900">스타일</h3>
|
<h3 className="font-semibold text-gray-900">스타일</h3>
|
||||||
<button onClick={() => closePanel("styles")} className="text-gray-400 hover:text-gray-600">
|
<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 && (
|
{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">
|
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||||
<h3 className="font-semibold text-gray-900">해상도</h3>
|
<h3 className="font-semibold text-gray-900">해상도</h3>
|
||||||
<button onClick={() => closePanel("resolution")} className="text-gray-400 hover:text-gray-600">
|
<button onClick={() => closePanel("resolution")} className="text-gray-400 hover:text-gray-600">
|
||||||
|
|
@ -3757,38 +3868,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
<div
|
<div
|
||||||
ref={canvasContainerRef}
|
ref={canvasContainerRef}
|
||||||
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
|
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 모드 안내 */}
|
{/* Pan 모드 안내 */}
|
||||||
{isPanMode && (
|
{isPanMode && (
|
||||||
|
|
@ -3816,7 +3895,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
>
|
>
|
||||||
<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="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
|
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
|
||||||
setSelectedComponent(null);
|
setSelectedComponent(null);
|
||||||
|
|
@ -4102,9 +4181,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
{/* 메뉴 할당 모달 */}
|
{/* 메뉴 할당 모달 */}
|
||||||
{showMenuAssignmentModal && selectedScreen && (
|
{showMenuAssignmentModal && selectedScreen && (
|
||||||
<MenuAssignmentModal
|
<MenuAssignmentModal
|
||||||
screenId={selectedScreen.screenId}
|
screenInfo={selectedScreen}
|
||||||
isOpen={showMenuAssignmentModal}
|
isOpen={showMenuAssignmentModal}
|
||||||
onClose={() => setShowMenuAssignmentModal(false)}
|
onClose={() => setShowMenuAssignmentModal(false)}
|
||||||
|
onAssignmentComplete={() => {
|
||||||
|
// 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함
|
||||||
|
// setShowMenuAssignmentModal(false);
|
||||||
|
// toast.success("메뉴에 화면이 할당되었습니다.");
|
||||||
|
}}
|
||||||
|
onBackToList={onBackToList}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 파일첨부 상세 모달 */}
|
{/* 파일첨부 상세 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { 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";
|
import { ComponentStyle } from "@/types/screen";
|
||||||
|
|
||||||
interface StyleEditorProps {
|
interface StyleEditorProps {
|
||||||
|
|
@ -30,243 +27,218 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||||
onStyleChange(newStyle);
|
onStyleChange(newStyle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetStyle = () => {
|
|
||||||
const resetStyle: ComponentStyle = {};
|
|
||||||
setLocalStyle(resetStyle);
|
|
||||||
onStyleChange(resetStyle);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyStyle = () => {
|
|
||||||
onStyleChange(localStyle);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-4 ${className}`}>
|
<div className={`space-y-6 p-4 ${className}`}>
|
||||||
<Card>
|
{/* 여백 섹션 */}
|
||||||
<CardHeader className="pb-3">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
<Box className="h-4 w-4 text-blue-600" />
|
||||||
<Palette className="h-4 w-4" />
|
<h3 className="font-semibold text-gray-900">여백</h3>
|
||||||
스타일 편집
|
</div>
|
||||||
</CardTitle>
|
<Separator />
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-4">
|
||||||
<Button variant="outline" size="sm" onClick={resetStyle}>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<RotateCcw className="mr-1 h-3 w-3" />
|
<div className="space-y-2">
|
||||||
초기화
|
<Label htmlFor="margin">외부 여백</Label>
|
||||||
</Button>
|
<Input
|
||||||
<Button size="sm" onClick={applyStyle}>
|
id="margin"
|
||||||
<Eye className="mr-1 h-3 w-3" />
|
type="text"
|
||||||
적용
|
placeholder="10px, 1rem"
|
||||||
</Button>
|
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>
|
</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>
|
|
||||||
|
|
||||||
{/* 여백 탭 */}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<TabsContent value="spacing" className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Label htmlFor="gap">간격</Label>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="margin">외부 여백</Label>
|
id="gap"
|
||||||
<Input
|
type="text"
|
||||||
id="margin"
|
placeholder="10px, 1rem"
|
||||||
type="text"
|
value={localStyle.gap || ""}
|
||||||
placeholder="10px, 1rem"
|
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||||
value={localStyle.margin || ""}
|
/>
|
||||||
onChange={(e) => handleStyleChange("margin", e.target.value)}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<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">
|
<div className="space-y-4">
|
||||||
<Label htmlFor="gap">간격</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Square className="h-4 w-4 text-green-600" />
|
||||||
id="gap"
|
<h3 className="font-semibold text-gray-900">테두리</h3>
|
||||||
type="text"
|
</div>
|
||||||
placeholder="10px, 1rem"
|
<Separator />
|
||||||
value={localStyle.gap || ""}
|
<div className="space-y-4">
|
||||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
/>
|
<div className="space-y-2">
|
||||||
</div>
|
<Label htmlFor="borderWidth">테두리 두께</Label>
|
||||||
</div>
|
<Input
|
||||||
</TabsContent>
|
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">
|
||||||
<TabsContent value="border" className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Label htmlFor="borderColor">테두리 색상</Label>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="borderWidth">테두리 두께</Label>
|
id="borderColor"
|
||||||
<Input
|
type="color"
|
||||||
id="borderWidth"
|
value={localStyle.borderColor || "#000000"}
|
||||||
type="text"
|
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||||
placeholder="1px, 2px"
|
/>
|
||||||
value={localStyle.borderWidth || ""}
|
</div>
|
||||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
<div className="space-y-2">
|
||||||
/>
|
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
||||||
</div>
|
<Input
|
||||||
<div className="space-y-2">
|
id="borderRadius"
|
||||||
<Label htmlFor="borderStyle">테두리 스타일</Label>
|
type="text"
|
||||||
<Select
|
placeholder="5px, 10px"
|
||||||
value={localStyle.borderStyle || "solid"}
|
value={localStyle.borderRadius || ""}
|
||||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||||
>
|
/>
|
||||||
<SelectTrigger>
|
</div>
|
||||||
<SelectValue />
|
</div>
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
</div>
|
||||||
<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">
|
<div className="space-y-4">
|
||||||
<Label htmlFor="borderColor">테두리 색상</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Palette className="h-4 w-4 text-purple-600" />
|
||||||
id="borderColor"
|
<h3 className="font-semibold text-gray-900">배경</h3>
|
||||||
type="color"
|
</div>
|
||||||
value={localStyle.borderColor || "#000000"}
|
<Separator />
|
||||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
<div className="space-y-4">
|
||||||
/>
|
<div className="space-y-2">
|
||||||
</div>
|
<Label htmlFor="backgroundColor">배경 색상</Label>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
id="backgroundColor"
|
||||||
<Input
|
type="color"
|
||||||
id="borderRadius"
|
value={localStyle.backgroundColor || "#ffffff"}
|
||||||
type="text"
|
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||||
placeholder="5px, 10px"
|
/>
|
||||||
value={localStyle.borderRadius || ""}
|
</div>
|
||||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 배경 탭 */}
|
<div className="space-y-2">
|
||||||
<TabsContent value="background" className="space-y-4">
|
<Label htmlFor="backgroundImage">배경 이미지</Label>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="backgroundColor">배경 색상</Label>
|
id="backgroundImage"
|
||||||
<Input
|
type="text"
|
||||||
id="backgroundColor"
|
placeholder="url('image.jpg')"
|
||||||
type="color"
|
value={localStyle.backgroundImage || ""}
|
||||||
value={localStyle.backgroundColor || "#ffffff"}
|
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* 텍스트 섹션 */}
|
||||||
<Label htmlFor="backgroundImage">배경 이미지</Label>
|
<div className="space-y-4">
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
id="backgroundImage"
|
<Type className="h-4 w-4 text-orange-600" />
|
||||||
type="text"
|
<h3 className="font-semibold text-gray-900">텍스트</h3>
|
||||||
placeholder="url('image.jpg')"
|
</div>
|
||||||
value={localStyle.backgroundImage || ""}
|
<Separator />
|
||||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
<div className="space-y-4">
|
||||||
/>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
</TabsContent>
|
<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">
|
||||||
<TabsContent value="typography" className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Label htmlFor="fontWeight">글자 굵기</Label>
|
||||||
<div className="space-y-2">
|
<Select
|
||||||
<Label htmlFor="color">텍스트 색상</Label>
|
value={localStyle.fontWeight || "normal"}
|
||||||
<Input
|
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||||
id="color"
|
>
|
||||||
type="color"
|
<SelectTrigger>
|
||||||
value={localStyle.color || "#000000"}
|
<SelectValue />
|
||||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
</SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
</div>
|
<SelectItem value="normal">보통</SelectItem>
|
||||||
<div className="space-y-2">
|
<SelectItem value="bold">굵게</SelectItem>
|
||||||
<Label htmlFor="fontSize">글자 크기</Label>
|
<SelectItem value="100">100</SelectItem>
|
||||||
<Input
|
<SelectItem value="400">400</SelectItem>
|
||||||
id="fontSize"
|
<SelectItem value="500">500</SelectItem>
|
||||||
type="text"
|
<SelectItem value="600">600</SelectItem>
|
||||||
placeholder="14px, 1rem"
|
<SelectItem value="700">700</SelectItem>
|
||||||
value={localStyle.fontSize || ""}
|
</SelectContent>
|
||||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
</Select>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
</div>
|
<Label htmlFor="textAlign">텍스트 정렬</Label>
|
||||||
|
<Select
|
||||||
<div className="grid grid-cols-2 gap-4">
|
value={localStyle.textAlign || "left"}
|
||||||
<div className="space-y-2">
|
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||||
<Label htmlFor="fontWeight">글자 굵기</Label>
|
>
|
||||||
<Select
|
<SelectTrigger>
|
||||||
value={localStyle.fontWeight || "normal"}
|
<SelectValue />
|
||||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<SelectTrigger>
|
<SelectItem value="left">왼쪽</SelectItem>
|
||||||
<SelectValue />
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
</SelectTrigger>
|
<SelectItem value="right">오른쪽</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="justify">양쪽</SelectItem>
|
||||||
<SelectItem value="normal">보통</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="bold">굵게</SelectItem>
|
</Select>
|
||||||
<SelectItem value="100">100</SelectItem>
|
</div>
|
||||||
<SelectItem value="400">400</SelectItem>
|
</div>
|
||||||
<SelectItem value="500">500</SelectItem>
|
</div>
|
||||||
<SelectItem value="600">600</SelectItem>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,23 +227,21 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 크기 */}
|
{/* 크기 */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div>
|
||||||
<div>
|
<Label>높이 (px)</Label>
|
||||||
<Label>너비 (px)</Label>
|
<Input
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
value={selectedComponent.size?.height || 0}
|
||||||
value={widget.width || 0}
|
onChange={(e) => {
|
||||||
onChange={(e) => handleUpdate("width", parseInt(e.target.value) || 0)}
|
const value = parseInt(e.target.value) || 0;
|
||||||
/>
|
// 40 단위로 반올림
|
||||||
</div>
|
const roundedValue = Math.max(40, Math.round(value / 40) * 40);
|
||||||
<div>
|
handleUpdate("size.height", roundedValue);
|
||||||
<Label>높이 (px)</Label>
|
}}
|
||||||
<Input
|
step={40}
|
||||||
type="number"
|
placeholder="40 단위로 입력"
|
||||||
value={widget.height || 0}
|
/>
|
||||||
onChange={(e) => handleUpdate("height", parseInt(e.target.value) || 0)}
|
<p className="mt-1 text-xs text-gray-500">40 단위로 자동 조정됩니다</p>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 스팬 */}
|
{/* 컬럼 스팬 */}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface ToolbarButton {
|
export interface ToolbarButton {
|
||||||
|
|
@ -110,14 +110,6 @@ export const defaultToolbarButtons: ToolbarButton[] = [
|
||||||
group: "editor",
|
group: "editor",
|
||||||
panelWidth: 300,
|
panelWidth: 300,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "zone",
|
|
||||||
label: "구역",
|
|
||||||
icon: <Square className="h-5 w-5" />,
|
|
||||||
shortcut: "Z",
|
|
||||||
group: "editor",
|
|
||||||
panelWidth: 0, // 토글만 (패널 없음)
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default LeftUnifiedToolbar;
|
export default LeftUnifiedToolbar;
|
||||||
|
|
|
||||||
|
|
@ -58,27 +58,56 @@ export const usePanelState = (panels: PanelConfig[]) => {
|
||||||
});
|
});
|
||||||
}, [panels]);
|
}, [panels]);
|
||||||
|
|
||||||
// 패널 토글
|
// 패널 토글 (다른 패널 자동 닫기)
|
||||||
const togglePanel = useCallback((panelId: string) => {
|
const togglePanel = useCallback((panelId: string) => {
|
||||||
setPanelStates((prev) => ({
|
setPanelStates((prev) => {
|
||||||
...prev,
|
const isCurrentlyOpen = prev[panelId]?.isOpen;
|
||||||
[panelId]: {
|
const newStates = { ...prev };
|
||||||
...prev[panelId],
|
|
||||||
isOpen: !prev[panelId]?.isOpen,
|
// 다른 모든 패널 닫기
|
||||||
},
|
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) => {
|
const openPanel = useCallback((panelId: string) => {
|
||||||
// console.log("📂 패널 열기:", panelId);
|
// console.log("📂 패널 열기:", panelId);
|
||||||
setPanelStates((prev) => ({
|
setPanelStates((prev) => {
|
||||||
...prev,
|
const newStates = { ...prev };
|
||||||
[panelId]: {
|
|
||||||
...prev[panelId],
|
// 다른 모든 패널 닫기
|
||||||
|
Object.keys(newStates).forEach((id) => {
|
||||||
|
if (id !== panelId) {
|
||||||
|
newStates[id] = {
|
||||||
|
...newStates[id],
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 패널 열기
|
||||||
|
newStates[panelId] = {
|
||||||
|
...newStates[panelId],
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
},
|
};
|
||||||
}));
|
|
||||||
|
return newStates;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 패널 닫기
|
// 패널 닫기
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
||||||
checked={checkedValues.includes(option.value)}
|
checked={checkedValues.includes(option.value)}
|
||||||
onChange={(e) => handleGroupChange(option.value, e.target.checked)}
|
onChange={(e) => handleGroupChange(option.value, e.target.checked)}
|
||||||
disabled={componentConfig.disabled || isDesignMode}
|
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>
|
<span className="text-sm text-gray-900">{option.label}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -146,13 +146,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
||||||
|
|
||||||
// checkbox (기본 체크박스)
|
// checkbox (기본 체크박스)
|
||||||
return (
|
return (
|
||||||
<label
|
<label className="flex h-full w-full cursor-pointer items-center gap-3">
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const CheckboxBasicDefinition = createComponentDefinition({
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
placeholder: "입력하세요",
|
||||||
},
|
},
|
||||||
defaultSize: { width: 150, height: 32 },
|
defaultSize: { width: 150, height: 120 }, // 40 * 3 (3개 옵션)
|
||||||
configPanel: CheckboxBasicConfigPanel,
|
configPanel: CheckboxBasicConfigPanel,
|
||||||
icon: "Edit",
|
icon: "Edit",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
|
||||||
|
|
@ -11,37 +11,37 @@ import { API_BASE_URL } from "@/lib/api/client";
|
||||||
|
|
||||||
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
|
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
|
||||||
const loadOfficeLibrariesFromCDN = async () => {
|
const loadOfficeLibrariesFromCDN = async () => {
|
||||||
if (typeof window === 'undefined') return { XLSX: null, mammoth: null };
|
if (typeof window === "undefined") return { XLSX: null, mammoth: null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// XLSX 라이브러리가 이미 로드되어 있는지 확인
|
// XLSX 라이브러리가 이미 로드되어 있는지 확인
|
||||||
if (!(window as any).XLSX) {
|
if (!(window as any).XLSX) {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement("script");
|
||||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
|
script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
|
||||||
script.onload = resolve;
|
script.onload = resolve;
|
||||||
script.onerror = reject;
|
script.onerror = reject;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// mammoth 라이브러리가 이미 로드되어 있는지 확인
|
// mammoth 라이브러리가 이미 로드되어 있는지 확인
|
||||||
if (!(window as any).mammoth) {
|
if (!(window as any).mammoth) {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement("script");
|
||||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js';
|
script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
|
||||||
script.onload = resolve;
|
script.onload = resolve;
|
||||||
script.onerror = reject;
|
script.onerror = reject;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
XLSX: (window as any).XLSX,
|
XLSX: (window as any).XLSX,
|
||||||
mammoth: (window as any).mammoth
|
mammoth: (window as any).mammoth,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Office 라이브러리 CDN 로드 실패:', error);
|
console.error("Office 라이브러리 CDN 로드 실패:", error);
|
||||||
return { XLSX: null, mammoth: null };
|
return { XLSX: null, mammoth: null };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -57,13 +57,7 @@ interface FileViewerModalProps {
|
||||||
/**
|
/**
|
||||||
* 파일 뷰어 모달 컴포넌트
|
* 파일 뷰어 모달 컴포넌트
|
||||||
*/
|
*/
|
||||||
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
|
||||||
file,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onDownload,
|
|
||||||
onDelete,
|
|
||||||
}) => {
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -73,37 +67,37 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
|
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// CDN에서 라이브러리 로드
|
// CDN에서 라이브러리 로드
|
||||||
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
|
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
|
||||||
|
|
||||||
if (fileExt === "docx" && mammoth) {
|
if (fileExt === "docx" && mammoth) {
|
||||||
// Word 문서 렌더링
|
// Word 문서 렌더링
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||||
|
|
||||||
const htmlContent = `
|
const htmlContent = `
|
||||||
<div>
|
<div>
|
||||||
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
|
<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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setRenderedContent(htmlContent);
|
setRenderedContent(htmlContent);
|
||||||
return true;
|
return true;
|
||||||
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
|
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
|
||||||
// Excel 문서 렌더링
|
// Excel 문서 렌더링
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
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 sheetName = workbook.SheetNames[0];
|
||||||
const worksheet = workbook.Sheets[sheetName];
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
|
|
||||||
const html = XLSX.utils.sheet_to_html(worksheet, {
|
const html = XLSX.utils.sheet_to_html(worksheet, {
|
||||||
table: { className: 'excel-table' }
|
table: { className: "excel-table" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const htmlContent = `
|
const htmlContent = `
|
||||||
<div>
|
<div>
|
||||||
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
|
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
|
||||||
|
|
@ -118,7 +112,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setRenderedContent(htmlContent);
|
setRenderedContent(htmlContent);
|
||||||
return true;
|
return true;
|
||||||
} else if (fileExt === "doc") {
|
} else if (fileExt === "doc") {
|
||||||
|
|
@ -130,7 +124,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
<p style="color: #666; font-size: 14px;">(.docx 파일만 미리보기 지원)</p>
|
<p style="color: #666; font-size: 14px;">(.docx 파일만 미리보기 지원)</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setRenderedContent(htmlContent);
|
setRenderedContent(htmlContent);
|
||||||
return true;
|
return true;
|
||||||
} else if (["ppt", "pptx"].includes(fileExt)) {
|
} else if (["ppt", "pptx"].includes(fileExt)) {
|
||||||
|
|
@ -142,22 +136,22 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
<p style="color: #666; font-size: 14px;">파일을 다운로드하여 확인해주세요.</p>
|
<p style="color: #666; font-size: 14px;">파일을 다운로드하여 확인해주세요.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setRenderedContent(htmlContent);
|
setRenderedContent(htmlContent);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false; // 지원하지 않는 형식
|
return false; // 지원하지 않는 형식
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Office 문서 렌더링 오류:", error);
|
console.error("Office 문서 렌더링 오류:", error);
|
||||||
|
|
||||||
const htmlContent = `
|
const htmlContent = `
|
||||||
<div style="color: red; text-align: center; padding: 20px;">
|
<div style="color: red; text-align: center; padding: 20px;">
|
||||||
Office 문서를 읽을 수 없습니다.<br>
|
Office 문서를 읽을 수 없습니다.<br>
|
||||||
파일이 손상되었거나 지원하지 않는 형식일 수 있습니다.
|
파일이 손상되었거나 지원하지 않는 형식일 수 있습니다.
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setRenderedContent(htmlContent);
|
setRenderedContent(htmlContent);
|
||||||
return true; // 오류 메시지라도 표시
|
return true; // 오류 메시지라도 표시
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -182,7 +176,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
const url = URL.createObjectURL(file._file);
|
const url = URL.createObjectURL(file._file);
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,20 +186,35 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
const generatePreviewUrl = async () => {
|
const generatePreviewUrl = async () => {
|
||||||
try {
|
try {
|
||||||
const fileExt = file.fileExt.toLowerCase();
|
const fileExt = file.fileExt.toLowerCase();
|
||||||
|
|
||||||
// 미리보기 지원 파일 타입 정의
|
// 미리보기 지원 파일 타입 정의
|
||||||
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
|
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 textExtensions = ["txt", "md", "json", "xml", "csv"];
|
||||||
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
|
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
|
||||||
|
|
||||||
const supportedExtensions = [
|
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
|
||||||
...imageExtensions,
|
|
||||||
...documentExtensions,
|
|
||||||
...textExtensions,
|
|
||||||
...mediaExtensions
|
|
||||||
];
|
|
||||||
|
|
||||||
if (supportedExtensions.includes(fileExt)) {
|
if (supportedExtensions.includes(fileExt)) {
|
||||||
// 이미지나 PDF는 인증된 요청으로 Blob 생성
|
// 이미지나 PDF는 인증된 요청으로 Blob 생성
|
||||||
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
|
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
|
||||||
|
|
@ -213,15 +222,15 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
// 인증된 요청으로 파일 데이터 가져오기
|
// 인증된 요청으로 파일 데이터 가져오기
|
||||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${localStorage.getItem("authToken")}`,
|
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
setPreviewUrl(blobUrl);
|
setPreviewUrl(blobUrl);
|
||||||
|
|
||||||
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
|
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
|
||||||
cleanup = () => URL.revokeObjectURL(blobUrl);
|
cleanup = () => URL.revokeObjectURL(blobUrl);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -236,20 +245,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
|
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
|
||||||
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
|
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
|
||||||
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
|
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
|
||||||
try {
|
try {
|
||||||
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
|
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
|
||||||
|
|
||||||
if (!renderSuccess) {
|
if (!renderSuccess) {
|
||||||
// 렌더링 실패 시 Blob URL 사용
|
// 렌더링 실패 시 Blob URL 사용
|
||||||
setPreviewUrl(blobUrl);
|
setPreviewUrl(blobUrl);
|
||||||
|
|
@ -263,7 +272,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
// 기타 문서는 직접 Blob URL 사용
|
// 기타 문서는 직접 Blob URL 사용
|
||||||
setPreviewUrl(blobUrl);
|
setPreviewUrl(blobUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
|
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
@ -291,7 +300,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
generatePreviewUrl();
|
generatePreviewUrl();
|
||||||
|
|
||||||
// cleanup 함수 반환
|
// cleanup 함수 반환
|
||||||
return () => {
|
return () => {
|
||||||
if (cleanup) {
|
if (cleanup) {
|
||||||
|
|
@ -306,24 +315,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
const renderPreview = () => {
|
const renderPreview = () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex h-96 items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewError) {
|
if (previewError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96">
|
<div className="flex h-96 flex-col items-center justify-center">
|
||||||
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" />
|
<AlertTriangle className="mb-4 h-16 w-16 text-yellow-500" />
|
||||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
<p className="mb-2 text-lg font-medium">미리보기 불가</p>
|
||||||
<p className="text-sm text-center">{previewError}</p>
|
<p className="text-center text-sm">{previewError}</p>
|
||||||
<Button
|
<Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
|
||||||
variant="outline"
|
<Download className="mr-2 h-4 w-4" />
|
||||||
onClick={() => onDownload?.(file)}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
파일 다운로드
|
파일 다운로드
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -335,11 +340,11 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
// 이미지 파일
|
// 이미지 파일
|
||||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
|
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
|
||||||
return (
|
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
|
<img
|
||||||
src={previewUrl || ""}
|
src={previewUrl || ""}
|
||||||
alt={file.realFileName}
|
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) => {
|
onError={(e) => {
|
||||||
console.error("이미지 로드 오류:", previewUrl, e);
|
console.error("이미지 로드 오류:", previewUrl, e);
|
||||||
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
|
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
|
||||||
|
|
@ -358,100 +363,83 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
<div className="h-96 overflow-auto">
|
<div className="h-96 overflow-auto">
|
||||||
<iframe
|
<iframe
|
||||||
src={previewUrl || ""}
|
src={previewUrl || ""}
|
||||||
className="w-full h-full border rounded-lg"
|
className="h-full w-full rounded-lg border"
|
||||||
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
|
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF 파일
|
// PDF 파일 - 브라우저 기본 뷰어 사용
|
||||||
if (fileExt === "pdf") {
|
if (fileExt === "pdf") {
|
||||||
return (
|
return (
|
||||||
<div className="h-96 overflow-auto">
|
<div className="h-[600px] overflow-auto rounded-lg border bg-gray-50">
|
||||||
<iframe
|
<object
|
||||||
src={previewUrl || ""}
|
data={previewUrl || ""}
|
||||||
className="w-full h-full border rounded-lg"
|
type="application/pdf"
|
||||||
onError={() => setPreviewError("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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe)
|
// Office 문서 - 모든 Office 문서는 다운로드 권장
|
||||||
if (
|
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 라이브러리로 렌더링된 콘텐츠가 있는 경우
|
// Office 문서 안내 메시지 표시
|
||||||
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)
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-96 overflow-auto">
|
<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">
|
||||||
<iframe
|
<FileText className="mb-6 h-20 w-20 text-blue-500" />
|
||||||
src={previewUrl || ""}
|
<h3 className="mb-2 text-xl font-semibold text-gray-800">Office 문서</h3>
|
||||||
className="w-full h-full border rounded-lg"
|
<p className="mb-6 max-w-md text-center text-sm text-gray-600">
|
||||||
onError={() => {
|
{fileExt === "docx" || fileExt === "doc"
|
||||||
console.log("iframe 오류 발생, fallback 옵션 제공");
|
? "Word 문서"
|
||||||
setPreviewError("이 Office 문서는 브라우저에서 직접 미리보기할 수 없습니다. 다운로드하여 확인해주세요.");
|
: fileExt === "xlsx" || fileExt === "xls"
|
||||||
}}
|
? "Excel 문서"
|
||||||
title={`${file.realFileName} 미리보기`}
|
: fileExt === "pptx" || fileExt === "ppt"
|
||||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
? "PowerPoint 문서"
|
||||||
onLoad={() => setIsLoading(false)}
|
: "Office 문서"}
|
||||||
/>
|
는 브라우저에서 미리보기가 지원되지 않습니다.
|
||||||
|
<br />
|
||||||
{/* 로딩 상태 */}
|
다운로드하여 확인해주세요.
|
||||||
{isLoading && (
|
</p>
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90">
|
<div className="flex gap-3">
|
||||||
<div className="text-center">
|
<Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
<Download className="mr-2 h-5 w-5" />
|
||||||
<p className="text-sm text-gray-600">Office 문서를 처리하는 중...</p>
|
다운로드하여 열기
|
||||||
<p className="text-xs text-gray-400 mt-1">잠시만 기다려주세요</p>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -460,11 +448,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
if (["mp4", "webm", "ogg"].includes(fileExt)) {
|
if (["mp4", "webm", "ogg"].includes(fileExt)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<video
|
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
|
||||||
controls
|
|
||||||
className="w-full max-h-96"
|
|
||||||
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
|
|
||||||
>
|
|
||||||
<source src={previewUrl || ""} type={`video/${fileExt}`} />
|
<source src={previewUrl || ""} type={`video/${fileExt}`} />
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -474,9 +458,9 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
// 오디오 파일
|
// 오디오 파일
|
||||||
if (["mp3", "wav", "ogg"].includes(fileExt)) {
|
if (["mp3", "wav", "ogg"].includes(fileExt)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96">
|
<div className="flex h-96 flex-col items-center justify-center">
|
||||||
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
<div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-gray-100">
|
||||||
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<audio
|
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
|
||||||
controls
|
|
||||||
className="w-full max-w-md"
|
|
||||||
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
|
|
||||||
>
|
|
||||||
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
|
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -497,17 +477,12 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
|
|
||||||
// 기타 파일 타입
|
// 기타 파일 타입
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96">
|
<div className="flex h-96 flex-col items-center justify-center">
|
||||||
<FileText className="w-16 h-16 mb-4 text-gray-400" />
|
<FileText className="mb-4 h-16 w-16 text-gray-400" />
|
||||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
<p className="mb-2 text-lg font-medium">미리보기 불가</p>
|
||||||
<p className="text-sm text-center mb-4">
|
<p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.</p>
|
||||||
{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.
|
<Button variant="outline" onClick={() => onDownload?.(file)}>
|
||||||
</p>
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onDownload?.(file)}
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
파일 다운로드
|
파일 다운로드
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -516,65 +491,53 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
<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>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<DialogTitle className="text-lg font-semibold truncate">
|
<DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
|
||||||
{file.realFileName}
|
|
||||||
</DialogTitle>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{file.fileExt.toUpperCase()}
|
{file.fileExt.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
파일 크기: {formatFileSize(file.size)} | 파일 형식: {file.fileExt.toUpperCase()}
|
파일 크기: {formatFileSize(file.fileSize || file.size || 0)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">{renderPreview()}</div>
|
||||||
{renderPreview()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 파일 정보 및 액션 버튼 */}
|
{/* 파일 정보 및 액션 버튼 */}
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
|
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||||
<span>크기: {formatFileSize(file.size)}</span>
|
<span>크기: {formatFileSize(file.fileSize || file.size || 0)}</span>
|
||||||
{file.uploadedAt && (
|
{(file.uploadedAt || file.regdate) && (
|
||||||
<span>업로드: {new Date(file.uploadedAt).toLocaleString()}</span>
|
<span>업로드: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 pt-4 border-t">
|
<div className="flex justify-end space-x-2 border-t pt-4">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
|
||||||
variant="outline"
|
<Download className="mr-2 h-4 w-4" />
|
||||||
size="sm"
|
|
||||||
onClick={() => onDownload?.(file)}
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
다운로드
|
다운로드
|
||||||
</Button>
|
</Button>
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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)}
|
onClick={() => onDelete(file)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
variant="outline"
|
<X className="mr-2 h-4 w-4" />
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 mr-2" />
|
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const FileUploadDefinition = createComponentDefinition({
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
placeholder: "입력하세요",
|
||||||
},
|
},
|
||||||
defaultSize: { width: 350, height: 40 },
|
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
|
||||||
configPanel: FileUploadConfigPanel,
|
configPanel: FileUploadConfigPanel,
|
||||||
icon: "Edit",
|
icon: "Edit",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
|
||||||
|
|
@ -84,13 +84,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
// radio-horizontal: 가로 배치
|
// radio-horizontal: 가로 배치
|
||||||
if (webType === "radio-horizontal") {
|
if (webType === "radio-horizontal") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-row gap-4">
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{options.map((option: any, index: number) => (
|
{options.map((option: any, index: number) => (
|
||||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -100,7 +94,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
checked={selectedValue === option.value}
|
checked={selectedValue === option.value}
|
||||||
onChange={() => handleRadioChange(option.value)}
|
onChange={() => handleRadioChange(option.value)}
|
||||||
disabled={componentConfig.disabled || isDesignMode}
|
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>
|
<span className="text-sm text-gray-900">{option.label}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -112,13 +106,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
// radio-vertical: 세로 배치
|
// radio-vertical: 세로 배치
|
||||||
if (webType === "radio-vertical") {
|
if (webType === "radio-vertical") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col gap-2">
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{options.map((option: any, index: number) => (
|
{options.map((option: any, index: number) => (
|
||||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -128,7 +116,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
checked={selectedValue === option.value}
|
checked={selectedValue === option.value}
|
||||||
onChange={() => handleRadioChange(option.value)}
|
onChange={() => handleRadioChange(option.value)}
|
||||||
disabled={componentConfig.disabled || isDesignMode}
|
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>
|
<span className="text-sm text-gray-900">{option.label}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -139,14 +127,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
|
|
||||||
// radio (기본 라디오 - direction 설정 따름)
|
// radio (기본 라디오 - direction 설정 따름)
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("flex gap-3", componentConfig.direction === "horizontal" ? "flex-row" : "flex-col")}>
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{options.map((option: any, index: number) => (
|
{options.map((option: any, index: number) => (
|
||||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -157,7 +138,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
onChange={() => handleRadioChange(option.value)}
|
onChange={() => handleRadioChange(option.value)}
|
||||||
disabled={componentConfig.disabled || isDesignMode}
|
disabled={componentConfig.disabled || isDesignMode}
|
||||||
required={componentConfig.required || false}
|
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>
|
<span className="text-sm text-gray-900">{option.label}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const RadioBasicDefinition = createComponentDefinition({
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
placeholder: "입력하세요",
|
||||||
},
|
},
|
||||||
defaultSize: { width: 150, height: 32 },
|
defaultSize: { width: 150, height: 80 }, // 40 * 2 (2개 옵션)
|
||||||
configPanel: RadioBasicConfigPanel,
|
configPanel: RadioBasicConfigPanel,
|
||||||
icon: "Edit",
|
icon: "Edit",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue