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:
Hyungi Ahn
2026-04-03 09:05:40 +09:00
parent 1a207be261
commit cb8a846773
4 changed files with 151 additions and 30 deletions

View 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>

View 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} />

View 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}

View File

@@ -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>