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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user