- 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>
203 lines
6.9 KiB
Svelte
203 lines
6.9 KiB
Svelte
<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'].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';
|
|
}
|
|
|
|
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;
|
|
}
|
|
});
|
|
|
|
async function loadFullDoc(id) {
|
|
loading = true;
|
|
try {
|
|
fullDoc = await api(`/documents/${id}`);
|
|
viewerType = getViewerType(fullDoc.file_format);
|
|
} catch (err) {
|
|
fullDoc = null;
|
|
viewerType = 'none';
|
|
} finally {
|
|
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>
|
|
|
|
<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>
|
|
{/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>
|