feat(ui): Phase E — PreviewPanel 분할 + detail inline + viewer Tabs
E.1 PreviewPanel 7개 editors/* 분할:
- frontend/src/lib/components/editors/ 신설 (7개 컴포넌트):
* NoteEditor — 사용자 메모 편집
* EditUrlEditor — 외부 편집 URL (Synology Drive 등)
* TagsEditor — 태그 추가/삭제
* AIClassificationEditor — AI 분류 read-only 표시
(breadcrumb + document_type + confidence tone Badge + importance)
* FileInfoView — 파일 메타 dl
* ProcessingStatusView — 파이프라인 단계 status dl
* DocumentDangerZone — 삭제 (ConfirmDialog 프리미티브 + id 고유화)
- PreviewPanel.svelte 344줄 → 60줄 얇은 wrapper로 축소
(header + 7개 editors 조합만)
- DocumentMetaRail (D.1)과 detail 페이지(E.2)가 동일 editors 재사용
E.2 detail 페이지 inline 편집:
- documents/[id]/+page.svelte: 기존 read-only 메타 패널 전면 교체
- 오른쪽 aside = 7개 editors 스택 (Card 프리미티브로 감쌈)
- 왼쪽 affordance row: Synology 편집 / 다운로드 / 링크 복사
- 삭제는 DocumentDangerZone이 담당 (ondelete → goto /documents)
- loading/error 상태도 EmptyState 프리미티브로 교체
- marked/DOMPurify renderer 유지, viewer 분기 그대로
E.3 관련 문서 stub:
- detail 페이지 오른쪽 aside에 "관련 문서" Card
- EmptyState "추후 지원" + TODO(backend) GET /documents/{id}/related
E.4 DocumentViewer Tabs 프리미티브:
- Markdown 편집 모드의 편집/미리보기 토글 → Tabs 프리미티브
- 키보드 nav (←→/Home/End), ARIA tablist/tab/tabpanel 자동 적용
검증:
- npm run build 통과 (editors/* 7개 모두 clean, $state 초기값
warning은 빈 문자열로 초기화하고 $effect로 doc 동기화해 해결)
- npm run lint:tokens 204 → 168 (detail 페이지 + PreviewPanel 전면
token 기반 재작성으로 -36)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,26 @@
|
||||
<script>
|
||||
// Phase E.2 — detail 페이지 inline 편집.
|
||||
// 기존 read-only 메타 패널(L138–201)을 editors/* 스택으로 교체.
|
||||
// + E.3 관련 문서 stub, + 헤더 affordance row.
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import TagPill from '$lib/components/TagPill.svelte';
|
||||
import { ExternalLink, Download, Link2, FileText } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
|
||||
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
|
||||
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
|
||||
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
|
||||
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
|
||||
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
|
||||
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
@@ -20,21 +35,21 @@
|
||||
let doc = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null); // 'not_found' | 'network' | null
|
||||
let rawMarkdown = $state(''); // hotfix: extracted_text 없을 때 원본 fetch fallback
|
||||
let rawMarkdown = $state(''); // fallback: extracted_text 없을 때 원본 .md
|
||||
|
||||
let docId = $derived($page.params.id);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
doc = await api(`/documents/${docId}`);
|
||||
// hotfix: markdown 계열인데 extracted_text 없으면 원본 .md 파일 직접 가져오기
|
||||
// (split view의 DocumentViewer와 동일한 동작 — A-8 후 보고된 fallback 표시 문제 해결)
|
||||
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
|
||||
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
|
||||
try {
|
||||
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
|
||||
if (resp.ok) rawMarkdown = await resp.text();
|
||||
} catch (e) { rawMarkdown = ''; }
|
||||
} catch (e) {
|
||||
rawMarkdown = '';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error = err?.status === 404 ? 'not_found' : 'network';
|
||||
@@ -43,7 +58,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
|
||||
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';
|
||||
@@ -53,152 +70,195 @@
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(format)) return 'image';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// E.2 affordance row 핸들러
|
||||
function copyLink() {
|
||||
const url = `${window.location.origin}/documents/${docId}`;
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => addToast('success', '링크 복사됨'))
|
||||
.catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
const url = `/api/documents/${docId}/file?token=${getAccessToken()}`;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc?.title || `document-${docId}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
function handleDocDelete() {
|
||||
addToast('success', '문서가 삭제되어 목록으로 이동합니다.');
|
||||
goto('/documents');
|
||||
}
|
||||
</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 class="flex items-center gap-2 text-sm mb-4 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a>
|
||||
<span class="text-faint">/</span>
|
||||
<span class="truncate max-w-md text-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 class="max-w-6xl mx-auto">
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
</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>
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 찾을 수 없습니다"
|
||||
description="삭제되었거나 접근 권한이 없을 수 있습니다."
|
||||
>
|
||||
<Button variant="ghost" size="sm" href="/documents">목록으로 돌아가기</Button>
|
||||
</EmptyState>
|
||||
{: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>
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 불러올 수 없습니다"
|
||||
description="네트워크 오류가 발생했습니다."
|
||||
>
|
||||
<Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button>
|
||||
</EmptyState>
|
||||
{: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 || rawMarkdown || '*텍스트 추출 대기 중*')}
|
||||
</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'}
|
||||
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 왼쪽 (2/3) — 뷰어 + affordance row -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<!-- Affordance row -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
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>
|
||||
Synology 편집
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadFile}>
|
||||
다운로드
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
|
||||
링크 복사
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 뷰어 -->
|
||||
<Card class="min-h-[500px]">
|
||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||
<div class="prose prose-invert prose-sm max-w-none markdown-body">
|
||||
{@html renderMd(doc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
|
||||
</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}
|
||||
{: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'}
|
||||
<EmptyState
|
||||
icon={ExternalLink}
|
||||
title="Synology Office 문서"
|
||||
description="외부 편집기에서 열어야 합니다."
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
href={doc.edit_url || 'https://link.hyungi.net'}
|
||||
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}
|
||||
>
|
||||
새 창에서 열기
|
||||
</Button>
|
||||
</EmptyState>
|
||||
{:else if viewerType === 'article'}
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text mb-3">{doc.title}</h1>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||
<span>출처: {doc.source_channel}</span>
|
||||
<span class="text-faint">·</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}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
원문 보기
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="인앱 미리보기 미지원"
|
||||
description="포맷: {doc.file_format}"
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
</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>
|
||||
<!-- 오른쪽 (1/3) — editors stack -->
|
||||
<aside class="space-y-4">
|
||||
<Card>
|
||||
<NoteEditor {doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<EditUrlEditor {doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<TagsEditor {doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<AIClassificationEditor {doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<FileInfoView {doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<ProcessingStatusView {doc} />
|
||||
</Card>
|
||||
|
||||
<!-- 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}
|
||||
<!-- E.3 관련 문서 stub -->
|
||||
<Card>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">관련 문서</h4>
|
||||
<!-- TODO(backend): GET /documents/{id}/related?limit=10 (벡터 유사도) -->
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="추후 지원"
|
||||
description="관련 문서 추천은 backend 연동 후 제공됩니다."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 태그 -->
|
||||
{#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>
|
||||
<Card>
|
||||
<DocumentDangerZone {doc} ondelete={handleDocDelete} />
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user