feat(ui): Phase D.3 — multi-select + batch actions (pLimit)
- DocumentTable/DocumentCard: selectable/selectedIds/onselectionchange props
* Table: 왼쪽 6px 너비 체크박스 컬럼
* Card: 좌상단 absolute 체크박스 (hover 또는 selected 시 표시)
* 체크박스 onclick stopPropagation으로 행 select와 분리
- documents/+page.svelte:
* selectedIds = $state(new Set()), URL/필터 변경 시 자동 초기화
* sticky 선택 toolbar (selection > 0): N건 / 전체 선택 / 선택 해제 /
일괄 도메인 / 일괄 태그 / 일괄 삭제
* 50건 상한 UI 가드 (초과 시 경고 + 모든 bulk 버튼 disabled)
* Bulk modals:
- 일괄 도메인: Select (Knowledge/* 6종 + Reference)
- 일괄 태그: TextInput (기존 ai_tags에 추가, 중복 skip)
- 일괄 삭제: ConfirmDialog (delete_file=true)
* runBulk 헬퍼: pLimit(5) + Promise.allSettled로 concurrency 제한,
성공/실패 카운트 toast (5대 원칙 #4)
* TODO(backend): POST /documents/batch-update — 단일 트랜잭션으로 교체
검증:
- npm run build 통과 (새 경고 없음, label → span 교체로 a11y clean)
- npm run lint:tokens 231 유지 (신규 코드 위반 0)
- 기존 pLimit.ts (Phase A 머지) 재사용, 외부 의존성 없음
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={handleClick}
|
||||
aria-label={doc.title || '문서 선택'}
|
||||
class="flex items-stretch bg-surface border rounded-lg hover:border-accent transition-colors group w-full text-left overflow-hidden
|
||||
{selected ? 'border-accent bg-accent/5' : 'border-default'}"
|
||||
<div
|
||||
class="relative flex items-stretch bg-surface border rounded-lg hover:border-accent transition-colors group overflow-hidden
|
||||
{selected ? 'border-accent bg-accent/5' : 'border-default'}
|
||||
{isChecked ? 'border-accent bg-accent/10' : ''}"
|
||||
>
|
||||
{#if selectable}
|
||||
<span
|
||||
class="absolute top-2 left-2 z-10 flex items-center justify-center transition-opacity
|
||||
{isChecked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onchange={toggleSelection}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="h-4 w-4 accent-accent cursor-pointer"
|
||||
aria-label="{doc.title || '문서'} 선택"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClick}
|
||||
aria-label={doc.title || '문서 선택'}
|
||||
class="flex items-stretch w-full text-left"
|
||||
>
|
||||
<!-- domain 색상 바 -->
|
||||
<div class="w-1 shrink-0 rounded-l-lg" style="background: {domainColor}"></div>
|
||||
|
||||
<!-- 콘텐츠 -->
|
||||
<div class="flex items-start gap-3 p-3 flex-1 min-w-0">
|
||||
<div class="flex items-start gap-3 p-3 flex-1 min-w-0 {selectable ? 'pl-8' : ''}">
|
||||
<!-- 포맷 아이콘 -->
|
||||
<div class="shrink-0 mt-0.5 text-dim group-hover:text-accent">
|
||||
<FormatIcon format={doc.file_format} size={18} />
|
||||
@@ -113,4 +153,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
<div class="w-full">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-default text-[10px] text-dim uppercase tracking-wider">
|
||||
{#if selectable}
|
||||
<div class="w-6 shrink-0" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{#each columns as col}
|
||||
<button
|
||||
onclick={() => toggleSort(col.key)}
|
||||
@@ -110,11 +129,29 @@
|
||||
|
||||
<!-- 행 -->
|
||||
{#each sortedItems() as doc}
|
||||
<button
|
||||
onclick={() => handleClick(doc)}
|
||||
class="flex items-center gap-1 px-2 py-1.5 w-full text-left border-b border-default/30 hover:bg-surface transition-colors group
|
||||
{selectedId === doc.id ? 'bg-accent/5 border-l-2 border-l-accent' : ''}"
|
||||
{@const isChecked = selectedIds.has(doc.id)}
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 py-1.5 w-full border-b border-default/30 hover:bg-surface transition-colors group
|
||||
{selectedId === doc.id ? 'bg-accent/5 border-l-2 border-l-accent' : ''}
|
||||
{isChecked ? 'bg-accent/10' : ''}"
|
||||
>
|
||||
{#if selectable}
|
||||
<span class="w-6 shrink-0 flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onchange={(e) => toggleSelection(doc.id, e)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="h-3.5 w-3.5 accent-accent cursor-pointer"
|
||||
aria-label="{doc.title || '문서'} 선택"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleClick(doc)}
|
||||
class="flex-1 flex items-center gap-1 text-left min-w-0"
|
||||
>
|
||||
<!-- 이름 -->
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="w-1 h-4 rounded-full shrink-0" style="background: {getDomainColor(doc.ai_domain)}"></span>
|
||||
@@ -137,6 +174,7 @@
|
||||
<div class="w-20 text-[10px] text-dim text-right">
|
||||
{formatDate(doc.created_at)}
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
<!-- D.3: 선택 toolbar (selection.size > 0 시) -->
|
||||
{#if selectionCount > 0}
|
||||
<div
|
||||
class="sticky top-0 z-dropdown flex flex-wrap items-center gap-2 px-4 py-2 shrink-0 bg-accent/10 border-y border-accent/30 backdrop-blur-sm"
|
||||
>
|
||||
<span class="text-xs font-medium {selectionOverLimit ? 'text-error' : 'text-accent'}">
|
||||
{selectionCount}건 선택
|
||||
{#if selectionOverLimit}
|
||||
<span class="ml-1">(최대 {MAX_SELECTION}건 초과)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={selectAll}
|
||||
disabled={allVisibleSelected}
|
||||
class="text-[11px] text-dim hover:text-text disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
전체 선택
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearSelection}
|
||||
class="text-[11px] text-dim hover:text-text"
|
||||
>
|
||||
선택 해제
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={FolderTree}
|
||||
disabled={selectionOverLimit || bulkBusy}
|
||||
onclick={() => ui.openModal('bulk-domain')}
|
||||
>
|
||||
일괄 도메인
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Tag}
|
||||
disabled={selectionOverLimit || bulkBusy}
|
||||
onclick={() => ui.openModal('bulk-tag')}
|
||||
>
|
||||
일괄 태그
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={Trash2}
|
||||
disabled={selectionOverLimit || bulkBusy}
|
||||
onclick={() => ui.openModal('bulk-delete')}
|
||||
>
|
||||
일괄 삭제
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 스크롤 영역 (목록) -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<!-- 결과 헤더 — 카운트만. 필터는 위의 칩 row가 표시. -->
|
||||
@@ -483,6 +646,9 @@
|
||||
{items}
|
||||
selectedId={selectedDoc?.id}
|
||||
onselect={selectDoc}
|
||||
selectable
|
||||
{selectedIds}
|
||||
onselectionchange={handleSelectionChange}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
@@ -492,6 +658,9 @@
|
||||
showDomain={!filterDomain}
|
||||
selected={selectedDoc?.id === doc.id}
|
||||
onselect={selectDoc}
|
||||
selectable
|
||||
{selectedIds}
|
||||
onselectionchange={handleSelectionChange}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -560,3 +729,57 @@
|
||||
/>
|
||||
{/if}
|
||||
</Drawer>
|
||||
|
||||
<!-- D.3: Bulk action modals -->
|
||||
<Modal id="bulk-domain" title="일괄 도메인 변경" size="sm">
|
||||
<p class="text-xs text-dim mb-3">{selectionCount}건의 문서에 새 도메인을 지정합니다.</p>
|
||||
<Select
|
||||
bind:value={bulkDomainValue}
|
||||
options={DOMAIN_OPTIONS}
|
||||
placeholder="도메인 선택"
|
||||
label="도메인"
|
||||
/>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('bulk-domain')}>취소</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!bulkDomainValue}
|
||||
loading={bulkBusy}
|
||||
onclick={bulkApplyDomain}
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal id="bulk-tag" title="일괄 태그 추가" size="sm">
|
||||
<p class="text-xs text-dim mb-3">{selectionCount}건의 문서에 태그를 추가합니다. 이미 같은 태그가 있으면 건너뜁니다.</p>
|
||||
<TextInput
|
||||
bind:value={bulkTagValue}
|
||||
placeholder="태그 입력"
|
||||
label="태그"
|
||||
/>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('bulk-tag')}>취소</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!bulkTagValue.trim()}
|
||||
loading={bulkBusy}
|
||||
onclick={bulkAddTag}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
id="bulk-delete"
|
||||
title="선택한 문서 삭제"
|
||||
message="{selectionCount}건의 문서를 원본 파일과 함께 삭제합니다. 되돌릴 수 없습니다."
|
||||
confirmLabel="삭제"
|
||||
tone="danger"
|
||||
loading={bulkBusy}
|
||||
onconfirm={bulkDelete}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user