- 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>
158 lines
4.9 KiB
Svelte
158 lines
4.9 KiB
Svelte
<script>
|
|
import { goto } from '$app/navigation';
|
|
import FormatIcon from './FormatIcon.svelte';
|
|
import TagPill from './TagPill.svelte';
|
|
|
|
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 '';
|
|
const d = new Date(dateStr);
|
|
const now = new Date();
|
|
const diff = now - d;
|
|
if (diff < 86400000) return '오늘';
|
|
if (diff < 172800000) return '어제';
|
|
if (diff < 604800000) return `${Math.floor(diff / 86400000)}일 전`;
|
|
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (!bytes) return '';
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
return `${(bytes / 1048576).toFixed(1)}MB`;
|
|
}
|
|
|
|
const DOMAIN_COLORS = {
|
|
'Knowledge/Philosophy': 'var(--domain-philosophy)',
|
|
'Knowledge/Language': 'var(--domain-language)',
|
|
'Knowledge/Engineering': 'var(--domain-engineering)',
|
|
'Knowledge/Industrial_Safety': 'var(--domain-safety)',
|
|
'Knowledge/Programming': 'var(--domain-programming)',
|
|
'Knowledge/General': 'var(--domain-general)',
|
|
'Reference': 'var(--domain-reference)',
|
|
};
|
|
|
|
let domainColor = $derived(DOMAIN_COLORS[doc.ai_domain] || 'var(--border)');
|
|
|
|
// 반응형: CSS media query matchMedia 사용
|
|
let isDesktop = $state(typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true);
|
|
if (typeof window !== 'undefined') {
|
|
window.matchMedia('(min-width: 1024px)').addEventListener('change', (e) => isDesktop = e.matches);
|
|
}
|
|
|
|
function handleClick() {
|
|
if (!isDesktop) {
|
|
goto(`/documents/${doc.id}`);
|
|
return;
|
|
}
|
|
if (onselect) {
|
|
onselect(doc);
|
|
} else {
|
|
goto(`/documents/${doc.id}`);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<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 {selectable ? 'pl-8' : ''}">
|
|
<!-- 포맷 아이콘 -->
|
|
<div class="shrink-0 mt-0.5 text-dim group-hover:text-accent">
|
|
<FormatIcon format={doc.file_format} size={18} />
|
|
</div>
|
|
|
|
<!-- 메인 콘텐츠 -->
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium truncate group-hover:text-accent">
|
|
{doc.title || '제목 없음'}
|
|
</p>
|
|
{#if doc.ai_summary}
|
|
<p class="text-xs text-dim truncate mt-0.5">{doc.ai_summary.replace(/[*#_`~]/g, '').slice(0, 100)}</p>
|
|
{/if}
|
|
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
|
|
{#if showDomain && doc.ai_domain}
|
|
<span class="text-[10px] text-dim">
|
|
{doc.ai_domain.replace('Knowledge/', '')}{doc.ai_sub_group ? ` / ${doc.ai_sub_group}` : ''}
|
|
</span>
|
|
{/if}
|
|
{#if doc.ai_tags?.length}
|
|
<div class="flex gap-1">
|
|
{#each doc.ai_tags.slice(0, 3) as tag}
|
|
<TagPill {tag} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 우측 메타 -->
|
|
<div class="shrink-0 flex flex-col items-end gap-1 text-[10px]">
|
|
{#if doc.source_channel === 'news' && doc.edit_url}
|
|
<span class="text-blue-400">📰</span>
|
|
{/if}
|
|
{#if doc.score !== undefined}
|
|
<span class="text-accent font-medium">{(doc.score * 100).toFixed(0)}%</span>
|
|
{/if}
|
|
{#if doc.data_origin}
|
|
<span class="px-1.5 py-0.5 rounded {doc.data_origin === 'work' ? 'bg-blue-900/30 text-blue-400' : 'bg-gray-800 text-gray-400'}">
|
|
{doc.data_origin}
|
|
</span>
|
|
{/if}
|
|
<span class="text-dim">{formatDate(doc.created_at)}</span>
|
|
{#if doc.file_size}
|
|
<span class="text-dim">{formatSize(doc.file_size)}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|