feat: Phase 1C — 프리뷰 패널 (문서 선택 시 우측 표시)
- PreviewPanel: AI 요약, 태그, 메타 정보, 처리 상태 표시 - DocumentCard: 선택 모드 지원 (클릭→프리뷰, 더블클릭 불필요) - 3-pane 완성: sidebar | document list | preview panel - 필터 변경 시 선택 자동 해제 - 데스크톱만 표시 (모바일은 detail 페이지로 이동) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import FormatIcon from './FormatIcon.svelte';
|
||||
import TagPill from './TagPill.svelte';
|
||||
|
||||
let { doc, showDomain = true } = $props();
|
||||
let { doc, showDomain = true, selected = false, onselect = null } = $props();
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
@@ -23,9 +23,13 @@
|
||||
}
|
||||
</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"
|
||||
{@const Tag = onselect ? 'button' : 'a'}
|
||||
<svelte:element
|
||||
this={Tag}
|
||||
href={onselect ? undefined : `/documents/${doc.id}`}
|
||||
onclick={onselect ? (e) => { e.preventDefault(); onselect(doc); } : undefined}
|
||||
class="flex items-start gap-3 p-3 bg-[var(--surface)] border rounded-lg hover:border-[var(--accent)] transition-colors group w-full text-left
|
||||
{selected ? 'border-[var(--accent)] bg-[var(--accent)]/5' : 'border-[var(--border)]'}"
|
||||
>
|
||||
<!-- 포맷 아이콘 -->
|
||||
<div class="shrink-0 mt-0.5 text-[var(--text-dim)] group-hover:text-[var(--accent)]">
|
||||
@@ -76,4 +80,4 @@
|
||||
<span class="text-[var(--text-dim)]">{formatSize(doc.file_size)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</svelte:element>
|
||||
|
||||
119
frontend/src/lib/components/PreviewPanel.svelte
Normal file
119
frontend/src/lib/components/PreviewPanel.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script>
|
||||
import { X, ExternalLink } from 'lucide-svelte';
|
||||
import FormatIcon from './FormatIcon.svelte';
|
||||
import TagPill from './TagPill.svelte';
|
||||
|
||||
let { doc, onclose } = $props();
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', 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>
|
||||
|
||||
<aside class="h-full flex flex-col bg-[var(--sidebar-bg)] border-l border-[var(--border)] overflow-y-auto">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--border)] shrink-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<FormatIcon format={doc.file_format} size={16} />
|
||||
<span class="text-sm font-medium truncate">{doc.title || '제목 없음'}</span>
|
||||
</div>
|
||||
<button onclick={onclose} class="p-1 rounded hover:bg-[var(--surface)] text-[var(--text-dim)]" aria-label="닫기">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-4 space-y-4">
|
||||
<!-- 전체 보기 버튼 -->
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="flex items-center justify-center gap-2 w-full px-3 py-2 bg-[var(--accent)] text-white text-sm rounded-lg hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
전체 보기
|
||||
</a>
|
||||
|
||||
<!-- AI 요약 -->
|
||||
{#if doc.ai_summary}
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">AI 요약</h4>
|
||||
<p class="text-sm leading-relaxed">{doc.ai_summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 태그 -->
|
||||
{#if doc.ai_tags?.length}
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">태그</h4>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each doc.ai_tags as tag}
|
||||
<TagPill {tag} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 메타 정보 -->
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">정보</h4>
|
||||
<dl class="space-y-1.5 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">포맷</dt>
|
||||
<dd class="uppercase">{doc.file_format}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">크기</dt>
|
||||
<dd>{formatSize(doc.file_size)}</dd>
|
||||
</div>
|
||||
{#if doc.ai_domain}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">분류</dt>
|
||||
<dd class="text-right">{doc.ai_domain.replace('Knowledge/', '')}{doc.ai_sub_group ? ` / ${doc.ai_sub_group}` : ''}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.source_channel}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">출처</dt>
|
||||
<dd>{doc.source_channel}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.data_origin}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">구분</dt>
|
||||
<dd>{doc.data_origin}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">등록일</dt>
|
||||
<dd>{formatDate(doc.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- 처리 상태 -->
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">처리</h4>
|
||||
<dl class="space-y-1 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">추출</dt>
|
||||
<dd class={doc.extracted_at ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>{doc.extracted_at ? '완료' : '대기'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">분류</dt>
|
||||
<dd class={doc.ai_processed_at ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>{doc.ai_processed_at ? '완료' : '대기'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">임베딩</dt>
|
||||
<dd class={doc.embedded_at ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>{doc.embedded_at ? '완료' : '대기'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -4,8 +4,10 @@
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/ui';
|
||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||
import PreviewPanel from '$lib/components/PreviewPanel.svelte';
|
||||
|
||||
let documents = $state([]);
|
||||
let selectedDoc = $state(null);
|
||||
let total = $state(0);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state('');
|
||||
@@ -29,6 +31,8 @@
|
||||
searchQuery = urlQ;
|
||||
searchMode = urlMode;
|
||||
|
||||
selectedDoc = null; // 필터 변경 시 선택 해제
|
||||
|
||||
if (urlQ) {
|
||||
doSearch(urlQ, urlMode);
|
||||
} else {
|
||||
@@ -112,9 +116,15 @@
|
||||
let totalPages = $derived(Math.ceil(total / 20));
|
||||
let items = $derived(searchResults || documents);
|
||||
let hasActiveFilters = $derived(!!filterDomain || !!filterSubGroup || !!searchQuery);
|
||||
|
||||
function selectDoc(doc) {
|
||||
selectedDoc = selectedDoc?.id === doc.id ? null : doc;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<div class="flex h-full">
|
||||
<!-- 문서 목록 영역 -->
|
||||
<div class="flex-1 overflow-y-auto p-4 lg:p-6">
|
||||
<!-- 검색바 + 필터 상태 -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input
|
||||
@@ -183,7 +193,12 @@
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#each items as doc}
|
||||
<DocumentCard {doc} showDomain={!filterDomain} />
|
||||
<DocumentCard
|
||||
{doc}
|
||||
showDomain={!filterDomain}
|
||||
selected={selectedDoc?.id === doc.id}
|
||||
onselect={selectDoc}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -203,3 +218,11 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 프리뷰 패널 (데스크톱만) -->
|
||||
{#if selectedDoc}
|
||||
<div class="hidden lg:block shrink-0" style="width: 360px">
|
||||
<PreviewPanel doc={selectedDoc} onclose={() => selectedDoc = null} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user