Files
hyungi_document_server/frontend/src/lib/components/UploadDropzone.svelte
Hyungi Ahn 563f54d7d5 fix(upload): 100MB 초과 파일 사전 차단 + NAS file_watcher 안내
home-caddy 의 request_body max_size 100MB 한도 (infra_inventory.md D8 /
Cloudflare 섹션 참조) 에 걸리는 업로드 시 사용자 콘솔에 의미 없는 413 만
나오던 문제. 이제:

1. 클라이언트 사전 검사: 100MB 초과 파일은 업로드 자체를 시도 안 하고
   즉시 toast 로 안내 (파일명 + 크기 + NAS 우회 경로)
2. 서버 fallback: 사전 검사를 통과했으나 인프라 한도에 걸려 413 응답이
   오는 경우에도 같은 안내 메시지

NAS 우회 경로: NAS 의 PKM 폴더에 직접 두면 file_watcher 가 5분 간격으로
자동 인덱싱. 이게 100MB+ 파일의 정식 처리 경로 (infra_inventory.md
Cloudflare 섹션의 413 정책).
2026-04-09 14:26:18 +09:00

160 lines
4.9 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { Upload } from 'lucide-svelte';
let { onupload = () => {} } = $props();
// home-caddy `request_body max_size 100MB` (infra_inventory.md D8 / Cloudflare 섹션 참조).
// 100MB 초과 파일은 NAS PKM 폴더 직접 마운트 → file_watcher 5분 간격 자동 인덱싱 경로 사용.
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const NAS_FALLBACK_HINT = '대용량 파일은 NAS의 PKM 폴더에 직접 두면 file_watcher 가 5분 이내에 자동 인덱싱합니다.';
let dragging = $state(false);
let uploading = $state(false);
let uploadFiles = $state([]);
let dragCounter = 0;
onMount(() => {
function onDragEnter(e) {
e.preventDefault();
e.stopPropagation();
dragCounter++;
dragging = true;
}
function onDragOver(e) {
e.preventDefault();
e.stopPropagation();
}
function onDragLeave(e) {
e.preventDefault();
e.stopPropagation();
dragCounter--;
if (dragCounter <= 0) {
dragging = false;
dragCounter = 0;
}
}
function onDrop(e) {
e.preventDefault();
e.stopPropagation();
dragging = false;
dragCounter = 0;
handleFiles(e.dataTransfer?.files);
}
window.addEventListener('dragenter', onDragEnter);
window.addEventListener('dragover', onDragOver);
window.addEventListener('dragleave', onDragLeave);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragenter', onDragEnter);
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('dragleave', onDragLeave);
window.removeEventListener('drop', onDrop);
};
});
async function handleFiles(fileList) {
const allFiles = Array.from(fileList || []);
if (allFiles.length === 0) return;
// 사전 크기 검사 — 100MB 초과는 즉시 차단 + NAS file_watcher 안내
const tooLarge = allFiles.filter(f => f.size > MAX_UPLOAD_BYTES);
const files = allFiles.filter(f => f.size <= MAX_UPLOAD_BYTES);
if (tooLarge.length > 0) {
const names = tooLarge
.map(f => `${f.name} (${(f.size / 1024 / 1024).toFixed(1)}MB)`)
.join(', ');
addToast(
'error',
`100MB 초과 파일은 업로드 불가 (${tooLarge.length}건: ${names}). ${NAS_FALLBACK_HINT}`,
10000
);
}
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++;
// 서버 측 413 (사전 검사 통과했지만 인프라 한도에 걸린 경우)
if (err && err.status === 413) {
addToast(
'error',
`${files[i].name}: 서버 거절 (Payload Too Large). ${NAS_FALLBACK_HINT}`,
10000
);
}
}
uploadFiles = [...uploadFiles];
}
if (success > 0) {
addToast('success', `${success}건 업로드 완료${failed > 0 ? `, ${failed}건 실패` : ''}`);
onupload();
} else {
addToast('error', `업로드 실패 (${failed}건)`);
}
setTimeout(() => {
uploading = false;
uploadFiles = [];
}, 3000);
}
</script>
<!-- 전체 페이지 드래그 오버레이 -->
{#if dragging}
<div class="fixed inset-0 z-50 bg-accent/10 border-2 border-dashed border-accent flex items-center justify-center">
<div class="bg-surface rounded-xl px-8 py-6 shadow-xl text-center">
<Upload size={32} class="mx-auto mb-2 text-accent" />
<p class="text-sm font-medium text-accent">여기에 파일을 놓으세요</p>
</div>
</div>
{/if}
<!-- 업로드 진행 상태 -->
{#if uploading && uploadFiles.length > 0}
<div class="mb-3 bg-surface border border-default rounded-lg p-3">
<p class="text-xs 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-success' :
f.status === 'failed' ? 'text-error' :
f.status === 'uploading' ? 'text-accent' :
'text-dim'
}>
{f.status === 'done' ? '✓' : f.status === 'failed' ? '✗' : f.status === 'uploading' ? '↑' : '…'}
</span>
</div>
{/each}
</div>
</div>
{/if}