feat: 2단계 — DEVONthink 스타일 테이블 뷰 + 카드/테이블 토글
- DocumentTable.svelte: 컬럼 정렬(stable sort), domain 색상 바, 포맷 아이콘 - 뷰 모드 토글 버튼 (카드 ↔ 테이블) - localStorage로 뷰 모드 + 정렬 상태 기억 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
142
frontend/src/lib/components/DocumentTable.svelte
Normal file
142
frontend/src/lib/components/DocumentTable.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import FormatIcon from './FormatIcon.svelte';
|
||||
|
||||
let { items = [], selectedId = null, onselect = null } = $props();
|
||||
let sortKey = $state('created_at');
|
||||
let sortOrder = $state('desc');
|
||||
|
||||
// localStorage에서 정렬 상태 복원
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('tableSort');
|
||||
if (saved) {
|
||||
try {
|
||||
const { key, order } = JSON.parse(saved);
|
||||
sortKey = key;
|
||||
sortOrder = order;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(key) {
|
||||
if (sortKey === key) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortKey = key;
|
||||
sortOrder = 'asc';
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('tableSort', JSON.stringify({ key: sortKey, order: sortOrder }));
|
||||
}
|
||||
}
|
||||
|
||||
// stable sort
|
||||
let sortedItems = $derived(() => {
|
||||
const arr = [...items];
|
||||
arr.sort((a, b) => {
|
||||
let va = a[sortKey] ?? '';
|
||||
let vb = b[sortKey] ?? '';
|
||||
if (typeof va === 'string') va = va.toLowerCase();
|
||||
if (typeof vb === 'string') vb = vb.toLowerCase();
|
||||
if (va === vb) return a.id - b.id;
|
||||
if (sortOrder === 'asc') return va > vb ? 1 : -1;
|
||||
return va < vb ? 1 : -1;
|
||||
});
|
||||
return arr;
|
||||
});
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
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`;
|
||||
}
|
||||
|
||||
function handleClick(doc) {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||
goto(`/documents/${doc.id}`);
|
||||
return;
|
||||
}
|
||||
if (onselect) onselect(doc);
|
||||
else goto(`/documents/${doc.id}`);
|
||||
}
|
||||
|
||||
const DOMAIN_COLORS = {
|
||||
'Philosophy': 'var(--domain-philosophy)',
|
||||
'Language': 'var(--domain-language)',
|
||||
'Engineering': 'var(--domain-engineering)',
|
||||
'Industrial_Safety': 'var(--domain-safety)',
|
||||
'Programming': 'var(--domain-programming)',
|
||||
'General': 'var(--domain-general)',
|
||||
'Reference': 'var(--domain-reference)',
|
||||
};
|
||||
|
||||
function getDomainColor(domain) {
|
||||
if (!domain) return 'var(--border)';
|
||||
const top = domain.split('/')[0];
|
||||
return DOMAIN_COLORS[top] || 'var(--border)';
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'title', label: '이름', flex: 'flex-1' },
|
||||
{ key: 'ai_domain', label: '분류', width: 'w-48' },
|
||||
{ key: 'document_type', label: '타입', width: 'w-24' },
|
||||
{ key: 'file_size', label: '크기', width: 'w-20' },
|
||||
{ key: 'created_at', label: '등록일', width: 'w-20' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-[var(--border)] text-[10px] text-[var(--text-dim)] uppercase tracking-wider">
|
||||
{#each columns as col}
|
||||
<button
|
||||
onclick={() => toggleSort(col.key)}
|
||||
class="flex items-center gap-1 {col.flex || col.width || ''} px-1 hover:text-[var(--text)] transition-colors text-left"
|
||||
>
|
||||
{col.label}
|
||||
{#if sortKey === col.key}
|
||||
<span class="text-[var(--accent)]">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 행 -->
|
||||
{#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-[var(--border)]/30 hover:bg-[var(--surface)] transition-colors group
|
||||
{selectedId === doc.id ? 'bg-[var(--accent)]/5 border-l-2 border-l-[var(--accent)]' : ''}"
|
||||
>
|
||||
<!-- 이름 -->
|
||||
<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>
|
||||
<FormatIcon format={doc.file_format} size={14} />
|
||||
<span class="text-xs truncate group-hover:text-[var(--accent)]">{doc.title || '제목 없음'}</span>
|
||||
</div>
|
||||
<!-- 분류 -->
|
||||
<div class="w-48 text-[10px] text-[var(--text-dim)] truncate">
|
||||
{doc.ai_domain?.replace('Industrial_Safety/', 'IS/') || '-'}
|
||||
</div>
|
||||
<!-- 타입 -->
|
||||
<div class="w-24 text-[10px] text-[var(--text-dim)]">
|
||||
{doc.document_type || doc.file_format?.toUpperCase() || '-'}
|
||||
</div>
|
||||
<!-- 크기 -->
|
||||
<div class="w-20 text-[10px] text-[var(--text-dim)] text-right">
|
||||
{formatSize(doc.file_size)}
|
||||
</div>
|
||||
<!-- 등록일 -->
|
||||
<div class="w-20 text-[10px] text-[var(--text-dim)] text-right">
|
||||
{formatDate(doc.created_at)}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -4,11 +4,20 @@
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/ui';
|
||||
import { Info } from 'lucide-svelte';
|
||||
import { List, LayoutGrid } from 'lucide-svelte';
|
||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||
import DocumentTable from '$lib/components/DocumentTable.svelte';
|
||||
import PreviewPanel from '$lib/components/PreviewPanel.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
|
||||
|
||||
// 뷰 모드 (localStorage 기억)
|
||||
let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card');
|
||||
function toggleViewMode() {
|
||||
viewMode = viewMode === 'card' ? 'table' : 'card';
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem('viewMode', viewMode);
|
||||
}
|
||||
|
||||
let documents = $state([]);
|
||||
let total = $state(0);
|
||||
let loading = $state(true);
|
||||
@@ -168,6 +177,18 @@
|
||||
<option value="trgm">부분매칭</option>
|
||||
<option value="vector">의미검색</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={toggleViewMode}
|
||||
class="p-1.5 rounded-lg border border-[var(--border)] hover:border-[var(--accent)] text-[var(--text-dim)] hover:text-[var(--accent)] transition-colors"
|
||||
aria-label="뷰 모드 전환"
|
||||
title={viewMode === 'card' ? '테이블 뷰' : '카드 뷰'}
|
||||
>
|
||||
{#if viewMode === 'card'}
|
||||
<List size={16} />
|
||||
{:else}
|
||||
<LayoutGrid size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{#if selectedDoc}
|
||||
<button
|
||||
onclick={() => infoPanelOpen = !infoPanelOpen}
|
||||
@@ -225,16 +246,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each items as doc}
|
||||
<DocumentCard
|
||||
{doc}
|
||||
showDomain={!filterDomain}
|
||||
selected={selectedDoc?.id === doc.id}
|
||||
onselect={selectDoc}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if viewMode === 'table'}
|
||||
<DocumentTable
|
||||
{items}
|
||||
selectedId={selectedDoc?.id}
|
||||
onselect={selectDoc}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each items as doc}
|
||||
<DocumentCard
|
||||
{doc}
|
||||
showDomain={!filterDomain}
|
||||
selected={selectedDoc?.id === doc.id}
|
||||
onselect={selectDoc}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !searchResults && totalPages > 1}
|
||||
<div class="flex justify-center gap-1 mt-4">
|
||||
|
||||
Reference in New Issue
Block a user