feat: 드래그 앤 드롭 업로드 (UploadDropzone)
- 파일 드래그 시 전체 페이지 오버레이 - 순차 업로드 + 파일별 진행 상태 - 성공/실패 토스트 + 목록 자동 새로고침 - documents 페이지에 통합 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
114
frontend/src/lib/components/UploadDropzone.svelte
Normal file
114
frontend/src/lib/components/UploadDropzone.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { addToast } from '$lib/stores/ui';
|
||||||
|
import { Upload } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { onupload = () => {} } = $props();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let uploading = $state(false);
|
||||||
|
let uploadFiles = $state([]);
|
||||||
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
function handleDragEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter++;
|
||||||
|
dragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragging = false;
|
||||||
|
dragCounter = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
dragCounter = 0;
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer?.files || []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
uploading = true;
|
||||||
|
uploadFiles = files.map(f => ({ name: f.name, status: 'pending' }));
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
uploadFiles[i].status = 'uploading';
|
||||||
|
uploadFiles = [...uploadFiles];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', files[i]);
|
||||||
|
await api('/documents/', { method: 'POST', body: formData });
|
||||||
|
uploadFiles[i].status = 'done';
|
||||||
|
success++;
|
||||||
|
} catch (err) {
|
||||||
|
uploadFiles[i].status = 'failed';
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
uploadFiles = [...uploadFiles];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success > 0) {
|
||||||
|
addToast('success', `${success}건 업로드 완료${failed > 0 ? `, ${failed}건 실패` : ''}`);
|
||||||
|
onupload();
|
||||||
|
} else {
|
||||||
|
addToast('error', `업로드 실패 (${failed}건)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
uploading = false;
|
||||||
|
uploadFiles = [];
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:document
|
||||||
|
on:dragenter={handleDragEnter}
|
||||||
|
on:dragleave={handleDragLeave}
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:drop={handleDrop}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 전체 페이지 드래그 오버레이 -->
|
||||||
|
{#if dragging}
|
||||||
|
<div class="fixed inset-0 z-50 bg-[var(--accent)]/10 border-2 border-dashed border-[var(--accent)] flex items-center justify-center pointer-events-none">
|
||||||
|
<div class="bg-[var(--surface)] rounded-xl px-8 py-6 shadow-xl text-center">
|
||||||
|
<Upload size={32} class="mx-auto mb-2 text-[var(--accent)]" />
|
||||||
|
<p class="text-sm font-medium text-[var(--accent)]">여기에 파일을 놓으세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 업로드 진행 상태 -->
|
||||||
|
{#if uploading && uploadFiles.length > 0}
|
||||||
|
<div class="mb-3 bg-[var(--surface)] border border-[var(--border)] rounded-lg p-3">
|
||||||
|
<p class="text-xs text-[var(--text-dim)] mb-2">업로드 중...</p>
|
||||||
|
<div class="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{#each uploadFiles as f}
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="truncate">{f.name}</span>
|
||||||
|
<span class={
|
||||||
|
f.status === 'done' ? 'text-[var(--success)]' :
|
||||||
|
f.status === 'failed' ? 'text-[var(--error)]' :
|
||||||
|
f.status === 'uploading' ? 'text-[var(--accent)]' :
|
||||||
|
'text-[var(--text-dim)]'
|
||||||
|
}>
|
||||||
|
{f.status === 'done' ? '✓' : f.status === 'failed' ? '✗' : f.status === 'uploading' ? '↑' : '…'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||||
import PreviewPanel from '$lib/components/PreviewPanel.svelte';
|
import PreviewPanel from '$lib/components/PreviewPanel.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
|
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
|
||||||
|
|
||||||
let documents = $state([]);
|
let documents = $state([]);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
@@ -135,6 +136,9 @@
|
|||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- 상단: 문서 목록 (30% when viewer active, 100% otherwise) -->
|
<!-- 상단: 문서 목록 (30% when viewer active, 100% otherwise) -->
|
||||||
<div class="overflow-y-auto px-4 py-3 {selectedDoc ? 'h-[30%] shrink-0 border-b border-[var(--border)]' : 'flex-1'}">
|
<div class="overflow-y-auto px-4 py-3 {selectedDoc ? 'h-[30%] shrink-0 border-b border-[var(--border)]' : 'flex-1'}">
|
||||||
|
<!-- 업로드 드롭존 -->
|
||||||
|
<UploadDropzone onupload={loadDocuments} />
|
||||||
|
|
||||||
<!-- 검색바 + 정보 버튼 -->
|
<!-- 검색바 + 정보 버튼 -->
|
||||||
<div class="flex gap-2 mb-3">
|
<div class="flex gap-2 mb-3">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user