From 90f79a7a691943a496f67f01c1e493256070e4de Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 01:44:45 +0000 Subject: (대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/file-download.ts | 260 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 lib/file-download.ts (limited to 'lib/file-download.ts') diff --git a/lib/file-download.ts b/lib/file-download.ts new file mode 100644 index 00000000..1e8536b5 --- /dev/null +++ b/lib/file-download.ts @@ -0,0 +1,260 @@ +// lib/file-download.ts +// 공용 파일 다운로드 유틸리티 + +import { toast } from "sonner"; + +/** + * 파일 타입 정보 + */ +export interface FileInfo { + type: 'pdf' | 'document' | 'spreadsheet' | 'image' | 'archive' | 'other'; + canPreview: boolean; + icon: string; + mimeType?: string; +} + +/** + * 파일 정보 가져오기 + */ +export const getFileInfo = (fileName: string): FileInfo => { + const ext = fileName.toLowerCase().split('.').pop(); + + const fileTypes: Record = { + pdf: { type: 'pdf', canPreview: true, icon: '📄', mimeType: 'application/pdf' }, + doc: { type: 'document', canPreview: false, icon: '📝', mimeType: 'application/msword' }, + docx: { type: 'document', canPreview: false, icon: '📝', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + xls: { type: 'spreadsheet', canPreview: false, icon: '📊', mimeType: 'application/vnd.ms-excel' }, + xlsx: { type: 'spreadsheet', canPreview: false, icon: '📊', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + ppt: { type: 'document', canPreview: false, icon: '📑', mimeType: 'application/vnd.ms-powerpoint' }, + pptx: { type: 'document', canPreview: false, icon: '📑', mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }, + jpg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/jpeg' }, + jpeg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/jpeg' }, + png: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/png' }, + gif: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/gif' }, + webp: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/webp' }, + svg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/svg+xml' }, + zip: { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/zip' }, + rar: { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/x-rar-compressed' }, + '7z': { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/x-7z-compressed' }, + txt: { type: 'document', canPreview: true, icon: '📝', mimeType: 'text/plain' }, + csv: { type: 'spreadsheet', canPreview: true, icon: '📊', mimeType: 'text/csv' }, + }; + + return fileTypes[ext || ''] || { type: 'other', canPreview: false, icon: '📎', mimeType: 'application/octet-stream' }; +}; + +/** + * 파일 크기를 읽기 쉬운 형태로 변환 + */ +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +/** + * 파일 다운로드 옵션 + */ +export interface FileDownloadOptions { + /** 다운로드 액션 타입 */ + action?: 'download' | 'preview'; + /** 에러 시 토스트 표시 여부 */ + showToast?: boolean; + /** 성공 시 토스트 표시 여부 */ + showSuccessToast?: boolean; + /** 커스텀 에러 핸들러 */ + onError?: (error: string) => void; + /** 커스텀 성공 핸들러 */ + onSuccess?: (fileName: string, fileSize?: number) => void; + /** 진행률 콜백 (큰 파일용) */ + onProgress?: (progress: number) => void; +} + +/** + * 파일 다운로드 결과 + */ +export interface FileDownloadResult { + success: boolean; + error?: string; + fileSize?: number; + fileInfo?: FileInfo; +} + +/** + * 파일 메타데이터 확인 + */ +export const checkFileMetadata = async (url: string): Promise<{ + exists: boolean; + size?: number; + contentType?: string; + lastModified?: Date; + error?: string; +}> => { + try { + const response = await fetch(url, { + method: 'HEAD', + headers: { 'Cache-Control': 'no-cache' } + }); + + if (!response.ok) { + let error = "파일 접근 실패"; + + switch (response.status) { + case 404: + error = "파일을 찾을 수 없습니다"; + break; + case 403: + error = "파일 접근 권한이 없습니다"; + break; + case 500: + error = "서버 오류가 발생했습니다"; + break; + default: + error = `파일 접근 실패 (${response.status})`; + } + + return { exists: false, error }; + } + + const contentLength = response.headers.get('Content-Length'); + const contentType = response.headers.get('Content-Type'); + const lastModified = response.headers.get('Last-Modified'); + + return { + exists: true, + size: contentLength ? parseInt(contentLength, 10) : undefined, + contentType: contentType || undefined, + lastModified: lastModified ? new Date(lastModified) : undefined, + }; + } catch (error) { + return { + exists: false, + error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다" + }; + } +}; + +/** + * 메인 파일 다운로드 함수 + */ +export const downloadFile = async ( + filePath: string, + fileName: string, + options: FileDownloadOptions = {} +): Promise => { + const { action = 'download', showToast = true, onError, onSuccess } = options; + + try { + // ✅ URL에 다운로드 강제 파라미터 추가 + const baseUrl = filePath.startsWith('http') + ? filePath + : `${window.location.origin}${filePath}`; + + const url = new URL(baseUrl); + if (action === 'download') { + url.searchParams.set('download', 'true'); // 🔑 핵심! + } + + const fullUrl = url.toString(); + + // 파일 정보 확인 + const metadata = await checkFileMetadata(fullUrl); + if (!metadata.exists) { + const error = metadata.error || "파일을 찾을 수 없습니다"; + if (showToast) toast.error(error); + if (onError) onError(error); + return { success: false, error }; + } + + const fileInfo = getFileInfo(fileName); + + // 미리보기 처리 (download=true 없이) + if (action === 'preview' && fileInfo.canPreview) { + const previewUrl = filePath.startsWith('http') + ? filePath + : `${window.location.origin}${filePath}`; + + window.open(previewUrl, '_blank', 'noopener,noreferrer'); + if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`); + if (onSuccess) onSuccess(fileName, metadata.size); + return { success: true, fileSize: metadata.size, fileInfo }; + } + + // ✅ 안전한 다운로드 방식 (fetch + Blob) + console.log(`📥 안전한 다운로드: ${fullUrl}`); + + const response = await fetch(fullUrl); + if (!response.ok) { + throw new Error(`다운로드 실패: ${response.status}`); + } + + // Blob으로 변환 + const blob = await response.blob(); + + // ✅ 브라우저 호환성을 고려한 다운로드 + const downloadUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = fileName; + link.style.display = 'none'; // 화면에 표시되지 않도록 + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 메모리 정리 (중요!) + setTimeout(() => URL.revokeObjectURL(downloadUrl), 100); + + // 성공 처리 + if (showToast) { + const sizeText = metadata.size ? ` (${formatFileSize(metadata.size)})` : ''; + toast.success(`${fileInfo.icon} 파일 다운로드 완료: ${fileName}${sizeText}`); + } + if (onSuccess) onSuccess(fileName, metadata.size); + + return { success: true, fileSize: metadata.size, fileInfo }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다"; + console.error("❌ 다운로드 오류:", error); + if (showToast) toast.error(errorMessage); + if (onError) onError(errorMessage); + return { success: false, error: errorMessage }; + } +}; + +/** + * 간편 다운로드 함수 + */ +export const quickDownload = (filePath: string, fileName: string) => { + return downloadFile(filePath, fileName, { action: 'download' }); +}; + +/** + * 간편 미리보기 함수 + */ +export const quickPreview = (filePath: string, fileName: string) => { + const fileInfo = getFileInfo(fileName); + + if (!fileInfo.canPreview) { + toast.warning("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다."); + return downloadFile(filePath, fileName, { action: 'download' }); + } + + return downloadFile(filePath, fileName, { action: 'preview' }); +}; + +/** + * 파일 다운로드 또는 미리보기 (자동 판단) + */ +export const smartFileAction = (filePath: string, fileName: string) => { + const fileInfo = getFileInfo(fileName); + const action = fileInfo.canPreview ? 'preview' : 'download'; + + return downloadFile(filePath, fileName, { action }); +}; \ No newline at end of file -- cgit v1.2.3