feat(ui): Phase E — PreviewPanel 분할 + detail inline + viewer Tabs

E.1 PreviewPanel 7개 editors/* 분할:
- frontend/src/lib/components/editors/ 신설 (7개 컴포넌트):
  * NoteEditor — 사용자 메모 편집
  * EditUrlEditor — 외부 편집 URL (Synology Drive 등)
  * TagsEditor — 태그 추가/삭제
  * AIClassificationEditor — AI 분류 read-only 표시
    (breadcrumb + document_type + confidence tone Badge + importance)
  * FileInfoView — 파일 메타 dl
  * ProcessingStatusView — 파이프라인 단계 status dl
  * DocumentDangerZone — 삭제 (ConfirmDialog 프리미티브 + id 고유화)
- PreviewPanel.svelte 344줄 → 60줄 얇은 wrapper로 축소
  (header + 7개 editors 조합만)
- DocumentMetaRail (D.1)과 detail 페이지(E.2)가 동일 editors 재사용

E.2 detail 페이지 inline 편집:
- documents/[id]/+page.svelte: 기존 read-only 메타 패널 전면 교체
- 오른쪽 aside = 7개 editors 스택 (Card 프리미티브로 감쌈)
- 왼쪽 affordance row: Synology 편집 / 다운로드 / 링크 복사
- 삭제는 DocumentDangerZone이 담당 (ondelete → goto /documents)
- loading/error 상태도 EmptyState 프리미티브로 교체
- marked/DOMPurify renderer 유지, viewer 분기 그대로

E.3 관련 문서 stub:
- detail 페이지 오른쪽 aside에 "관련 문서" Card
- EmptyState "추후 지원" + TODO(backend) GET /documents/{id}/related

E.4 DocumentViewer Tabs 프리미티브:
- Markdown 편집 모드의 편집/미리보기 토글 → Tabs 프리미티브
- 키보드 nav (←→/Home/End), ARIA tablist/tab/tabpanel 자동 적용

검증:
- npm run build 통과 (editors/* 7개 모두 clean, $state 초기값
  warning은 빈 문자열로 초기화하고 $effect로 doc 동기화해 해결)
- npm run lint:tokens 204 → 168 (detail 페이지 + PreviewPanel 전면
  token 기반 재작성으로 -36)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-08 12:51:10 +09:00
parent 7a38c95f3f
commit 4938b25d12
10 changed files with 690 additions and 476 deletions

View File

@@ -1,344 +1,58 @@
<script>
import { X, ExternalLink, Plus, Save, Trash2 } from 'lucide-svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
// Phase E.1 — 얇은 wrapper로 축소.
// 기존 344줄 → editors/* 7개로 분할. 이 파일은 header + 조합만 담당.
// DocumentMetaRail (D.1) 과 documents/[id]/+page.svelte (E.2) 둘 다
// 같은 editors/* 를 재사용한다.
import { X, ExternalLink } from 'lucide-svelte';
import FormatIcon from './FormatIcon.svelte';
import TagPill from './TagPill.svelte';
import NoteEditor from './editors/NoteEditor.svelte';
import EditUrlEditor from './editors/EditUrlEditor.svelte';
import TagsEditor from './editors/TagsEditor.svelte';
import AIClassificationEditor from './editors/AIClassificationEditor.svelte';
import FileInfoView from './editors/FileInfoView.svelte';
import ProcessingStatusView from './editors/ProcessingStatusView.svelte';
import DocumentDangerZone from './editors/DocumentDangerZone.svelte';
let { doc, onclose, ondelete = () => {} } = $props();
// 메모 상태
let noteText = $state('');
let noteEditing = $state(false);
let noteSaving = $state(false);
// 태그 편집
let newTag = $state('');
let tagEditing = $state(false);
// 삭제
let deleteConfirm = $state(false);
let deleting = $state(false);
async function deleteDoc() {
deleting = true;
try {
await api(`/documents/${doc.id}?delete_file=true`, { method: 'DELETE' });
addToast('success', '문서 삭제됨');
ondelete();
} catch (err) {
addToast('error', '삭제 실패');
} finally {
deleting = false;
deleteConfirm = false;
}
}
// 편집 URL
let editUrlText = $state('');
let editUrlEditing = $state(false);
// doc 변경 시 초기화
$effect(() => {
if (doc) {
noteText = doc.user_note || '';
editUrlText = doc.edit_url || '';
noteEditing = false;
tagEditing = false;
editUrlEditing = false;
newTag = '';
}
});
async function saveNote() {
noteSaving = true;
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ user_note: noteText }),
});
doc.user_note = noteText;
noteEditing = false;
addToast('success', '메모 저장됨');
} catch (err) {
addToast('error', '메모 저장 실패');
} finally {
noteSaving = false;
}
}
async function saveEditUrl() {
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ edit_url: editUrlText.trim() || null }),
});
doc.edit_url = editUrlText.trim() || null;
editUrlEditing = false;
addToast('success', '편집 URL 저장됨');
} catch (err) {
addToast('error', '편집 URL 저장 실패');
}
}
async function addTag() {
const tag = newTag.trim();
if (!tag) return;
const updatedTags = [...(doc.ai_tags || []), tag];
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_tags: updatedTags }),
});
doc.ai_tags = updatedTags;
newTag = '';
addToast('success', '태그 추가됨');
} catch (err) {
addToast('error', '태그 추가 실패');
}
}
async function removeTag(tagToRemove) {
const updatedTags = (doc.ai_tags || []).filter(t => t !== tagToRemove);
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_tags: updatedTags }),
});
doc.ai_tags = updatedTags;
addToast('success', '태그 삭제됨');
} catch (err) {
addToast('error', '태그 삭제 실패');
}
}
function formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric' });
}
function formatSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / 1048576).toFixed(1)}MB`;
}
</script>
<aside class="h-full flex flex-col bg-sidebar border-l border-default overflow-y-auto">
<aside class="h-full w-full flex flex-col bg-sidebar border-l border-default overflow-y-auto">
<!-- 헤더 -->
<div class="flex items-center justify-between px-4 py-3 border-b border-default shrink-0">
<div class="flex items-center gap-2 min-w-0">
<FormatIcon format={doc.file_format} size={16} />
<span class="text-sm font-medium truncate">{doc.title || '제목 없음'}</span>
<span class="text-sm font-medium text-text truncate">{doc.title || '제목 없음'}</span>
</div>
<div class="flex items-center gap-1">
<a href="/documents/{doc.id}" class="p-1 rounded hover:bg-surface text-dim" title="전체 보기">
<div class="flex items-center gap-1 shrink-0">
<a
href="/documents/{doc.id}"
class="p-1 rounded hover:bg-surface text-dim hover:text-text"
title="전체 보기"
aria-label="전체 보기"
>
<ExternalLink size={14} />
</a>
<button onclick={onclose} class="p-1 rounded hover:bg-surface text-dim" aria-label="닫기">
<button
type="button"
onclick={onclose}
class="p-1 rounded hover:bg-surface text-dim hover:text-text"
aria-label="닫기"
>
<X size={16} />
</button>
</div>
</div>
<!-- 조합 -->
<div class="flex-1 p-4 space-y-4">
<!-- 메모 -->
<div>
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">메모</h4>
{#if noteEditing}
<textarea
bind:value={noteText}
class="w-full h-24 px-3 py-2 bg-bg border border-default rounded-lg text-sm text-text resize-none outline-none focus:border-accent"
placeholder="메모 입력..."
></textarea>
<div class="flex gap-2 mt-1.5">
<button
onclick={saveNote}
disabled={noteSaving}
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} /> 저장
</button>
<button
onclick={() => { noteEditing = false; noteText = doc.user_note || ''; }}
class="px-2 py-1 text-xs text-dim hover:text-text"
>취소</button>
</div>
{:else}
<button
onclick={() => noteEditing = true}
class="w-full text-left px-3 py-2 bg-bg border border-default rounded-lg text-sm min-h-[40px]
{noteText ? 'text-text' : 'text-dim'}"
>
{noteText || '메모 추가...'}
</button>
{/if}
</div>
<!-- 편집 URL -->
<div>
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">편집 링크</h4>
{#if editUrlEditing}
<div class="flex gap-1">
<input
bind:value={editUrlText}
placeholder="Synology Drive URL 붙여넣기..."
class="flex-1 px-2 py-1 bg-bg border border-default rounded text-xs text-text outline-none focus:border-accent"
/>
<button onclick={saveEditUrl} class="px-2 py-1 text-xs bg-accent text-white rounded">저장</button>
<button onclick={() => { editUrlEditing = false; editUrlText = doc.edit_url || ''; }} class="px-2 py-1 text-xs text-dim">취소</button>
</div>
{:else if doc.edit_url}
<div class="flex items-center gap-1">
<a href={doc.edit_url} target="_blank" class="text-xs text-accent truncate hover:underline">{doc.edit_url}</a>
<button onclick={() => editUrlEditing = true} class="text-[10px] text-dim hover:text-text">수정</button>
</div>
{:else}
<button
onclick={() => editUrlEditing = true}
class="text-xs text-dim hover:text-accent"
>+ URL 추가</button>
{/if}
</div>
<!-- 태그 -->
<div>
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">태그</h4>
<div class="flex flex-wrap gap-1 mb-2">
{#each doc.ai_tags || [] as tag}
<span class="inline-flex items-center gap-0.5">
<TagPill {tag} clickable={false} />
<button
onclick={() => removeTag(tag)}
class="text-dim hover:text-error text-[10px]"
title="삭제"
>×</button>
</span>
{/each}
</div>
{#if tagEditing}
<form onsubmit={(e) => { e.preventDefault(); addTag(); }} class="flex gap-1">
<input
bind:value={newTag}
placeholder="태그 입력..."
class="flex-1 px-2 py-1 bg-bg border border-default rounded text-xs text-text outline-none focus:border-accent"
/>
<button type="submit" class="px-2 py-1 text-xs bg-accent text-white rounded">추가</button>
</form>
{:else}
<button
onclick={() => tagEditing = true}
class="flex items-center gap-1 text-xs text-dim hover:text-accent"
>
<Plus size={12} /> 태그 추가
</button>
{/if}
</div>
<!-- AI 분류 -->
{#if doc.ai_domain}
<div>
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">분류</h4>
<!-- domain breadcrumb -->
<div class="flex flex-wrap gap-1 mb-2">
{#each doc.ai_domain.split('/') as part, i}
{#if i > 0}<span class="text-[10px] text-dim"></span>{/if}
<span class="text-xs text-accent">{part}</span>
{/each}
</div>
<!-- document_type + confidence -->
<div class="flex items-center gap-2">
{#if doc.document_type}
<span class="text-[10px] px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">{doc.document_type}</span>
{/if}
{#if doc.ai_confidence}
<span class="text-[10px] px-1.5 py-0.5 rounded {doc.ai_confidence >= 0.85 ? 'bg-green-900/30 text-green-400' : doc.ai_confidence >= 0.6 ? 'bg-amber-900/30 text-amber-400' : 'bg-red-900/30 text-red-400'}">
{(doc.ai_confidence * 100).toFixed(0)}%
</span>
{/if}
{#if doc.importance && doc.importance !== 'medium'}
<span class="text-[10px] px-1.5 py-0.5 rounded {doc.importance === 'high' ? 'bg-red-900/30 text-red-400' : 'bg-gray-800 text-gray-400'}">
{doc.importance}
</span>
{/if}
</div>
</div>
{/if}
<!-- 파일 정보 -->
<div>
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">정보</h4>
<dl class="space-y-1.5 text-xs">
<div class="flex justify-between">
<dt class="text-dim">포맷</dt>
<dd class="uppercase">{doc.file_format}{doc.original_format ? ` (원본: ${doc.original_format})` : ''}</dd>
</div>
<div class="flex justify-between">
<dt class="text-dim">크기</dt>
<dd>{formatSize(doc.file_size)}</dd>
</div>
{#if doc.source_channel}
<div class="flex justify-between">
<dt class="text-dim">출처</dt>
<dd>{doc.source_channel}</dd>
</div>
{/if}
{#if doc.data_origin}
<div class="flex justify-between">
<dt class="text-dim">구분</dt>
<dd>{doc.data_origin}</dd>
</div>
{/if}
<div class="flex justify-between">
<dt class="text-dim">등록일</dt>
<dd>{formatDate(doc.created_at)}</dd>
</div>
</dl>
</div>
<!-- 처리 상태 -->
<div>
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">처리</h4>
<dl class="space-y-1 text-xs">
<div class="flex justify-between">
<dt class="text-dim">추출</dt>
<dd class={doc.extracted_at ? 'text-success' : 'text-dim'}>{doc.extracted_at ? '완료' : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-dim">분류</dt>
<dd class={doc.ai_processed_at ? 'text-success' : 'text-dim'}>{doc.ai_processed_at ? '완료' : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-dim">임베딩</dt>
<dd class={doc.embedded_at ? 'text-success' : 'text-dim'}>{doc.embedded_at ? '완료' : '대기'}</dd>
</div>
</dl>
</div>
<!-- 삭제 -->
<NoteEditor {doc} />
<EditUrlEditor {doc} />
<TagsEditor {doc} />
<AIClassificationEditor {doc} />
<FileInfoView {doc} />
<ProcessingStatusView {doc} />
<div class="pt-2 border-t border-default">
{#if deleteConfirm}
<div class="flex items-center gap-2">
<span class="text-xs text-error">정말 삭제?</span>
<button
onclick={deleteDoc}
disabled={deleting}
class="px-2 py-1 text-xs bg-error text-white rounded disabled:opacity-50"
>{deleting ? '삭제 중...' : '확인'}</button>
<button
onclick={() => deleteConfirm = false}
class="px-2 py-1 text-xs text-dim"
>취소</button>
</div>
{:else}
<button
onclick={() => deleteConfirm = true}
class="flex items-center gap-1 text-xs text-dim hover:text-error"
>
<Trash2 size={12} /> 문서 삭제
</button>
{/if}
<DocumentDangerZone {doc} {ondelete} />
</div>
</div>
</aside>