feat: 드라이브(파일 저장소) 프론트엔드 UI 구현
폴더 탐색·드래그앤드롭 업로드(presigned 3단계)·다운로드·이름변경/삭제, 라우트(/drive)·사이드바 네비 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user