가장 큰 위험 batch. Phase A 디자인 시스템 정착 마지막 mechanical refactor (8 파일 8/8 누적 — core components 0 hit 달성). PreviewPanel (53 hits → 0): - bg-[var(--sidebar-bg)] → bg-sidebar (메인 aside) - bg-[var(--bg)] → bg-bg (input 배경) - bg-[var(--surface)] → bg-surface (hover) - bg-[var(--accent)] → bg-accent + hover:bg-accent-hover (저장 버튼) - bg-[var(--error)] → bg-error (삭제 확인) - text/border 토큰 일괄 swap - focus:border-accent (input) - confidence 색상 (green/amber/red palette)은 plan B3 명시 없어 그대로 DocumentViewer (28 hits → 0): - 뷰어 본체 bg-surface border-default - 툴바 bg-sidebar - 마크다운 편집 탭 bg-surface, edit textarea bg-bg - 상태별 hover 토큰 swap - 뉴스 article 태그 blue-900/30 그대로 (lint:tokens 미검출) +layout.svelte (10 hits → 0): - nav 잔여 var() (햄버거, 로고, 메뉴 링크) 토큰 swap - 로딩 텍스트 text-dim - toast 영역 의미 swap (plan B3 명시): * green-900/200 → bg-success/10 + text-success + border-success/30 * red-900/200 → bg-error/10 + text-error + border-error/30 * yellow-900/200 → bg-warning/10 + text-warning + border-warning/30 * blue-900/200 → bg-accent/10 + text-accent + border-accent/30 - class:* 디렉티브 8개 → script TOAST_CLASS dict + dynamic class binding (svelte 5에서 슬래시 포함 클래스명을 class: 디렉티브로 못 씀) 검증: - npm run lint:tokens : 360 → 269 (-91, B3 파일 0 hit) - 누적 진행: 421 → 269 (-152 / 8 파일 완료, plan 정정 목표 정확 달성) - npm run build : ✅ - npx svelte-check : ✅ 0 errors - ⚠ 3-risk grep : hover/border-border/var() 잔여 0건 A-8 종료 시점 상태: - core components 8 파일: lint:tokens 0 hit ✅ - routes 7 파일 잔존 (~269): news 92, settings 47, documents/[id] 36, +page 28, documents 26, inbox 25, login 15 - lint:tokens 강제화 (pre-commit hook)는 Phase D + F 완료 후 별도 commit 플랜: ~/.claude/plans/compressed-churning-dragon.md §A.4 Batch 3
271 lines
9.7 KiB
Svelte
271 lines
9.7 KiB
Svelte
<script>
|
|
import { api, getAccessToken } from '$lib/api';
|
|
import { addToast } from '$lib/stores/toast';
|
|
import { marked } from 'marked';
|
|
import DOMPurify from 'dompurify';
|
|
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
|
|
|
|
// marked + sanitize
|
|
marked.use({ mangle: false, headerIds: false });
|
|
function renderMd(text) {
|
|
return DOMPurify.sanitize(marked(text), {
|
|
USE_PROFILES: { html: true },
|
|
FORBID_TAGS: ['style', 'script'],
|
|
FORBID_ATTR: ['onerror', 'onclick'],
|
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
});
|
|
}
|
|
|
|
let { doc } = $props();
|
|
let fullDoc = $state(null);
|
|
let loading = $state(true);
|
|
let viewerType = $state('none');
|
|
|
|
// Markdown 편집
|
|
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' };
|
|
return null;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (doc?.id) {
|
|
loadFullDoc(doc.id);
|
|
editMode = false;
|
|
}
|
|
});
|
|
|
|
async function loadFullDoc(id) {
|
|
loading = true;
|
|
try {
|
|
fullDoc = await api(`/documents/${id}`);
|
|
viewerType = fullDoc.source_channel === 'news' ? 'article' : getViewerType(fullDoc.file_format);
|
|
|
|
// Markdown: 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;
|
|
viewerType = 'none';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function startEdit() {
|
|
editContent = fullDoc?.extracted_text || rawMarkdown || '';
|
|
editMode = true;
|
|
editTab = 'edit';
|
|
}
|
|
|
|
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 editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null);
|
|
</script>
|
|
|
|
<svelte:window on:keydown={handleKeydown} />
|
|
|
|
<div class="h-full flex flex-col bg-surface border-t border-default">
|
|
<!-- 뷰어 툴바 -->
|
|
{#if fullDoc && !loading}
|
|
<div class="flex items-center justify-between px-3 py-1.5 border-b border-default bg-sidebar shrink-0">
|
|
<span class="text-xs 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-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>
|
|
{:else}
|
|
<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"
|
|
>
|
|
<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>
|
|
</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-dim">로딩 중...</p>
|
|
</div>
|
|
{:else if fullDoc}
|
|
{#if viewerType === 'markdown'}
|
|
{#if editMode}
|
|
<!-- Markdown 편집 (탭 전환) -->
|
|
<div class="flex flex-col h-full">
|
|
<div class="flex gap-1 px-3 py-1 border-b border-default shrink-0">
|
|
<button
|
|
onclick={() => editTab = 'edit'}
|
|
class="px-3 py-1 text-xs rounded-t {editTab === 'edit' ? 'bg-surface text-text' : 'text-dim'}"
|
|
>편집</button>
|
|
<button
|
|
onclick={() => editTab = 'preview'}
|
|
class="px-3 py-1 text-xs rounded-t {editTab === 'preview' ? 'bg-surface text-text' : 'text-dim'}"
|
|
>미리보기</button>
|
|
</div>
|
|
{#if editTab === 'edit'}
|
|
<textarea
|
|
bind:value={editContent}
|
|
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none"
|
|
spellcheck="false"
|
|
></textarea>
|
|
{:else}
|
|
<div class="flex-1 overflow-auto p-4 markdown-body">
|
|
{@html renderMd(editContent)}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="p-4 markdown-body">
|
|
{@html renderMd(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
|
|
</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-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-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>
|
|
</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>
|
|
<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>
|
|
{/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"
|
|
>
|
|
<ExternalLink size={14} /> 원문 보기
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center justify-center h-full">
|
|
<p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|