feat: 드라이브(파일 저장소) 프론트엔드 UI 구현

폴더 탐색·드래그앤드롭 업로드(presigned 3단계)·다운로드·이름변경/삭제, 라우트(/drive)·사이드바 네비 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:20:51 +09:00
parent 9ce3158450
commit 638d1265f6
8 changed files with 1264 additions and 1 deletions
@@ -0,0 +1,66 @@
<script setup lang="ts">
// 드라이브 이름 입력 모달 — 폴더 생성 / 폴더·파일 이름 변경 공용
import { computed, ref } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
const props = withDefaults(
defineProps<{
title: string
label?: string
placeholder?: string
init?: string
confirmText?: string
}>(),
{ label: '이름', placeholder: '', init: '', confirmText: '저장' },
)
const emit = defineEmits<{
(e: 'save', name: string): void
(e: 'close'): void
}>()
const name = ref(props.init)
const valid = computed(() => {
const v = name.value.trim()
return v.length >= 1 && v.length <= 255
})
function submit(): void {
if (!valid.value) return
emit('save', name.value.trim())
}
</script>
<template>
<BaseModal
:title="title"
size="sm"
@close="emit('close')"
>
<label class="form-field"><span class="form-label"><span class="req">*</span> {{ label }}</span>
<input
v-model="name"
class="form-input"
:placeholder="placeholder"
maxlength="255"
autofocus
@keydown.enter="submit"
>
</label>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ confirmText }}
</button>
</template>
</BaseModal>
</template>
+72
View File
@@ -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<DriveList> {
return (await api.get('/drive', {
params: folderId ? { folderId } : {},
})) as unknown as DriveList
}
// 폴더
async function createFolder(
name: string,
parentId?: string,
): Promise<DriveFolder> {
return (await api.post('/drive/folders', {
name,
...(parentId ? { parentId } : {}),
})) as unknown as DriveFolder
}
async function renameFolder(id: string, name: string): Promise<DriveFolder> {
return (await api.patch(`/drive/folders/${id}`, { name })) as unknown as DriveFolder
}
async function deleteFolder(id: string): Promise<void> {
await api.delete(`/drive/folders/${id}`)
}
// 파일 — presigned 3단계 업로드(① presign → ② 직접 PUT[store] → ③ complete)
async function presignUpload(
payload: PresignUploadPayload,
): Promise<PresignUploadResult> {
return (await api.post('/drive/files/presign', payload)) as unknown as PresignUploadResult
}
async function completeUpload(fileId: string): Promise<DriveFile> {
return (await api.post(`/drive/files/${fileId}/complete`)) as unknown as DriveFile
}
async function getDownloadUrl(fileId: string): Promise<string> {
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<DriveFile> {
return (await api.patch(`/drive/files/${id}`, { name })) as unknown as DriveFile
}
async function deleteFile(id: string): Promise<void> {
await api.delete(`/drive/files/${id}`)
}
return {
list,
createFolder,
renameFolder,
deleteFolder,
presignUpload,
completeUpload,
getDownloadUrl,
renameFile,
deleteFile,
}
}
+20 -1
View File
@@ -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() {
</svg>
<span>화상회의</span>
</RouterLink>
<RouterLink
to="/drive"
class="nav-item"
title="드라이브"
:class="{ active: activeNav === 'drive' }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
</svg>
<span>드라이브</span>
</RouterLink>
</nav>
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
+764
View File
@@ -0,0 +1,764 @@
<script setup lang="ts">
// 드라이브(파일 저장소) — 폴더 탐색 + presigned 업/다운로드 + 이름변경/삭제
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { watch } from 'vue'
import { storeToRefs } from 'pinia'
import AppShell from '@/layouts/AppShell.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import DriveNameModal from '@/components/drive/DriveNameModal.vue'
import { useDriveStore } from '@/stores/drive.store'
import { useDialog } from '@/composables/useDialog'
import { fileBadge, formatBytes, relativeTimeKo } from '@/shared/utils/format'
import {
DRIVE_ACCEPT,
DRIVE_MAX_FILE_SIZE,
isAllowedDriveMime,
resolveMimeType,
} from '@/shared/utils/drive'
import type { DriveFile, DriveFolder } from '@/types/drive'
const route = useRoute()
const router = useRouter()
const store = useDriveStore()
const dialog = useDialog()
const { current, breadcrumb, folders, files, loading, uploads, itemCount, isEmpty } =
storeToRefs(store)
// 로컬 상태 — 검색(현재 폴더 내), 모달, 드래그, 파일 입력
const keyword = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
type ModalState =
| { kind: 'create-folder' }
| { kind: 'rename-folder'; id: string; name: string }
| { kind: 'rename-file'; id: string; name: string }
| null
const modal = ref<ModalState>(null)
// 드래그앤드롭(중첩 요소 깜빡임 방지용 depth 카운터)
const dragDepth = ref(0)
const isDragging = computed(() => dragDepth.value > 0)
// --- 검색 필터(클라이언트, 현재 폴더 한정) ---
const kw = computed(() => keyword.value.trim().toLowerCase())
const shownFolders = computed(() =>
kw.value ? folders.value.filter((f) => f.name.toLowerCase().includes(kw.value)) : folders.value,
)
const shownFiles = computed(() =>
kw.value ? files.value.filter((f) => f.name.toLowerCase().includes(kw.value)) : files.value,
)
const noResult = computed(
() => shownFolders.value.length === 0 && shownFiles.value.length === 0,
)
// 모달 표시 속성
const modalProps = computed(() => {
const m = modal.value
if (!m) return null
if (m.kind === 'create-folder') {
return { title: '새 폴더', label: '폴더 이름', placeholder: '폴더 이름', init: '', confirmText: '만들기' }
}
if (m.kind === 'rename-folder') {
return { title: '폴더 이름 변경', label: '폴더 이름', placeholder: '', init: m.name, confirmText: '저장' }
}
return { title: '파일 이름 변경', label: '파일 이름', placeholder: '', init: m.name, confirmText: '저장' }
})
// --- 탐색 ---
function openFolder(f: DriveFolder): void {
router.push(`/drive/${f.id}`)
}
function goRoot(): void {
router.push('/drive')
}
// --- 모달 열기 ---
function openCreateFolder(): void {
modal.value = { kind: 'create-folder' }
}
function openRenameFolder(f: DriveFolder): void {
modal.value = { kind: 'rename-folder', id: f.id, name: f.name }
}
function openRenameFile(f: DriveFile): void {
modal.value = { kind: 'rename-file', id: f.id, name: f.name }
}
async function onModalSave(name: string): Promise<void> {
const m = modal.value
if (!m) return
try {
if (m.kind === 'create-folder') await store.createFolder(name)
else if (m.kind === 'rename-folder') await store.renameFolder(m.id, name)
else await store.renameFile(m.id, name)
modal.value = null
} catch {
// 에러는 API 인터셉터가 다이얼로그로 표시 — 모달은 열어 둔다
}
}
// --- 삭제 ---
async function delFolder(f: DriveFolder): Promise<void> {
const ok = await dialog.confirm(
`'${f.name}' 폴더를 삭제할까요? 하위 폴더·파일이 모두 삭제되며 되돌릴 수 없습니다.`,
{ title: '폴더 삭제', confirmText: '삭제', variant: 'danger' },
)
if (!ok) return
await store.removeFolder(f.id)
}
async function delFile(f: DriveFile): Promise<void> {
const ok = await dialog.confirm(
`'${f.name}' 파일을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
{ title: '파일 삭제', confirmText: '삭제', variant: 'danger' },
)
if (!ok) return
await store.removeFile(f.id)
}
// --- 업로드 ---
function pickFiles(): void {
fileInput.value?.click()
}
function onFileInput(e: Event): void {
const input = e.target as HTMLInputElement
if (input.files) handleFiles(Array.from(input.files))
input.value = '' // 같은 파일 재선택 허용
}
// 검증 후 허용 파일만 업로드 시작(거부 항목은 모아서 안내)
function handleFiles(list: File[]): void {
const rejected: string[] = []
const accepted: { file: File; mime: string }[] = []
for (const f of list) {
const mime = resolveMimeType(f)
if (f.size === 0) rejected.push(`${f.name} (빈 파일)`)
else if (f.size > DRIVE_MAX_FILE_SIZE) rejected.push(`${f.name} (용량 초과 · 최대 ${formatBytes(DRIVE_MAX_FILE_SIZE)})`)
else if (!isAllowedDriveMime(mime)) rejected.push(`${f.name} (지원하지 않는 형식)`)
else accepted.push({ file: f, mime })
}
if (rejected.length) {
dialog.alert(`다음 파일은 업로드할 수 없습니다.\n\n${rejected.join('\n')}`, {
title: '업로드 불가',
variant: 'danger',
})
}
for (const a of accepted) void store.uploadOne(a.file, a.mime)
}
// 드래그앤드롭
function onDragEnter(): void {
dragDepth.value++
}
function onDragLeave(): void {
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
function onDrop(e: DragEvent): void {
dragDepth.value = 0
const dropped = e.dataTransfer?.files
if (dropped && dropped.length) handleFiles(Array.from(dropped))
}
// 라우트(folderId) 변경 시 목록 로드 — 진입/뒤로가기 포함
watch(
() => route.params.folderId,
(v) => {
const id = typeof v === 'string' && v ? v : null
keyword.value = ''
void store.fetchList(id)
},
{ immediate: true },
)
</script>
<template>
<AppShell>
<div
class="page"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<!-- 브레드크럼 -->
<nav class="crumb dr-crumb">
<template v-if="breadcrumb.length === 0">
<b>드라이브</b>
</template>
<template v-else>
<a
href="#"
@click.prevent="goRoot"
>드라이브</a>
<template
v-for="(c, i) in breadcrumb"
:key="c.id"
>
<span class="dr-sep"></span>
<RouterLink
v-if="i < breadcrumb.length - 1"
:to="`/drive/${c.id}`"
>
{{ c.name }}
</RouterLink>
<b v-else>{{ c.name }}</b>
</template>
</template>
</nav>
<div class="pagehead">
<h1>{{ current ? current.name : '드라이브' }}</h1>
<span class="count-pill">{{ itemCount }}</span>
</div>
<p class="lede">
파일과 폴더를 저장하고 관리하세요. 파일을 영역에 끌어다 놓아 업로드할 있습니다.
</p>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="11"
cy="11"
r="7"
/>
<path d="m21 21-4.3-4.3" />
</svg>
<input
v-model="keyword"
type="search"
placeholder="이 폴더에서 검색"
aria-label="현재 폴더 검색"
>
</div>
<button
class="btn"
type="button"
@click="openCreateFolder"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
<path d="M12 11v4M10 13h4" />
</svg>
폴더
</button>
<button
class="btn primary"
type="button"
@click="pickFiles"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 16V4M7 9l5-5 5 5" />
<path d="M5 20h14" />
</svg>
업로드
</button>
<input
ref="fileInput"
type="file"
multiple
:accept="DRIVE_ACCEPT"
class="dr-file-input"
@change="onFileInput"
>
</div>
<!-- 업로드 진행 패널 -->
<div
v-if="uploads.length"
class="dr-uploads"
>
<div
v-for="u in uploads"
:key="u.id"
class="dr-up"
:class="{ error: u.status === 'error' }"
>
<span class="dr-up-name">{{ u.name }}</span>
<span class="dr-up-bar">
<span
class="dr-up-fill"
:class="{ error: u.status === 'error' }"
:style="{ width: (u.status === 'error' ? 100 : u.progress) + '%' }"
/>
</span>
<span class="dr-up-status">
<template v-if="u.status === 'uploading'">{{ u.progress }}%</template>
<template v-else-if="u.status === 'completing'">확정 </template>
<template v-else>{{ u.error || '실패' }}</template>
</span>
<button
v-if="u.status === 'error'"
class="dr-up-x"
type="button"
aria-label="닫기"
@click="store.dismissUpload(u.id)"
>
</button>
</div>
</div>
<!-- 목록 -->
<div class="dr-listwrap">
<EmptyState v-if="loading && isEmpty">
불러오는
</EmptyState>
<EmptyState v-else-if="isEmpty">
폴더가 비어 있습니다. 파일을 끌어다 놓거나 업로드 파일을 추가해 보세요.
</EmptyState>
<EmptyState v-else-if="noResult">
검색 결과가 없습니다.
</EmptyState>
<div
v-else
class="list"
>
<!-- 폴더 -->
<div
v-for="f in shownFolders"
:key="'fd-' + f.id"
class="dr-row"
>
<button
class="dr-hit"
type="button"
@click="openFolder(f)"
>
<span class="dr-ico dr-folder">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
</span>
<span class="dr-main">
<span class="dr-name">{{ f.name }}</span>
<span class="dr-meta">폴더 · {{ relativeTimeKo(f.updatedAt) }}</span>
</span>
</button>
<span class="dr-actions">
<button
class="btn dr-act"
type="button"
title="이름 변경"
@click="openRenameFolder(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
<button
class="btn danger dr-act"
type="button"
title="삭제"
@click="delFolder(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" /></svg>
</button>
</span>
</div>
<!-- 파일 -->
<div
v-for="f in shownFiles"
:key="'fl-' + f.id"
class="dr-row"
>
<button
class="dr-hit"
type="button"
title="다운로드"
@click="store.download(f.id)"
>
<span
class="dr-ico dr-badge"
:class="fileBadge(f.kind).cls"
>{{ fileBadge(f.kind).label }}</span>
<span class="dr-main">
<span class="dr-name">{{ f.name }}</span>
<span class="dr-meta">{{ formatBytes(f.size) }} · {{ relativeTimeKo(f.updatedAt) }}</span>
</span>
</button>
<span class="dr-actions">
<button
class="btn dr-act"
type="button"
title="다운로드"
@click="store.download(f.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 4v12M7 11l5 5 5-5" /><path d="M5 20h14" /></svg>
</button>
<button
class="btn dr-act"
type="button"
title="이름 변경"
@click="openRenameFile(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
<button
class="btn danger dr-act"
type="button"
title="삭제"
@click="delFile(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" /></svg>
</button>
</span>
</div>
</div>
<!-- 드래그 오버레이 -->
<div
v-if="isDragging"
class="dr-drop"
>
<div class="dr-drop-card">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 16V4M7 9l5-5 5 5" />
<path d="M5 20h14" />
</svg>
<span>여기에 놓아 업로드</span>
</div>
</div>
</div>
</div>
<!-- 폴더 생성 / 이름 변경 모달 -->
<DriveNameModal
v-if="modal && modalProps"
:title="modalProps.title"
:label="modalProps.label"
:placeholder="modalProps.placeholder"
:init="modalProps.init"
:confirm-text="modalProps.confirmText"
@save="onModalSave"
@close="modal = null"
/>
</AppShell>
</template>
<style scoped>
/* 브레드크럼 링크 */
.dr-crumb a,
.dr-crumb :deep(a) {
color: var(--text-2);
text-decoration: none;
cursor: pointer;
}
.dr-crumb a:hover,
.dr-crumb :deep(a:hover) {
color: var(--accent);
}
.dr-sep {
opacity: 0.5;
}
/* 페이지 헤더 — 다른 목록 페이지(ProjectListPage)와 동일 스페이싱 */
.pagehead {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.5625rem 0 0.25rem;
}
.pagehead h1 {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.025rem;
white-space: nowrap;
}
.lede {
color: var(--text-2);
font-size: 0.844rem;
margin: 0.25rem 0 1.125rem;
}
/* 툴바 — 검색은 늘리고 버튼은 우측 */
.toolbar .search {
flex: 1 1 auto;
}
.dr-file-input {
display: none;
}
/* 업로드 진행 패널 */
.dr-uploads {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.dr-up {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
}
.dr-up.error {
border-color: var(--red-border);
background: var(--red-weak);
}
.dr-up-name {
flex: 0 0 14rem;
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
}
.dr-up-bar {
flex: 1 1 auto;
height: 0.5rem;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
}
.dr-up-fill {
display: block;
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.15s ease;
}
.dr-up-fill.error {
background: var(--red);
}
.dr-up-status {
flex: 0 0 auto;
min-width: 3rem;
text-align: right;
font-size: 0.8125rem;
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.dr-up.error .dr-up-status {
color: var(--red);
}
.dr-up-x {
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
font-size: 0.875rem;
padding: 0 0.25rem;
}
.dr-up-x:hover {
color: var(--text);
}
/* 목록 — 전역 .list 카드(radius 0.625rem + shadow-sm) 사용 */
.dr-listwrap {
position: relative;
}
.dr-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
}
.dr-row:first-child {
border-top: none;
}
.dr-row:hover {
background: #fafbfc;
}
.dr-hit {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 1rem;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
}
.dr-ico {
flex: 0 0 auto;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5625rem;
display: grid;
place-items: center;
}
.dr-ico svg {
width: 1.3125rem;
height: 1.3125rem;
}
.dr-folder {
color: var(--accent);
background: var(--accent-weak);
}
/* 파일 종류 배지(.fi-* 색상 — TaskDetail 첨부와 동일 팔레트) */
.dr-badge {
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.02em;
color: #fff;
}
.dr-badge.fi-img {
background: #2aa775;
}
.dr-badge.fi-pdf {
background: #e25950;
}
.dr-badge.fi-zip {
background: #8a64d6;
}
.dr-badge.fi-doc {
background: #3d8bf2;
}
.dr-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.dr-name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dr-row:hover .dr-name {
color: var(--accent);
}
.dr-meta {
font-size: 0.8125rem;
color: var(--text-3);
}
/* 행 액션 — 전역 .btn 아이콘 버튼(회의 로비와 동일 패턴) */
.dr-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.dr-act {
width: 2.25rem;
padding: 0;
justify-content: center;
}
.dr-act:hover {
border-color: var(--border-strong);
color: var(--text);
}
/* danger 변형(전역 .btn 보강 — 회의 로비와 동일) */
.btn.danger {
background: #fff;
border-color: var(--red-border);
color: var(--red);
}
.btn.danger:hover {
background: var(--red-weak);
border-color: var(--red);
color: var(--red);
}
@media (max-width: 40rem) {
.dr-up-name {
flex-basis: 8rem;
}
}
/* 드래그 오버레이 */
.dr-drop {
position: absolute;
inset: 0;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--accent-weak) 85%, transparent);
border: 2px dashed var(--accent-border);
display: grid;
place-items: center;
pointer-events: none;
z-index: 5;
}
.dr-drop-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--accent);
font-weight: 700;
}
.dr-drop-card svg {
width: 2rem;
height: 2rem;
}
</style>
+7
View File
@@ -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',
+83
View File
@@ -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<string> = new Set<string>([
// 이미지
'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',
])
// <input type="file" accept="..."> 용 확장자 목록
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<string, string> = {
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)
}
+177
View File
@@ -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<T extends { name: string }>(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<void> {
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<string | null>(null) // 현재 폴더(루트면 null)
const current = ref<DriveFolder | null>(null)
const breadcrumb = ref<DriveCrumb[]>([])
const folders = ref<DriveFolder[]>([])
const files = ref<DriveFile[]>([])
const loading = ref(false)
const uploads = ref<UploadItem[]>([])
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<void> {
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<void> {
const created = await drive.createFolder(name, folderId.value ?? undefined)
folders.value = [...folders.value, created].sort(byName)
}
async function renameFolder(id: string, name: string): Promise<void> {
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<void> {
await drive.deleteFolder(id)
folders.value = folders.value.filter((f) => f.id !== id)
}
// --- 파일 ---
async function renameFile(id: string, name: string): Promise<void> {
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<void> {
await drive.deleteFile(id)
files.value = files.value.filter((f) => f.id !== id)
}
// 다운로드 — 단기 presigned URL 발급 후 브라우저 다운로드 트리거
async function download(id: string): Promise<void> {
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<void> {
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,
}
})
+75
View File
@@ -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<string, string>
expiresAt: string
}
// presign 발급 응답
export interface PresignUploadResult {
fileId: string
file: DriveFile
upload: PresignedUpload
}
// presign 요청 본문
export interface PresignUploadPayload {
filename: string
contentType: string
size: number
folderId?: string
}