Files
hyungi_document_server/frontend/src/routes/documents/[id]/+page.svelte
2026-04-06 14:01:39 +09:00

195 lines
8.0 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api, getAccessToken } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import TagPill from '$lib/components/TagPill.svelte';
marked.use({ mangle: false, headerIds: false });
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
let doc = $state(null);
let loading = $state(true);
let error = $state(null); // 'not_found' | 'network' | null
let docId = $derived($page.params.id);
onMount(async () => {
try {
doc = await api(`/documents/${docId}`);
} catch (err) {
error = err?.status === 404 ? 'not_found' : 'network';
} finally {
loading = false;
}
});
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
function getViewerType(format) {
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
if (['hwp', 'hwpx'].includes(format)) return 'hwp-markdown';
if (['odoc', 'osheet'].includes(format)) return 'synology';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(format)) return 'image';
return 'unsupported';
}
</script>
<div class="p-4 lg:p-6">
<!-- breadcrumb -->
<div class="flex items-center gap-2 text-sm mb-4 text-[var(--text-dim)]">
<a href="/documents" class="hover:text-[var(--text)]">문서</a>
<span>/</span>
<span class="truncate max-w-md text-[var(--text)]">{doc?.title || '로딩...'}</span>
</div>
{#if loading}
<div class="max-w-6xl mx-auto p-6">
<div class="bg-[var(--surface)] rounded-xl p-6 border border-[var(--border)] animate-pulse h-96"></div>
</div>
{:else if error === 'not_found'}
<div class="text-center py-20 text-[var(--text-dim)]">
<p class="text-lg mb-2">문서를 찾을 수 없습니다</p>
<a href="/documents" class="text-sm text-[var(--accent)] hover:underline">목록으로 돌아가기</a>
</div>
{:else if error === 'network'}
<div class="text-center py-20 text-[var(--text-dim)]">
<p class="text-lg mb-2">문서를 불러올 수 없습니다</p>
<button onclick={() => location.reload()} class="text-sm text-[var(--accent)] hover:underline">다시 시도</button>
</div>
{:else if doc}
<div class="max-w-6xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 뷰어 (2/3) -->
<div class="lg:col-span-2 bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 min-h-[500px]">
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
<div class="prose prose-invert prose-sm max-w-none">
{@html renderMd(doc.extracted_text || '*텍스트 추출 대기 중*')}
</div>
{:else if viewerType === 'pdf'}
<iframe
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
class="w-full h-[80vh] rounded"
title={doc.title}
></iframe>
{:else if viewerType === 'image'}
<img src="/api/documents/{doc.id}/file?token={getAccessToken()}" alt={doc.title} class="max-w-full rounded" />
{:else if viewerType === 'synology'}
<div class="text-center py-10">
<p class="text-[var(--text-dim)] mb-4">Synology Office 문서</p>
<a
href={doc.edit_url || 'https://link.hyungi.net'}
target="_blank"
class="px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)]"
>
새 창에서 열기
</a>
</div>
{:else if viewerType === 'article'}
<!-- 뉴스 전용 뷰어 -->
<div>
<h1 class="text-xl font-bold mb-3">{doc.title}</h1>
<div class="flex items-center gap-2 mb-4 text-xs text-[var(--text-dim)]">
<span>출처: {doc.source_channel}</span>
<span>·</span>
<span>{new Date(doc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric' })}</span>
</div>
{#if doc.extracted_text}
<div class="markdown-body mb-6">
{@html renderMd(doc.extracted_text)}
</div>
{/if}
{#if doc.edit_url}
<a
href={doc.edit_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)]"
>원문 보기 →</a>
{/if}
</div>
{:else}
<div class="text-center py-10">
<p class="text-[var(--text-dim)] mb-2">이 문서 형식은 인앱 미리보기를 지원하지 않습니다</p>
<p class="text-xs text-[var(--text-dim)]">포맷: {doc.file_format}</p>
</div>
{/if}
</div>
<!-- 메타데이터 패널 (1/3) -->
<div class="space-y-4">
<!-- 기본 정보 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">문서 정보</h3>
<dl class="space-y-2 text-sm">
<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>{doc.file_size ? (doc.file_size / 1024).toFixed(1) + ' KB' : '-'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">도메인</dt>
<dd>{doc.ai_domain || '미분류'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">출처</dt>
<dd>{doc.source_channel || '-'}</dd>
</div>
</dl>
</div>
<!-- AI 요약 -->
{#if doc.ai_summary}
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">AI 요약</h3>
<div class="text-sm leading-relaxed markdown-body">{@html renderMd(doc.ai_summary)}</div>
</div>
{/if}
<!-- 태그 -->
{#if doc.ai_tags?.length > 0}
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">태그</h3>
<div class="flex flex-wrap gap-1.5">
{#each doc.ai_tags as tag}
<TagPill {tag} />
{/each}
</div>
</div>
{/if}
<!-- 가공 이력 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">가공 이력</h3>
<dl class="space-y-2 text-xs">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">텍스트 추출</dt>
<dd>{doc.extracted_at ? new Date(doc.extracted_at).toLocaleDateString('ko') : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">AI 분류</dt>
<dd>{doc.ai_processed_at ? new Date(doc.ai_processed_at).toLocaleDateString('ko') : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">벡터 임베딩</dt>
<dd>{doc.embedded_at ? new Date(doc.embedded_at).toLocaleDateString('ko') : '대기'}</dd>
</div>
</dl>
</div>
</div>
</div>
{/if}
</div>