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 { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/ui';
|
import { addToast } from '$lib/stores/ui';
|
||||||
|
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||||
|
|
||||||
let documents = $state([]);
|
let documents = $state([]);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
@@ -182,36 +183,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
{#each items as doc}
|
{#each items as doc}
|
||||||
<a
|
<DocumentCard {doc} showDomain={!filterDomain} />
|
||||||
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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user