diff --git a/frontend/src/lib/components/DocumentCard.svelte b/frontend/src/lib/components/DocumentCard.svelte index 1af2a20..39b7347 100644 --- a/frontend/src/lib/components/DocumentCard.svelte +++ b/frontend/src/lib/components/DocumentCard.svelte @@ -3,7 +3,26 @@ import FormatIcon from './FormatIcon.svelte'; import TagPill from './TagPill.svelte'; - let { doc, showDomain = true, selected = false, onselect = null } = $props(); + let { + doc, + showDomain = true, + selected = false, + onselect = null, + // D.3 다중 선택 + selectable = false, + selectedIds = new Set(), + onselectionchange = null, + } = $props(); + + let isChecked = $derived(selectedIds.has(doc.id)); + + function toggleSelection(e) { + e?.stopPropagation?.(); + const next = new Set(selectedIds); + if (next.has(doc.id)) next.delete(doc.id); + else next.add(doc.id); + onselectionchange?.(next); + } function formatDate(dateStr) { if (!dateStr) return ''; @@ -54,17 +73,38 @@ } - + + diff --git a/frontend/src/lib/components/DocumentTable.svelte b/frontend/src/lib/components/DocumentTable.svelte index 1783ebf..5921beb 100644 --- a/frontend/src/lib/components/DocumentTable.svelte +++ b/frontend/src/lib/components/DocumentTable.svelte @@ -2,7 +2,23 @@ import { goto } from '$app/navigation'; import FormatIcon from './FormatIcon.svelte'; - let { items = [], selectedId = null, onselect = null } = $props(); + let { + items = [], + selectedId = null, + onselect = null, + // D.3 다중 선택 + selectable = false, + selectedIds = new Set(), + onselectionchange = null, + } = $props(); + + function toggleSelection(id, e) { + e?.stopPropagation?.(); + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + onselectionchange?.(next); + } let sortKey = $state('created_at'); let sortOrder = $state('desc'); @@ -95,6 +111,9 @@
+ {#if selectable} + + {/if} {#each columns as col} + +
{/each}
diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 982ebd6..146885e 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -3,20 +3,39 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { Info, List, LayoutGrid, ChevronLeft, X, Plus } from 'lucide-svelte'; + import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree } from 'lucide-svelte'; import DocumentCard from '$lib/components/DocumentCard.svelte'; import DocumentTable from '$lib/components/DocumentTable.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import DocumentMetaRail from '$lib/components/DocumentMetaRail.svelte'; import UploadDropzone from '$lib/components/UploadDropzone.svelte'; import Drawer from '$lib/components/ui/Drawer.svelte'; + import Modal from '$lib/components/ui/Modal.svelte'; + import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte'; + import Button from '$lib/components/ui/Button.svelte'; + import Select from '$lib/components/ui/Select.svelte'; + import TextInput from '$lib/components/ui/TextInput.svelte'; import { ui } from '$lib/stores/uiState.svelte'; import { useIsXl } from '$lib/composables/useMedia.svelte'; + import { pLimit } from '$lib/utils/pLimit'; // D.2: 필터 칩에서 사용할 format 화이트리스트. // 백엔드 `GET /documents/?format=...` 파라미터가 이미 받음. const FORMATS = ['pdf', 'hwp', 'hwpx', 'md', 'docx', 'xlsx', 'png', 'jpg']; + // D.3: bulk 도메인 옵션 (Sidebar DOMAIN_COLORS와 일치) + const DOMAIN_OPTIONS = [ + { value: 'Knowledge/Philosophy', label: 'Knowledge / Philosophy' }, + { value: 'Knowledge/Language', label: 'Knowledge / Language' }, + { value: 'Knowledge/Engineering', label: 'Knowledge / Engineering' }, + { value: 'Knowledge/Industrial_Safety', label: 'Knowledge / Industrial Safety' }, + { value: 'Knowledge/Programming', label: 'Knowledge / Programming' }, + { value: 'Knowledge/General', label: 'Knowledge / General' }, + { value: 'Reference', label: 'Reference' }, + ]; + + const MAX_SELECTION = 50; + // 뷰 모드 (localStorage 기억) let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card'); function toggleViewMode() { @@ -85,6 +104,12 @@ let tagPopoverOpen = $state(false); let formatPopoverOpen = $state(false); + // D.3: 다중 선택 + bulk action 상태 + let selectedIds = $state(new Set()); + let bulkDomainValue = $state(''); + let bulkTagValue = $state(''); + let bulkBusy = $state(false); + $effect(() => { const _p = currentPage; const _d = filterDomain; @@ -98,6 +123,7 @@ searchQuery = urlQ; searchMode = urlMode; selectedDoc = null; + selectedIds = new Set(); // D.3: URL/필터 변경 시 선택 초기화 if (ui.isDrawerOpen('meta')) ui.closeDrawer(); if (urlQ) { @@ -211,6 +237,79 @@ selectedDoc = selectedDoc?.id === doc.id ? null : doc; } + // D.3: 다중 선택 핸들러 + function handleSelectionChange(next) { + selectedIds = next; + } + + function selectAll() { + selectedIds = new Set(items.map((d) => d.id)); + } + + function clearSelection() { + selectedIds = new Set(); + } + + // D.3: bulk action 실행기 — pLimit(5) + Promise.allSettled. + // TODO(backend): POST /documents/batch-update — 단일 트랜잭션으로 교체. + async function runBulk(label, taskFn) { + const ids = [...selectedIds]; + if (ids.length === 0 || ids.length > MAX_SELECTION) return; + bulkBusy = true; + const limit = pLimit(5); + const results = await Promise.allSettled( + ids.map((id) => limit(() => taskFn(id))) + ); + bulkBusy = false; + const success = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.length - success; + if (failed > 0) { + addToast('error', `${label}: ${failed}건 실패`); + } + if (success > 0) { + addToast('success', `${label}: ${success}건 완료`); + } + clearSelection(); + loadDocuments(); + } + + async function bulkApplyDomain() { + if (!bulkDomainValue) return; + const domain = bulkDomainValue; + await runBulk('도메인 변경', (id) => + api(`/documents/${id}`, { + method: 'PATCH', + body: JSON.stringify({ ai_domain: domain }), + }) + ); + bulkDomainValue = ''; + ui.closeModal('bulk-domain'); + } + + async function bulkAddTag() { + const tag = bulkTagValue.trim(); + if (!tag) return; + await runBulk('태그 추가', async (id) => { + const doc = items.find((d) => d.id === id); + const existing = Array.isArray(doc?.ai_tags) ? doc.ai_tags : []; + if (existing.includes(tag)) return; // 이미 있으면 skip + const nextTags = [...existing, tag]; + return api(`/documents/${id}`, { + method: 'PATCH', + body: JSON.stringify({ ai_tags: nextTags }), + }); + }); + bulkTagValue = ''; + ui.closeModal('bulk-tag'); + } + + async function bulkDelete() { + await runBulk('삭제', (id) => + api(`/documents/${id}?delete_file=true`, { method: 'DELETE' }) + ); + // ConfirmDialog가 자체적으로 닫힘 + } + function handleKeydown(e) { if (e.key === 'Escape') { ui.handleEscape(); // drawer/modal stack 우선순위로 중앙 처리 @@ -223,6 +322,13 @@ !!filterDomain || !!filterSubGroup || !!filterTag || !!filterSource || !!filterFormat || !!searchQuery ); + // D.3 derived + let selectionCount = $derived(selectedIds.size); + let selectionOverLimit = $derived(selectionCount > MAX_SELECTION); + let allVisibleSelected = $derived( + items.length > 0 && items.every((d) => selectedIds.has(d.id)) + ); + // D.2: 현재 결과 집계 — 상위 20개 태그 (클라이언트 집계, 백엔드 변경 없음). let topTags = $derived.by(() => { const counts = new Map(); @@ -449,6 +555,63 @@ {/if} + + {#if selectionCount > 0} +
+ + {selectionCount}건 선택 + {#if selectionOverLimit} + (최대 {MAX_SELECTION}건 초과) + {/if} + + + +
+ + + +
+ {/if} +
@@ -483,6 +646,9 @@ {items} selectedId={selectedDoc?.id} onselect={selectDoc} + selectable + {selectedIds} + onselectionchange={handleSelectionChange} /> {:else}
@@ -492,6 +658,9 @@ showDomain={!filterDomain} selected={selectedDoc?.id === doc.id} onselect={selectDoc} + selectable + {selectedIds} + onselectionchange={handleSelectionChange} /> {/each}
@@ -560,3 +729,57 @@ /> {/if} + + + +

{selectionCount}건의 문서에 새 도메인을 지정합니다.

+