feat: Markdown 편집기 + PDF 변환 파이프라인 + 뷰어 포맷 분기

- Markdown split editor: textarea + marked preview, Ctrl+S 저장
- PUT /api/documents/{id}/content: 원본 파일 저장 + extracted_text 갱신
- GET /api/documents/{id}/preview: PDF 미리보기 캐시 서빙
- preview_worker: LibreOffice headless → PDF 변환 (timeout 60s, retry 1회)
- queue_consumer: preview stage 추가 (embed 후 자동 트리거)
- DocumentViewer: 포맷별 분기 (markdown/pdf/preview-pdf/image/text/cad)
- 오피스/CAD 문서: 새 탭 편집 버튼
- Dockerfile: LibreOffice headless 설치
- migration 005: preview_status, preview_hash, preview_at 컬럼

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-03 10:10:03 +09:00
parent 3546c8cefb
commit 4bea408bbd
7 changed files with 354 additions and 45 deletions

View File

@@ -1,25 +1,44 @@
<script>
import { api, getAccessToken } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { marked } from 'marked';
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
let { doc } = $props();
let fullDoc = $state(null);
let loading = $state(true);
let viewerType = $state('none');
// Markdown 편집
let editMode = $state(false);
let editContent = $state('');
let saving = $state(false);
function getViewerType(format) {
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
if (['md', 'txt'].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';
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';
}
// doc이 바뀌면 상세 데이터 로딩
function getEditUrl(doc) {
if (['odoc', 'osheet', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp'].includes(doc.file_format)) {
return `https://link.hyungi.net`;
}
if (['dwg', 'dxf'].includes(doc.file_format)) {
return 'https://web.autocad.com';
}
return null;
}
$effect(() => {
if (doc?.id) {
loadFullDoc(doc.id);
editMode = false;
}
});
@@ -35,46 +54,149 @@
loading = false;
}
}
function startEdit() {
editContent = fullDoc?.extracted_text || '';
editMode = true;
}
async function saveContent() {
saving = true;
try {
await api(`/documents/${fullDoc.id}/content`, {
method: 'PUT',
body: JSON.stringify({ content: editContent }),
});
fullDoc.extracted_text = editContent;
editMode = false;
addToast('success', '저장됨');
} catch (err) {
addToast('error', '저장 실패');
} finally {
saving = false;
}
}
function handleKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 's' && editMode) {
e.preventDefault();
saveContent();
}
}
let editUrl = $derived(fullDoc ? getEditUrl(fullDoc) : null);
</script>
<div class="h-full bg-[var(--surface)] border-t border-[var(--border)] overflow-auto">
{#if loading}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-[var(--text-dim)]">로딩 중...</p>
<svelte:window on:keydown={handleKeydown} />
<div class="h-full flex flex-col bg-[var(--surface)] border-t border-[var(--border)]">
<!-- 뷰어 툴바 -->
{#if fullDoc && !loading}
<div class="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] bg-[var(--sidebar-bg)] shrink-0">
<span class="text-xs text-[var(--text-dim)] truncate">{fullDoc.title || '제목 없음'}</span>
<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-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
<Save size={12} /> {saving ? '저장 중...' : '저장'}
</button>
<button
onclick={() => editMode = false}
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--text)]"
>취소</button>
{:else}
<button
onclick={startEdit}
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)] border border-[var(--border)] rounded"
>편집</button>
{/if}
{/if}
{#if editUrl}
<a
href={editUrl}
target="_blank"
rel="noopener"
class="flex items-center gap-1 px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)] border border-[var(--border)] rounded"
>
<ExternalLink size={12} /> 편집
</a>
{/if}
<a
href="/documents/{fullDoc.id}"
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)] border border-[var(--border)] rounded"
>전체 보기</a>
</div>
</div>
{:else if fullDoc}
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
<div class="p-4 prose prose-invert prose-sm max-w-none">
{@html marked(fullDoc.extracted_text || '*텍스트 추출 대기 중*')}
</div>
{: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 === '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 === 'synology'}
<iframe
src="https://ds1525.hyungi.net:15001/oo/r/{fullDoc.file_path}"
class="w-full h-full border-0"
title={fullDoc.title}
allow="clipboard-read; clipboard-write"
></iframe>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-center">
<p class="text-sm text-[var(--text-dim)] mb-1">미리보기를 지원하지 않는 형식입니다</p>
<p class="text-xs text-[var(--text-dim)]">{fullDoc.file_format}</p>
</div>
</div>
{/if}
{/if}
<!-- 뷰어 본문 -->
<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-[var(--text-dim)]">로딩 중...</p>
</div>
{:else if fullDoc}
{#if viewerType === 'markdown'}
{#if editMode}
<!-- Markdown split editor -->
<div class="flex h-full">
<textarea
bind:value={editContent}
class="w-1/2 h-full p-4 bg-[var(--bg)] text-[var(--text)] text-sm font-mono resize-none outline-none border-r border-[var(--border)]"
spellcheck="false"
></textarea>
<div class="w-1/2 h-full p-4 overflow-auto prose prose-invert prose-sm max-w-none">
{@html marked(editContent)}
</div>
</div>
{:else}
<div class="p-4 prose prose-invert prose-sm max-w-none">
{@html marked(fullDoc.extracted_text || '*텍스트 추출 대기 중*')}
</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>
{:else if viewerType === 'text'}
<div class="p-4">
<pre class="text-sm text-[var(--text)] whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre>
</div>
{:else if viewerType === 'cad'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-[var(--text-dim)]">CAD 미리보기 (향후 지원 예정)</p>
<a
href="https://web.autocad.com"
target="_blank"
class="px-3 py-1.5 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)]"
>AutoCAD Web에서 열기</a>
</div>
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-[var(--text-dim)]">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>
</div>
{/if}
{/if}
</div>
</div>