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>
|
||||
|
||||
Reference in New Issue
Block a user