diff --git a/frontend/src/components/drive/DriveNameModal.vue b/frontend/src/components/drive/DriveNameModal.vue new file mode 100644 index 0000000..de0807e --- /dev/null +++ b/frontend/src/components/drive/DriveNameModal.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/composables/useDrive.ts b/frontend/src/composables/useDrive.ts new file mode 100644 index 0000000..542ccda --- /dev/null +++ b/frontend/src/composables/useDrive.ts @@ -0,0 +1,72 @@ +import { useApi } from './useApi' +import type { + DriveFile, + DriveFolder, + DriveList, + PresignUploadPayload, + PresignUploadResult, +} from '@/types/drive' + +// 드라이브 도메인 API Composable — 인터셉터가 success/data 를 언래핑 +// (바이너리 송수신은 백엔드를 거치지 않고 presigned URL 로 직접 — store 에서 처리) +export function useDrive() { + const api = useApi() + + // 목록 (folderId 생략 시 루트) + async function list(folderId?: string): Promise { + return (await api.get('/drive', { + params: folderId ? { folderId } : {}, + })) as unknown as DriveList + } + + // 폴더 + async function createFolder( + name: string, + parentId?: string, + ): Promise { + return (await api.post('/drive/folders', { + name, + ...(parentId ? { parentId } : {}), + })) as unknown as DriveFolder + } + async function renameFolder(id: string, name: string): Promise { + return (await api.patch(`/drive/folders/${id}`, { name })) as unknown as DriveFolder + } + async function deleteFolder(id: string): Promise { + await api.delete(`/drive/folders/${id}`) + } + + // 파일 — presigned 3단계 업로드(① presign → ② 직접 PUT[store] → ③ complete) + async function presignUpload( + payload: PresignUploadPayload, + ): Promise { + return (await api.post('/drive/files/presign', payload)) as unknown as PresignUploadResult + } + async function completeUpload(fileId: string): Promise { + return (await api.post(`/drive/files/${fileId}/complete`)) as unknown as DriveFile + } + async function getDownloadUrl(fileId: string): Promise { + const res = (await api.get(`/drive/files/${fileId}/download-url`)) as unknown as { + url: string + } + return res.url + } + async function renameFile(id: string, name: string): Promise { + return (await api.patch(`/drive/files/${id}`, { name })) as unknown as DriveFile + } + async function deleteFile(id: string): Promise { + await api.delete(`/drive/files/${id}`) + } + + return { + list, + createFolder, + renameFolder, + deleteFolder, + presignUpload, + completeUpload, + getDownloadUrl, + renameFile, + deleteFile, + } +} diff --git a/frontend/src/layouts/AppShell.vue b/frontend/src/layouts/AppShell.vue index 69b747a..6734415 100644 --- a/frontend/src/layouts/AppShell.vue +++ b/frontend/src/layouts/AppShell.vue @@ -29,10 +29,11 @@ function toggleCollapse() { const mobileOpen = ref(false) // 현재 경로 기준으로 사이드바 네비 활성 항목을 판별 -const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting'>(() => { +const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting' | 'drive'>(() => { if (route.path.startsWith('/projects')) return 'projects' if (route.path.startsWith('/schedule')) return 'schedule' if (route.path.startsWith('/meeting')) return 'meeting' + if (route.path.startsWith('/drive')) return 'drive' return 'tasks' }) @@ -227,6 +228,24 @@ async function onLogout() { 화상회의 + + + + + 드라이브 + diff --git a/frontend/src/pages/relay/DrivePage.vue b/frontend/src/pages/relay/DrivePage.vue new file mode 100644 index 0000000..08ae8af --- /dev/null +++ b/frontend/src/pages/relay/DrivePage.vue @@ -0,0 +1,764 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b663db3..3ea606f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -115,6 +115,13 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/relay/MeetingPage.vue'), meta: { requiresAuth: true }, }, + { + // 드라이브 — 개인 파일 저장소. :folderId 로 폴더 직접 진입(/drive/<폴더ID>) + path: '/drive/:folderId?', + name: 'drive', + component: () => import('@/pages/relay/DrivePage.vue'), + meta: { requiresAuth: true }, + }, { path: '/:pathMatch(.*)*', name: 'not-found', diff --git a/frontend/src/shared/utils/drive.ts b/frontend/src/shared/utils/drive.ts new file mode 100644 index 0000000..1de5a7c --- /dev/null +++ b/frontend/src/shared/utils/drive.ts @@ -0,0 +1,83 @@ +// 드라이브 클라이언트 측 상수/순수 유틸 — 업로드 사전 검증·MIME 추론 +// (백엔드 drive.config.ts 의 화이트리스트/최대크기와 동기화 유지) + +// 파일 1건 최대 크기(기본 200MB) — 백엔드 DRIVE_MAX_FILE_MB 기본값과 일치 +export const DRIVE_MAX_FILE_SIZE = 200 * 1024 * 1024 + +// 허용 MIME 화이트리스트(백엔드 DRIVE_ALLOWED_MIME_TYPES 미러) +export const DRIVE_ALLOWED_MIME: ReadonlySet = new Set([ + // 이미지 + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + // 문서 + 'application/pdf', + 'text/plain', + 'text/csv', + 'text/markdown', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/json', + // 압축 + 'application/zip', + 'application/x-zip-compressed', + 'application/x-7z-compressed', + 'application/x-rar-compressed', + // 미디어 + 'video/mp4', + 'video/webm', + 'video/quicktime', + 'audio/mpeg', + 'audio/wav', + 'audio/ogg', +]) + +// 용 확장자 목록 +export const DRIVE_ACCEPT = + '.png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.md,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.json,.zip,.7z,.rar,.mp4,.webm,.mov,.mp3,.wav,.ogg' + +// 확장자 → MIME 추론(브라우저가 file.type 을 비워줄 때 보완) +const EXT_TO_MIME: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + pdf: 'application/pdf', + txt: 'text/plain', + csv: 'text/csv', + md: 'text/markdown', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + json: 'application/json', + zip: 'application/zip', + '7z': 'application/x-7z-compressed', + rar: 'application/x-rar-compressed', + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', +} + +// 파일의 MIME 결정 — file.type 우선, 없으면 확장자로 추론 +export function resolveMimeType(file: File): string { + if (file.type) return file.type + const ext = file.name.split('.').pop()?.toLowerCase() ?? '' + return EXT_TO_MIME[ext] ?? '' +} + +// 업로드 허용 여부 +export function isAllowedDriveMime(mime: string): boolean { + return DRIVE_ALLOWED_MIME.has(mime) +} diff --git a/frontend/src/stores/drive.store.ts b/frontend/src/stores/drive.store.ts new file mode 100644 index 0000000..98ef61d --- /dev/null +++ b/frontend/src/stores/drive.store.ts @@ -0,0 +1,177 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { useDrive } from '@/composables/useDrive' +import type { DriveCrumb, DriveFile, DriveFolder } from '@/types/drive' +import type { PresignedUpload } from '@/types/drive' + +// 진행 중 업로드 1건(UI 표시용) +export interface UploadItem { + id: number // 로컬 임시 id + name: string + size: number + progress: number // 0~100 + status: 'uploading' | 'completing' | 'error' + error?: string +} + +// 이름 오름차순 정렬(백엔드 정렬과 동일 기준 유지) +function byName(a: T, b: T): number { + return a.name.localeCompare(b.name, 'ko') +} + +// presigned URL 로 객체 스토리지에 직접 PUT — 진행률을 위해 XHR 사용(fetch 는 업로드 진행률 미지원) +function putToStorage( + upload: PresignedUpload, + file: File, + onProgress: (pct: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open(upload.method, upload.url) + // 서명 대상 헤더(Content-Type 등)를 반드시 그대로 전송 + for (const [k, v] of Object.entries(upload.headers)) { + xhr.setRequestHeader(k, v) + } + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve() + else reject(new Error(`업로드에 실패했습니다. (${xhr.status})`)) + } + xhr.onerror = () => reject(new Error('네트워크 오류로 업로드에 실패했습니다.')) + xhr.send(file) + }) +} + +let uploadSeq = 0 + +export const useDriveStore = defineStore('drive', () => { + const drive = useDrive() + + // --- 상태 --- + const folderId = ref(null) // 현재 폴더(루트면 null) + const current = ref(null) + const breadcrumb = ref([]) + const folders = ref([]) + const files = ref([]) + const loading = ref(false) + const uploads = ref([]) + + const isEmpty = computed( + () => folders.value.length === 0 && files.value.length === 0, + ) + const itemCount = computed(() => folders.value.length + files.value.length) + + // --- 조회 --- + async function fetchList(id: string | null): Promise { + folderId.value = id + loading.value = true + try { + const res = await drive.list(id ?? undefined) + current.value = res.current + breadcrumb.value = res.breadcrumb + folders.value = [...res.folders].sort(byName) + files.value = [...res.files].sort(byName) + } finally { + loading.value = false + } + } + + // --- 폴더 --- + async function createFolder(name: string): Promise { + const created = await drive.createFolder(name, folderId.value ?? undefined) + folders.value = [...folders.value, created].sort(byName) + } + async function renameFolder(id: string, name: string): Promise { + const updated = await drive.renameFolder(id, name) + folders.value = folders.value + .map((f) => (f.id === id ? updated : f)) + .sort(byName) + } + async function removeFolder(id: string): Promise { + await drive.deleteFolder(id) + folders.value = folders.value.filter((f) => f.id !== id) + } + + // --- 파일 --- + async function renameFile(id: string, name: string): Promise { + const updated = await drive.renameFile(id, name) + files.value = files.value.map((f) => (f.id === id ? updated : f)).sort(byName) + } + async function removeFile(id: string): Promise { + await drive.deleteFile(id) + files.value = files.value.filter((f) => f.id !== id) + } + + // 다운로드 — 단기 presigned URL 발급 후 브라우저 다운로드 트리거 + async function download(id: string): Promise { + const url = await drive.getDownloadUrl(id) + const a = document.createElement('a') + a.href = url + a.rel = 'noopener' + document.body.appendChild(a) + a.click() + a.remove() + } + + // 업로드 1건 — presign → 직접 PUT(진행률) → complete. 검증은 호출 전(page)에서 끝낸 상태로 가정. + async function uploadOne(file: File, contentType: string): Promise { + const targetFolder = folderId.value // 시작 시점의 폴더(완료 시 위치 비교용) + const item: UploadItem = { + id: ++uploadSeq, + name: file.name, + size: file.size, + progress: 0, + status: 'uploading', + } + uploads.value = [...uploads.value, item] + try { + const res = await drive.presignUpload({ + filename: file.name, + contentType, + size: file.size, + folderId: targetFolder ?? undefined, + }) + await putToStorage(res.upload, file, (p) => { + item.progress = p + }) + item.status = 'completing' + const done = await drive.completeUpload(res.fileId) + // 업로드 중 사용자가 폴더를 옮기지 않았다면 현재 목록에 즉시 반영 + if ((done.folderId ?? null) === (folderId.value ?? null)) { + files.value = [...files.value, done].sort(byName) + } + uploads.value = uploads.value.filter((u) => u.id !== item.id) + } catch (e: unknown) { + item.status = 'error' + item.error = e instanceof Error ? e.message : '업로드에 실패했습니다.' + } + } + + // 완료/실패 업로드 항목 닫기 + function dismissUpload(id: number): void { + uploads.value = uploads.value.filter((u) => u.id !== id) + } + + return { + folderId, + current, + breadcrumb, + folders, + files, + loading, + uploads, + isEmpty, + itemCount, + fetchList, + createFolder, + renameFolder, + removeFolder, + renameFile, + removeFile, + download, + uploadOne, + dismissUpload, + } +}) diff --git a/frontend/src/types/drive.ts b/frontend/src/types/drive.ts new file mode 100644 index 0000000..6d07286 --- /dev/null +++ b/frontend/src/types/drive.ts @@ -0,0 +1,75 @@ +// 드라이브(파일 저장소) 타입 — 백엔드 drive 모듈 응답과 1:1 미러 +// (SSOT: backend/src/modules/drive/drive.service.ts 의 *Response 인터페이스) +import type { FileKind } from '@/shared/utils/format' + +// 업로더/소유자(개인 드라이브에선 보통 본인) — PublicUser 부분 미러 +export interface DriveOwner { + id: string + name: string + email?: string + avatarUrl?: string | null +} + +// 파일 업로드 상태: pending(presign 후 확정 전) | active(확정 완료) +export type DriveFileStatus = 'pending' | 'active' + +// 폴더 +export interface DriveFolder { + id: string + name: string + parentId: string | null + owner: DriveOwner | null + createdAt: string + updatedAt: string +} + +// 파일 +export interface DriveFile { + id: string + name: string + size: number // 바이트 + mime: string + kind: FileKind // 'pdf' | 'zip' | 'doc' | 'img' + status: DriveFileStatus + folderId: string | null + owner: DriveOwner | null + createdAt: string + updatedAt: string +} + +// 브레드크럼 한 칸(루트→현재 경로) +export interface DriveCrumb { + id: string + name: string +} + +// 목록 응답 +export interface DriveList { + current: DriveFolder | null + breadcrumb: DriveCrumb[] + folders: DriveFolder[] + files: DriveFile[] +} + +// presigned 업로드 발급 결과(storage.types.PresignedUpload 미러) +export interface PresignedUpload { + url: string + method: 'PUT' + headers: Record + expiresAt: string +} + +// presign 발급 응답 +export interface PresignUploadResult { + fileId: string + file: DriveFile + upload: PresignedUpload +} + +// presign 요청 본문 +export interface PresignUploadPayload { + filename: string + contentType: string + size: number + folderId?: string +}