// 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 }); };