feat(documents): 3-pane 중앙 리더 markdown-first 일원화 (DocumentViewer) #30
@@ -3,10 +3,14 @@
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
|
||||
import { ExternalLink, Save } from 'lucide-svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import { getViewerType } from '$lib/utils/viewerType';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
|
||||
// marked + sanitize
|
||||
// 편집 미리보기 전용 plain marked (본문 렌더는 MarkdownDoc 가 담당).
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text), {
|
||||
@@ -22,33 +26,19 @@
|
||||
let loading = $state(true);
|
||||
let viewerType = $state('none');
|
||||
|
||||
// Markdown 편집
|
||||
// Markdown 편집 (md/txt — extracted_text 가 표시·편집 단일 필드)
|
||||
let editMode = $state(false);
|
||||
let editContent = $state('');
|
||||
let editTab = $state('edit');
|
||||
let saving = $state(false);
|
||||
let rawMarkdown = $state('');
|
||||
|
||||
function getViewerType(format) {
|
||||
if (['md', 'txt'].includes(format)) return 'markdown';
|
||||
if (format === 'pdf') return 'pdf';
|
||||
if (['hwp', 'hwpx'].includes(format)) return 'preview-pdf';
|
||||
if (['odoc', 'osheet', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp'].includes(format)) return 'preview-pdf';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'].includes(format)) return 'image';
|
||||
if (['csv', 'json', 'xml', 'html'].includes(format)) return 'text';
|
||||
if (['dwg', 'dxf'].includes(format)) return 'cad';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
|
||||
|
||||
function getEditInfo(doc) {
|
||||
// DB에 저장된 편집 URL 우선
|
||||
if (doc.edit_url) return { url: doc.edit_url, label: '편집' };
|
||||
// ODF 포맷 → Synology Drive
|
||||
if (ODF_FORMATS.includes(doc.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
|
||||
// CAD
|
||||
if (['dwg', 'dxf'].includes(doc.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
|
||||
function getEditInfo(d) {
|
||||
if (d.edit_url) return { url: d.edit_url, label: '편집' };
|
||||
if (ODF_FORMATS.includes(d.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
|
||||
if (['dwg', 'dxf'].includes(d.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,18 +51,17 @@
|
||||
|
||||
async function loadFullDoc(id) {
|
||||
loading = true;
|
||||
rawMarkdown = '';
|
||||
try {
|
||||
fullDoc = await api(`/documents/${id}`);
|
||||
viewerType = fullDoc.source_channel === 'news' ? 'article' : getViewerType(fullDoc.file_format);
|
||||
viewerType = getViewerType(fullDoc.file_format, fullDoc.source_channel);
|
||||
|
||||
// Markdown: extracted_text 없으면 원본 파일 직접 가져오기
|
||||
// 본문 markdown(md/txt) 인데 extracted_text 가 비면 원본 파일 직접 로드.
|
||||
if (viewerType === 'markdown' && !fullDoc.extracted_text) {
|
||||
try {
|
||||
const resp = await fetch(`/api/documents/${id}/file?token=${getAccessToken()}`);
|
||||
if (resp.ok) rawMarkdown = await resp.text();
|
||||
} catch (e) { rawMarkdown = ''; }
|
||||
} else {
|
||||
rawMarkdown = '';
|
||||
}
|
||||
} catch (err) {
|
||||
fullDoc = null;
|
||||
@@ -82,6 +71,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// PDF markdown-first: marker 가 만든 canonical md_content 가 있으면 기본으로 그것을 보여주고
|
||||
// "PDF 원본" 토글 제공. lastDocId 는 prop(fullDoc.id) 로 키잉 — 3-pane 은 라우트 리마운트가
|
||||
// 없어 page.params 가드는 no-op 이 된다.
|
||||
let pdfViewMode = $state('markdown');
|
||||
let lastDocId = $state(null);
|
||||
let canShowMarkdown = $derived(
|
||||
!!(isMdSuccess(fullDoc?.md_status) && fullDoc?.md_content?.trim())
|
||||
);
|
||||
$effect(() => {
|
||||
if (!fullDoc) return;
|
||||
if (fullDoc.id !== lastDocId) {
|
||||
lastDocId = fullDoc.id;
|
||||
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
|
||||
}
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
|
||||
});
|
||||
|
||||
function startEdit() {
|
||||
editContent = fullDoc?.extracted_text || rawMarkdown || '';
|
||||
editMode = true;
|
||||
@@ -113,6 +119,7 @@
|
||||
}
|
||||
|
||||
let editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null);
|
||||
const PROSE = 'prose prose-invert prose-base max-w-none';
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
@@ -125,38 +132,22 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{#if viewerType === 'markdown'}
|
||||
{#if editMode}
|
||||
<button
|
||||
onclick={saveContent}
|
||||
disabled={saving}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<button onclick={saveContent} disabled={saving}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover disabled:opacity-50">
|
||||
<Save size={12} /> {saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editMode = false}
|
||||
class="px-2 py-1 text-xs text-dim hover:text-text"
|
||||
>취소</button>
|
||||
<button onclick={() => editMode = false} class="px-2 py-1 text-xs text-dim hover:text-text">취소</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={startEdit}
|
||||
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
|
||||
>편집</button>
|
||||
<button onclick={startEdit} class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">편집</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if editInfo}
|
||||
<a
|
||||
href={editInfo.url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
|
||||
>
|
||||
<a href={editInfo.url} target="_blank" rel="noopener"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">
|
||||
<ExternalLink size={12} /> {editInfo.label}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/{fullDoc.id}"
|
||||
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
|
||||
>전체 보기</a>
|
||||
<a href="/documents/{fullDoc.id}" class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -164,109 +155,130 @@
|
||||
<!-- 뷰어 본문 -->
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<p class="text-sm text-dim">로딩 중...</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">로딩 중...</p></div>
|
||||
{:else if fullDoc}
|
||||
{#if viewerType === 'markdown'}
|
||||
{#if editMode}
|
||||
<!-- Markdown 편집 (Tabs 프리미티브 — E.4) -->
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'edit', label: '편집' },
|
||||
{ id: 'preview', label: '미리보기' },
|
||||
]}
|
||||
bind:value={editTab}
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<Tabs tabs={[{ id: 'edit', label: '편집' }, { id: 'preview', label: '미리보기' }]} bind:value={editTab} class="flex flex-col h-full">
|
||||
{#snippet children(activeId)}
|
||||
{#if activeId === 'edit'}
|
||||
<textarea
|
||||
bind:value={editContent}
|
||||
<textarea bind:value={editContent}
|
||||
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none min-h-[300px]"
|
||||
spellcheck="false"
|
||||
aria-label="마크다운 편집"
|
||||
></textarea>
|
||||
spellcheck="false" aria-label="마크다운 편집"></textarea>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-auto p-4 markdown-body">
|
||||
{@html renderMd(editContent)}
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4 markdown-body">{@html renderMd(editContent)}</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 markdown-body">
|
||||
{@html renderMd(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
|
||||
<!-- md/txt = extracted_text 단일 필드(표시=편집), MarkdownDoc 로 앵커/KaTeX/이미지 렌더 -->
|
||||
<div class="p-4">
|
||||
<MarkdownDoc
|
||||
documentId={fullDoc.id}
|
||||
mdContent={null}
|
||||
mdStatus={fullDoc.md_status}
|
||||
mdExtractionError={fullDoc.md_extraction_error}
|
||||
mdExtractionQuality={fullDoc.md_extraction_quality}
|
||||
extractedText={fullDoc.extracted_text || rawMarkdown}
|
||||
class={PROSE}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if viewerType === 'pdf'}
|
||||
<iframe
|
||||
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
|
||||
class="w-full h-full border-0"
|
||||
title={fullDoc.title}
|
||||
></iframe>
|
||||
{:else if viewerType === 'preview-pdf'}
|
||||
<iframe
|
||||
src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}"
|
||||
class="w-full h-full border-0"
|
||||
title={fullDoc.title}
|
||||
onerror={() => {}}
|
||||
></iframe>
|
||||
{:else if viewerType === 'image'}
|
||||
<div class="flex items-center justify-center h-full p-4">
|
||||
<img
|
||||
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
|
||||
alt={fullDoc.title}
|
||||
class="max-w-full max-h-full object-contain rounded"
|
||||
<div class="p-4 flex flex-col h-full">
|
||||
<div class="mb-2 flex items-center gap-2 shrink-0">
|
||||
<MarkdownStatusBadge mdStatus={fullDoc.md_status} mdExtractionError={fullDoc.md_extraction_error} mdExtractionQuality={fullDoc.md_extraction_quality} />
|
||||
{#if canShowMarkdown}
|
||||
<button onclick={() => (pdfViewMode = 'markdown')}
|
||||
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'markdown' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">Markdown</button>
|
||||
<button onclick={() => (pdfViewMode = 'pdf')}
|
||||
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'pdf' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">PDF 원본</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pdfViewMode === 'markdown' && canShowMarkdown}
|
||||
<div class="flex-1 overflow-auto">
|
||||
<MarkdownDoc
|
||||
documentId={fullDoc.id}
|
||||
mdContent={fullDoc.md_content}
|
||||
mdFrontmatter={fullDoc.md_frontmatter}
|
||||
mdStatus={fullDoc.md_status}
|
||||
mdExtractionError={fullDoc.md_extraction_error}
|
||||
mdExtractionQuality={fullDoc.md_extraction_quality}
|
||||
extractedText={fullDoc.extracted_text}
|
||||
class={PROSE}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<iframe src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" class="flex-1 w-full border-0 rounded" title={fullDoc.title}></iframe>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if viewerType === 'hwp-markdown'}
|
||||
<div class="p-4">
|
||||
<MarkdownDoc
|
||||
documentId={fullDoc.id}
|
||||
mdContent={fullDoc.md_content}
|
||||
mdFrontmatter={fullDoc.md_frontmatter}
|
||||
mdStatus={fullDoc.md_status}
|
||||
mdExtractionError={fullDoc.md_extraction_error}
|
||||
mdExtractionQuality={fullDoc.md_extraction_quality}
|
||||
extractedText={fullDoc.extracted_text}
|
||||
class={PROSE}
|
||||
/>
|
||||
</div>
|
||||
{:else if viewerType === 'preview-pdf'}
|
||||
<iframe src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}" class="w-full h-full border-0" title={fullDoc.title} onerror={() => {}}></iframe>
|
||||
{:else if viewerType === 'image'}
|
||||
<div class="flex items-center justify-center h-full p-4">
|
||||
<img src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" alt={fullDoc.title} class="max-w-full max-h-full object-contain rounded" />
|
||||
</div>
|
||||
{:else if viewerType === 'text'}
|
||||
<div class="p-4">
|
||||
<pre class="text-sm text-text whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre>
|
||||
<div class="p-4"><pre class="text-sm text-text whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre></div>
|
||||
{:else if viewerType === 'synology'}
|
||||
<div class="flex flex-col items-center justify-center h-full gap-3">
|
||||
<p class="text-sm text-dim">Synology Office 문서 — 외부 편집기에서 열어야 합니다.</p>
|
||||
<a href={fullDoc.edit_url || 'https://link.hyungi.net'} target="_blank" rel="noopener"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
|
||||
<ExternalLink size={14} /> 새 창에서 열기
|
||||
</a>
|
||||
</div>
|
||||
{:else if viewerType === 'cad'}
|
||||
<div class="flex flex-col items-center justify-center h-full gap-3">
|
||||
<p class="text-sm text-dim">CAD 미리보기 (향후 지원 예정)</p>
|
||||
<a
|
||||
href="https://web.autocad.com"
|
||||
target="_blank"
|
||||
class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover"
|
||||
>AutoCAD Web에서 열기</a>
|
||||
<a href="https://web.autocad.com" target="_blank" rel="noopener" class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover">AutoCAD Web에서 열기</a>
|
||||
</div>
|
||||
{:else if viewerType === 'article'}
|
||||
<!-- 뉴스 전용 뷰어 -->
|
||||
<div class="p-5 max-w-3xl mx-auto">
|
||||
<h1 class="text-lg font-bold mb-2">{fullDoc.title}</h1>
|
||||
<h1 class="text-lg font-bold mb-2 text-text">{fullDoc.title}</h1>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||
{#if fullDoc.ai_tags?.length}
|
||||
{#each fullDoc.ai_tags.filter(t => t.startsWith('News/')) as tag}
|
||||
<span class="px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">{tag.replace('News/', '')}</span>
|
||||
<span class="px-1.5 py-0.5 rounded bg-accent/15 text-accent-hover">{tag.replace('News/', '')}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span>{new Date(fullDoc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div class="markdown-body mb-6">
|
||||
{@html renderMd(fullDoc.extracted_text || '')}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-default">
|
||||
{#if fullDoc.edit_url}
|
||||
<a
|
||||
href={fullDoc.edit_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover"
|
||||
>
|
||||
<MarkdownDoc
|
||||
documentId={fullDoc.id}
|
||||
mdContent={fullDoc.md_content}
|
||||
mdStatus={fullDoc.md_status}
|
||||
mdExtractionError={fullDoc.md_extraction_error}
|
||||
mdExtractionQuality={fullDoc.md_extraction_quality}
|
||||
extractedText={fullDoc.extracted_text}
|
||||
class="{PROSE} mb-6"
|
||||
/>
|
||||
{#if fullDoc.edit_url}
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-default">
|
||||
<a href={fullDoc.edit_url} target="_blank" rel="noopener noreferrer"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
|
||||
<ExternalLink size={14} /> 원문 보기
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// 뷰어 타입 분류 단일 source — 상세페이지(/documents/[id])와 3-pane 중앙 리더
|
||||
// (DocumentViewer)가 공유한다. 두 곳이 각자 getViewerType 을 두면 csv/hwp/office 분기가
|
||||
// drift 하므로(이원화 재발) 여기 하나로 수렴한다.
|
||||
//
|
||||
// ⚠ 소비 컴포넌트는 이 함수가 낼 수 있는 모든 ViewerType 에 render 분기가 있어야 한다.
|
||||
// (분류 통합 ≠ render 통합 — 양쪽 컴포넌트의 {#if viewerType===...} 에 누락 없는지 확인.)
|
||||
|
||||
export type ViewerType =
|
||||
| 'article'
|
||||
| 'markdown'
|
||||
| 'hwp-markdown'
|
||||
| 'pdf'
|
||||
| 'preview-pdf'
|
||||
| 'image'
|
||||
| 'text'
|
||||
| 'synology'
|
||||
| 'cad'
|
||||
| 'unsupported';
|
||||
|
||||
const MARKDOWN = new Set(['md', 'txt']);
|
||||
// csv/json/xml/html 은 markdown 으로 렌더하면 콤마/행이 한 문단으로 뭉친다 → <pre> 로 원형 보존.
|
||||
const TEXT = new Set(['csv', 'json', 'xml', 'html']);
|
||||
const HWP = new Set(['hwp', 'hwpx']);
|
||||
// LibreOffice headless → PDF preview (/preview) 로 인앱 표시.
|
||||
const OFFICE_PREVIEW = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
|
||||
// Synology Office 네이티브 — 인앱 변환 부적합, 외부 편집기로.
|
||||
const SYNOLOGY = new Set(['odoc', 'osheet']);
|
||||
const IMAGE = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff']);
|
||||
const CAD = new Set(['dwg', 'dxf']);
|
||||
|
||||
export function getViewerType(
|
||||
format: string | null | undefined,
|
||||
sourceChannel?: string | null,
|
||||
): ViewerType {
|
||||
if (sourceChannel === 'news') return 'article';
|
||||
const f = (format ?? '').toLowerCase();
|
||||
if (MARKDOWN.has(f)) return 'markdown';
|
||||
if (f === 'pdf') return 'pdf';
|
||||
if (HWP.has(f)) return 'hwp-markdown';
|
||||
if (OFFICE_PREVIEW.has(f)) return 'preview-pdf';
|
||||
if (SYNOLOGY.has(f)) return 'synology';
|
||||
if (IMAGE.has(f)) return 'image';
|
||||
if (TEXT.has(f)) return 'text';
|
||||
if (CAD.has(f)) return 'cad';
|
||||
return 'unsupported';
|
||||
}
|
||||
Reference in New Issue
Block a user