feat: Phase 1B — DocumentCard/TagPill/FormatIcon 컴포넌트
- DocumentCard: 포맷 아이콘, 제목+요약, domain 경로, 태그 pill, data_origin 배지, 날짜, 파일 크기 - TagPill: 계층별 색상 (@amber, #blue, $green, !red), 클릭→필터 - FormatIcon: 파일 포맷별 lucide 아이콘 매핑 - documents 페이지에서 DocumentCard 컴포넌트 사용 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
79
frontend/src/lib/components/DocumentCard.svelte
Normal file
79
frontend/src/lib/components/DocumentCard.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import FormatIcon from './FormatIcon.svelte';
|
||||
import TagPill from './TagPill.svelte';
|
||||
|
||||
let { doc, showDomain = true } = $props();
|
||||
|
||||
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`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="flex items-start gap-3 p-3 bg-[var(--surface)] border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition-colors group"
|
||||
>
|
||||
<!-- 포맷 아이콘 -->
|
||||
<div class="shrink-0 mt-0.5 text-[var(--text-dim)] group-hover:text-[var(--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-[var(--accent)]">
|
||||
{doc.title || '제목 없음'}
|
||||
</p>
|
||||
|
||||
<!-- AI 요약 -->
|
||||
{#if doc.ai_summary}
|
||||
<p class="text-xs text-[var(--text-dim)] truncate mt-0.5">{doc.ai_summary.slice(0, 100)}</p>
|
||||
{/if}
|
||||
|
||||
<!-- 메타 행: domain 경로 + 태그 -->
|
||||
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{#if showDomain && doc.ai_domain}
|
||||
<span class="text-[10px] text-[var(--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.score !== undefined}
|
||||
<span class="text-[var(--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-[var(--text-dim)]">{formatDate(doc.created_at)}</span>
|
||||
{#if doc.file_size}
|
||||
<span class="text-[var(--text-dim)]">{formatSize(doc.file_size)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
30
frontend/src/lib/components/FormatIcon.svelte
Normal file
30
frontend/src/lib/components/FormatIcon.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import { FileText, File, Image, FileSpreadsheet, Presentation, Mail, FileCode, FileQuestion } from 'lucide-svelte';
|
||||
|
||||
let { format = '', size = 16 } = $props();
|
||||
|
||||
const ICON_MAP = {
|
||||
pdf: FileText,
|
||||
hwp: FileText,
|
||||
hwpx: FileText,
|
||||
md: FileCode,
|
||||
txt: File,
|
||||
csv: FileSpreadsheet,
|
||||
json: FileCode,
|
||||
xml: FileCode,
|
||||
html: FileCode,
|
||||
jpg: Image,
|
||||
jpeg: Image,
|
||||
png: Image,
|
||||
gif: Image,
|
||||
bmp: Image,
|
||||
tiff: Image,
|
||||
eml: Mail,
|
||||
odoc: FileText,
|
||||
osheet: FileSpreadsheet,
|
||||
};
|
||||
|
||||
let Icon = $derived(ICON_MAP[format?.toLowerCase()] || FileQuestion);
|
||||
</script>
|
||||
|
||||
<svelte:component this={Icon} {size} />
|
||||
40
frontend/src/lib/components/TagPill.svelte
Normal file
40
frontend/src/lib/components/TagPill.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { tag = '', clickable = true } = $props();
|
||||
|
||||
// 계층별 색상
|
||||
function getColor(t) {
|
||||
if (t.startsWith('@상태/') || t.startsWith('@')) return { bg: 'bg-amber-900/30', text: 'text-amber-400' };
|
||||
if (t.startsWith('#주제/') || t.startsWith('#')) return { bg: 'bg-blue-900/30', text: 'text-blue-400' };
|
||||
if (t.startsWith('$유형/') || t.startsWith('$')) return { bg: 'bg-emerald-900/30', text: 'text-emerald-400' };
|
||||
if (t.startsWith('!우선순위/') || t.startsWith('!')) return { bg: 'bg-red-900/30', text: 'text-red-400' };
|
||||
return { bg: 'bg-[var(--border)]', text: 'text-[var(--text-dim)]' };
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
if (!clickable) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.set('tag', tag);
|
||||
params.delete('page');
|
||||
goto(`/documents?${params}`, { noScroll: true });
|
||||
}
|
||||
|
||||
let color = $derived(getColor(tag));
|
||||
</script>
|
||||
|
||||
{#if clickable}
|
||||
<button
|
||||
onclick={handleClick}
|
||||
class="inline-flex text-[10px] px-1.5 py-0.5 rounded {color.bg} {color.text} hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="inline-flex text-[10px] px-1.5 py-0.5 rounded {color.bg} {color.text}">
|
||||
{tag}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/ui';
|
||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||
|
||||
let documents = $state([]);
|
||||
let total = $state(0);
|
||||
@@ -182,36 +183,7 @@
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#each items as doc}
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="flex items-center justify-between p-3 bg-[var(--surface)] border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition-colors group"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase shrink-0 font-mono">{doc.file_format}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate group-hover:text-[var(--accent)]">{doc.title || '제목 없음'}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
{#if doc.ai_domain}
|
||||
<span class="text-[10px] text-[var(--text-dim)]">{doc.ai_domain.replace('Knowledge/', '')}{doc.ai_sub_group ? ` / ${doc.ai_sub_group}` : ''}</span>
|
||||
{/if}
|
||||
{#if doc.ai_summary}
|
||||
<span class="text-[10px] text-[var(--text-dim)] truncate max-w-xs hidden sm:inline">· {doc.ai_summary?.slice(0, 80)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 ml-3">
|
||||
{#if doc.ai_tags?.length}
|
||||
<span class="text-[10px] text-[var(--accent)] hidden md:inline">{doc.ai_tags[0]}</span>
|
||||
{/if}
|
||||
{#if doc.score !== undefined}
|
||||
<span class="text-[10px] text-[var(--accent)]">{(doc.score * 100).toFixed(0)}%</span>
|
||||
{/if}
|
||||
{#if doc.data_origin}
|
||||
<span class="text-[10px] 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}
|
||||
</div>
|
||||
</a>
|
||||
<DocumentCard {doc} showDomain={!filterDomain} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user