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:
@@ -4,6 +4,7 @@
|
|||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
|
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
|
||||||
|
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||||
|
|
||||||
// marked + sanitize
|
// marked + sanitize
|
||||||
marked.use({ mangle: false, headerIds: false });
|
marked.use({ mangle: false, headerIds: false });
|
||||||
@@ -169,29 +170,31 @@
|
|||||||
{:else if fullDoc}
|
{:else if fullDoc}
|
||||||
{#if viewerType === 'markdown'}
|
{#if viewerType === 'markdown'}
|
||||||
{#if editMode}
|
{#if editMode}
|
||||||
<!-- Markdown 편집 (탭 전환) -->
|
<!-- Markdown 편집 (Tabs 프리미티브 — E.4) -->
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div class="flex gap-1 px-3 py-1 border-b border-default shrink-0">
|
<Tabs
|
||||||
<button
|
tabs={[
|
||||||
onclick={() => editTab = 'edit'}
|
{ id: 'edit', label: '편집' },
|
||||||
class="px-3 py-1 text-xs rounded-t {editTab === 'edit' ? 'bg-surface text-text' : 'text-dim'}"
|
{ id: 'preview', label: '미리보기' },
|
||||||
>편집</button>
|
]}
|
||||||
<button
|
bind:value={editTab}
|
||||||
onclick={() => editTab = 'preview'}
|
class="flex flex-col h-full"
|
||||||
class="px-3 py-1 text-xs rounded-t {editTab === 'preview' ? 'bg-surface text-text' : 'text-dim'}"
|
>
|
||||||
>미리보기</button>
|
{#snippet children(activeId)}
|
||||||
</div>
|
{#if activeId === 'edit'}
|
||||||
{#if editTab === 'edit'}
|
<textarea
|
||||||
<textarea
|
bind:value={editContent}
|
||||||
bind:value={editContent}
|
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none min-h-[300px]"
|
||||||
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none"
|
spellcheck="false"
|
||||||
spellcheck="false"
|
aria-label="마크다운 편집"
|
||||||
></textarea>
|
></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex-1 overflow-auto p-4 markdown-body">
|
<div class="flex-1 overflow-auto p-4 markdown-body">
|
||||||
{@html renderMd(editContent)}
|
{@html renderMd(editContent)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="p-4 markdown-body">
|
<div class="p-4 markdown-body">
|
||||||
|
|||||||
@@ -1,344 +1,58 @@
|
|||||||
<script>
|
<script>
|
||||||
import { X, ExternalLink, Plus, Save, Trash2 } from 'lucide-svelte';
|
// Phase E.1 — 얇은 wrapper로 축소.
|
||||||
import { api } from '$lib/api';
|
// 기존 344줄 → editors/* 7개로 분할. 이 파일은 header + 조합만 담당.
|
||||||
import { addToast } from '$lib/stores/toast';
|
// DocumentMetaRail (D.1) 과 documents/[id]/+page.svelte (E.2) 둘 다
|
||||||
|
// 같은 editors/* 를 재사용한다.
|
||||||
|
import { X, ExternalLink } from 'lucide-svelte';
|
||||||
import FormatIcon from './FormatIcon.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 { 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>
|
</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 justify-between px-4 py-3 border-b border-default shrink-0">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<FormatIcon format={doc.file_format} size={16} />
|
<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>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<a href="/documents/{doc.id}" class="p-1 rounded hover:bg-surface text-dim" title="전체 보기">
|
<a
|
||||||
|
href="/documents/{doc.id}"
|
||||||
|
class="p-1 rounded hover:bg-surface text-dim hover:text-text"
|
||||||
|
title="전체 보기"
|
||||||
|
aria-label="전체 보기"
|
||||||
|
>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</a>
|
</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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 조합 -->
|
||||||
<div class="flex-1 p-4 space-y-4">
|
<div class="flex-1 p-4 space-y-4">
|
||||||
<!-- 메모 -->
|
<NoteEditor {doc} />
|
||||||
<div>
|
<EditUrlEditor {doc} />
|
||||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">메모</h4>
|
<TagsEditor {doc} />
|
||||||
{#if noteEditing}
|
<AIClassificationEditor {doc} />
|
||||||
<textarea
|
<FileInfoView {doc} />
|
||||||
bind:value={noteText}
|
<ProcessingStatusView {doc} />
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- 삭제 -->
|
|
||||||
<div class="pt-2 border-t border-default">
|
<div class="pt-2 border-t border-default">
|
||||||
{#if deleteConfirm}
|
<DocumentDangerZone {doc} {ondelete} />
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script>
|
||||||
|
// Phase E.1 — AI 분류 결과 표시.
|
||||||
|
// 현재는 read-only. 향후 phase에서 inline select로 override 가능하게 확장.
|
||||||
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
|
|
||||||
|
let { doc } = $props();
|
||||||
|
|
||||||
|
let parts = $derived(doc?.ai_domain ? doc.ai_domain.split('/') : []);
|
||||||
|
let confidenceTone = $derived.by(() => {
|
||||||
|
const c = doc?.ai_confidence ?? 0;
|
||||||
|
if (c >= 0.85) return 'success';
|
||||||
|
if (c >= 0.6) return 'warning';
|
||||||
|
return 'error';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#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 items-center gap-1 mb-2">
|
||||||
|
{#each parts as part, i}
|
||||||
|
{#if i > 0}<span class="text-[10px] text-faint">›</span>{/if}
|
||||||
|
<span class="text-xs text-accent">{part}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{#if doc.document_type}
|
||||||
|
<Badge tone="accent" size="sm">{doc.document_type}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if doc.ai_confidence}
|
||||||
|
<Badge tone={confidenceTone} size="sm">
|
||||||
|
{(doc.ai_confidence * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if doc.importance && doc.importance !== 'medium'}
|
||||||
|
<Badge tone={doc.importance === 'high' ? 'error' : 'neutral'} size="sm">
|
||||||
|
{doc.importance}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script>
|
||||||
|
// Phase E.1 — 문서 삭제 영역. ConfirmDialog 프리미티브 사용.
|
||||||
|
import { Trash2 } from 'lucide-svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { addToast } from '$lib/stores/toast';
|
||||||
|
import { ui } from '$lib/stores/uiState.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
|
|
||||||
|
let { doc, ondelete = () => {} } = $props();
|
||||||
|
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
// doc id 기반으로 modal id를 고유하게 해서, 여러 danger zone이 동시에
|
||||||
|
// 있어도 충돌하지 않게 한다 (예: detail 페이지 + rail).
|
||||||
|
let modalId = $derived(`doc-delete-${doc?.id ?? 'unknown'}`);
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!doc) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await api(`/documents/${doc.id}?delete_file=true`, { method: 'DELETE' });
|
||||||
|
addToast('success', '문서 삭제됨');
|
||||||
|
ondelete();
|
||||||
|
} catch (err) {
|
||||||
|
addToast('error', '삭제 실패');
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={Trash2}
|
||||||
|
onclick={() => ui.openModal(modalId)}
|
||||||
|
class="text-dim hover:text-error"
|
||||||
|
>
|
||||||
|
문서 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
id={modalId}
|
||||||
|
title="문서 삭제"
|
||||||
|
message="원본 파일과 모든 메타데이터(메모/태그/분류)가 함께 삭제됩니다. 되돌릴 수 없습니다."
|
||||||
|
confirmLabel="삭제"
|
||||||
|
tone="danger"
|
||||||
|
loading={deleting}
|
||||||
|
onconfirm={confirm}
|
||||||
|
/>
|
||||||
84
frontend/src/lib/components/editors/EditUrlEditor.svelte
Normal file
84
frontend/src/lib/components/editors/EditUrlEditor.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script>
|
||||||
|
// Phase E.1 — 편집 URL(외부 Synology Drive 등) 등록.
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { addToast } from '$lib/stores/toast';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
let { doc } = $props();
|
||||||
|
|
||||||
|
let urlText = $state('');
|
||||||
|
let editing = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (doc) {
|
||||||
|
urlText = doc.edit_url || '';
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const next = urlText.trim() || null;
|
||||||
|
await api(`/documents/${doc.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ edit_url: next }),
|
||||||
|
});
|
||||||
|
doc.edit_url = next;
|
||||||
|
editing = false;
|
||||||
|
addToast('success', '편집 URL 저장됨');
|
||||||
|
} catch (err) {
|
||||||
|
addToast('error', '편집 URL 저장 실패');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
editing = false;
|
||||||
|
urlText = doc.edit_url || '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">편집 링크</h4>
|
||||||
|
{#if editing}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input
|
||||||
|
bind:value={urlText}
|
||||||
|
aria-label="편집 URL"
|
||||||
|
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 variant="primary" size="sm" loading={saving} onclick={save}>저장</Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={cancel}>취소</Button>
|
||||||
|
</div>
|
||||||
|
{:else if doc.edit_url}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={doc.edit_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-xs text-accent truncate hover:underline flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
{doc.edit_url}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
class="text-[10px] text-dim hover:text-text shrink-0"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
class="text-xs text-dim hover:text-accent"
|
||||||
|
>
|
||||||
|
+ URL 추가
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
52
frontend/src/lib/components/editors/FileInfoView.svelte
Normal file
52
frontend/src/lib/components/editors/FileInfoView.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script>
|
||||||
|
// Phase E.1 — 파일 메타 정보 read-only 표시.
|
||||||
|
let { doc } = $props();
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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="text-text uppercase">
|
||||||
|
{doc.file_format}{doc.original_format ? ` (원본: ${doc.original_format})` : ''}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-dim">크기</dt>
|
||||||
|
<dd class="text-text">{formatSize(doc.file_size)}</dd>
|
||||||
|
</div>
|
||||||
|
{#if doc.source_channel}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-dim">출처</dt>
|
||||||
|
<dd class="text-text">{doc.source_channel}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if doc.data_origin}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-dim">구분</dt>
|
||||||
|
<dd class="text-text">{doc.data_origin}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-dim">등록일</dt>
|
||||||
|
<dd class="text-text">{formatDate(doc.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
70
frontend/src/lib/components/editors/NoteEditor.svelte
Normal file
70
frontend/src/lib/components/editors/NoteEditor.svelte
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script>
|
||||||
|
// Phase E.1 — PreviewPanel 분할. 사용자 메모 편집.
|
||||||
|
import { Save } from 'lucide-svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { addToast } from '$lib/stores/toast';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
let { doc } = $props();
|
||||||
|
|
||||||
|
// 초기값은 빈 문자열, 아래 $effect가 doc 변경 시 동기화.
|
||||||
|
let noteText = $state('');
|
||||||
|
let editing = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (doc) {
|
||||||
|
noteText = doc.user_note || '';
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await api(`/documents/${doc.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ user_note: noteText }),
|
||||||
|
});
|
||||||
|
doc.user_note = noteText;
|
||||||
|
editing = false;
|
||||||
|
addToast('success', '메모 저장됨');
|
||||||
|
} catch (err) {
|
||||||
|
addToast('error', '메모 저장 실패');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
editing = false;
|
||||||
|
noteText = doc.user_note || '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">메모</h4>
|
||||||
|
{#if editing}
|
||||||
|
<textarea
|
||||||
|
bind:value={noteText}
|
||||||
|
aria-label="메모"
|
||||||
|
class="w-full h-24 px-3 py-2 bg-bg border border-default rounded-md text-sm text-text resize-none outline-none focus:border-accent"
|
||||||
|
placeholder="메모 입력..."
|
||||||
|
></textarea>
|
||||||
|
<div class="flex gap-2 mt-1.5">
|
||||||
|
<Button variant="primary" size="sm" icon={Save} loading={saving} onclick={save}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={cancel}>취소</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
class="w-full text-left px-3 py-2 bg-bg border border-default rounded-md text-sm min-h-[40px] hover:border-accent transition-colors
|
||||||
|
{noteText ? 'text-text' : 'text-dim'}"
|
||||||
|
>
|
||||||
|
{noteText || '메모 추가...'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script>
|
||||||
|
// Phase E.1 — 파이프라인 처리 단계 상태 표시.
|
||||||
|
import { CheckCircle2, Clock } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { doc } = $props();
|
||||||
|
|
||||||
|
const STAGES = [
|
||||||
|
{ key: 'extracted_at', label: '추출' },
|
||||||
|
{ key: 'ai_processed_at', label: '분류' },
|
||||||
|
{ key: 'embedded_at', label: '임베딩' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">처리</h4>
|
||||||
|
<dl class="space-y-1 text-xs">
|
||||||
|
{#each STAGES as stage}
|
||||||
|
{@const done = !!doc[stage.key]}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<dt class="text-dim flex items-center gap-1">
|
||||||
|
{#if done}
|
||||||
|
<CheckCircle2 size={10} class="text-success" />
|
||||||
|
{:else}
|
||||||
|
<Clock size={10} class="text-faint" />
|
||||||
|
{/if}
|
||||||
|
{stage.label}
|
||||||
|
</dt>
|
||||||
|
<dd class={done ? 'text-success' : 'text-dim'}>
|
||||||
|
{done ? '완료' : '대기'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
101
frontend/src/lib/components/editors/TagsEditor.svelte
Normal file
101
frontend/src/lib/components/editors/TagsEditor.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script>
|
||||||
|
// Phase E.1 — 태그 추가/삭제.
|
||||||
|
// Phase F의 inbox override TagsEditor도 향후 이 컴포넌트로 통일 가능.
|
||||||
|
import { Plus, X } from 'lucide-svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { addToast } from '$lib/stores/toast';
|
||||||
|
import TagPill from '$lib/components/TagPill.svelte';
|
||||||
|
|
||||||
|
let { doc } = $props();
|
||||||
|
|
||||||
|
let newTag = $state('');
|
||||||
|
let editing = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (doc) {
|
||||||
|
newTag = '';
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addTag() {
|
||||||
|
const tag = newTag.trim();
|
||||||
|
if (!tag) return;
|
||||||
|
const updated = [...(doc.ai_tags || []), tag];
|
||||||
|
try {
|
||||||
|
await api(`/documents/${doc.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ ai_tags: updated }),
|
||||||
|
});
|
||||||
|
doc.ai_tags = updated;
|
||||||
|
newTag = '';
|
||||||
|
addToast('success', '태그 추가됨');
|
||||||
|
} catch (err) {
|
||||||
|
addToast('error', '태그 추가 실패');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(tagToRemove) {
|
||||||
|
const updated = (doc.ai_tags || []).filter((t) => t !== tagToRemove);
|
||||||
|
try {
|
||||||
|
await api(`/documents/${doc.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ ai_tags: updated }),
|
||||||
|
});
|
||||||
|
doc.ai_tags = updated;
|
||||||
|
addToast('success', '태그 삭제됨');
|
||||||
|
} catch (err) {
|
||||||
|
addToast('error', '태그 삭제 실패');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeTag(tag)}
|
||||||
|
class="text-dim hover:text-error"
|
||||||
|
aria-label="{tag} 삭제"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if editing}
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
}}
|
||||||
|
class="flex gap-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:value={newTag}
|
||||||
|
aria-label="새 태그"
|
||||||
|
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 hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
class="flex items-center gap-1 text-xs text-dim hover:text-accent"
|
||||||
|
>
|
||||||
|
<Plus size={12} /> 태그 추가
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
<script>
|
<script>
|
||||||
|
// Phase E.2 — detail 페이지 inline 편집.
|
||||||
|
// 기존 read-only 메타 패널(L138–201)을 editors/* 스택으로 교체.
|
||||||
|
// + E.3 관련 문서 stub, + 헤더 affordance row.
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, getAccessToken } from '$lib/api';
|
import { api, getAccessToken } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/toast';
|
import { addToast } from '$lib/stores/toast';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import TagPill from '$lib/components/TagPill.svelte';
|
import { ExternalLink, Download, Link2, FileText } from 'lucide-svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import Card from '$lib/components/ui/Card.svelte';
|
||||||
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
|
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||||
|
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
|
||||||
|
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
|
||||||
|
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
|
||||||
|
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
|
||||||
|
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
|
||||||
|
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
|
||||||
|
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
|
||||||
|
|
||||||
marked.use({ mangle: false, headerIds: false });
|
marked.use({ mangle: false, headerIds: false });
|
||||||
function renderMd(text) {
|
function renderMd(text) {
|
||||||
@@ -20,21 +35,21 @@
|
|||||||
let doc = $state(null);
|
let doc = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state(null); // 'not_found' | 'network' | null
|
let error = $state(null); // 'not_found' | 'network' | null
|
||||||
let rawMarkdown = $state(''); // hotfix: extracted_text 없을 때 원본 fetch fallback
|
let rawMarkdown = $state(''); // fallback: extracted_text 없을 때 원본 .md
|
||||||
|
|
||||||
let docId = $derived($page.params.id);
|
let docId = $derived($page.params.id);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
doc = await api(`/documents/${docId}`);
|
doc = await api(`/documents/${docId}`);
|
||||||
// hotfix: markdown 계열인데 extracted_text 없으면 원본 .md 파일 직접 가져오기
|
|
||||||
// (split view의 DocumentViewer와 동일한 동작 — A-8 후 보고된 fallback 표시 문제 해결)
|
|
||||||
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
|
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
|
||||||
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
|
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
|
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
|
||||||
if (resp.ok) rawMarkdown = await resp.text();
|
if (resp.ok) rawMarkdown = await resp.text();
|
||||||
} catch (e) { rawMarkdown = ''; }
|
} catch (e) {
|
||||||
|
rawMarkdown = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err?.status === 404 ? 'not_found' : 'network';
|
error = err?.status === 404 ? 'not_found' : 'network';
|
||||||
@@ -43,7 +58,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
|
let viewerType = $derived(
|
||||||
|
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
|
||||||
|
);
|
||||||
|
|
||||||
function getViewerType(format) {
|
function getViewerType(format) {
|
||||||
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
|
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
|
||||||
@@ -53,152 +70,195 @@
|
|||||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(format)) return 'image';
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(format)) return 'image';
|
||||||
return 'unsupported';
|
return 'unsupported';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E.2 affordance row 핸들러
|
||||||
|
function copyLink() {
|
||||||
|
const url = `${window.location.origin}/documents/${docId}`;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() => addToast('success', '링크 복사됨'))
|
||||||
|
.catch(() => addToast('error', '복사 실패'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile() {
|
||||||
|
const url = `/api/documents/${docId}/file?token=${getAccessToken()}`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = doc?.title || `document-${docId}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocDelete() {
|
||||||
|
addToast('success', '문서가 삭제되어 목록으로 이동합니다.');
|
||||||
|
goto('/documents');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4 lg:p-6">
|
<div class="p-4 lg:p-6">
|
||||||
<!-- breadcrumb -->
|
<!-- breadcrumb -->
|
||||||
<div class="flex items-center gap-2 text-sm mb-4 text-[var(--text-dim)]">
|
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
|
||||||
<a href="/documents" class="hover:text-[var(--text)]">문서</a>
|
<a href="/documents" class="hover:text-text">문서</a>
|
||||||
<span>/</span>
|
<span class="text-faint">/</span>
|
||||||
<span class="truncate max-w-md text-[var(--text)]">{doc?.title || '로딩...'}</span>
|
<span class="truncate max-w-md text-text">{doc?.title || '로딩...'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="max-w-6xl mx-auto p-6">
|
<div class="max-w-6xl mx-auto">
|
||||||
<div class="bg-[var(--surface)] rounded-xl p-6 border border-[var(--border)] animate-pulse h-96"></div>
|
<Skeleton h="h-96" rounded="card" />
|
||||||
</div>
|
</div>
|
||||||
{:else if error === 'not_found'}
|
{:else if error === 'not_found'}
|
||||||
<div class="text-center py-20 text-[var(--text-dim)]">
|
<EmptyState
|
||||||
<p class="text-lg mb-2">문서를 찾을 수 없습니다</p>
|
icon={FileText}
|
||||||
<a href="/documents" class="text-sm text-[var(--accent)] hover:underline">목록으로 돌아가기</a>
|
title="문서를 찾을 수 없습니다"
|
||||||
</div>
|
description="삭제되었거나 접근 권한이 없을 수 있습니다."
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="sm" href="/documents">목록으로 돌아가기</Button>
|
||||||
|
</EmptyState>
|
||||||
{:else if error === 'network'}
|
{:else if error === 'network'}
|
||||||
<div class="text-center py-20 text-[var(--text-dim)]">
|
<EmptyState
|
||||||
<p class="text-lg mb-2">문서를 불러올 수 없습니다</p>
|
icon={FileText}
|
||||||
<button onclick={() => location.reload()} class="text-sm text-[var(--accent)] hover:underline">다시 시도</button>
|
title="문서를 불러올 수 없습니다"
|
||||||
</div>
|
description="네트워크 오류가 발생했습니다."
|
||||||
|
>
|
||||||
|
<Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button>
|
||||||
|
</EmptyState>
|
||||||
{:else if doc}
|
{:else if doc}
|
||||||
<div class="max-w-6xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<!-- 뷰어 (2/3) -->
|
<!-- 왼쪽 (2/3) — 뷰어 + affordance row -->
|
||||||
<div class="lg:col-span-2 bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 min-h-[500px]">
|
<div class="lg:col-span-2 space-y-4">
|
||||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
<!-- Affordance row -->
|
||||||
<div class="prose prose-invert prose-sm max-w-none">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{@html renderMd(doc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
|
{#if doc.edit_url}
|
||||||
</div>
|
<Button
|
||||||
{:else if viewerType === 'pdf'}
|
variant="secondary"
|
||||||
<iframe
|
size="sm"
|
||||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
icon={ExternalLink}
|
||||||
class="w-full h-[80vh] rounded"
|
href={doc.edit_url}
|
||||||
title={doc.title}
|
|
||||||
></iframe>
|
|
||||||
{:else if viewerType === 'image'}
|
|
||||||
<img src="/api/documents/{doc.id}/file?token={getAccessToken()}" alt={doc.title} class="max-w-full rounded" />
|
|
||||||
{:else if viewerType === 'synology'}
|
|
||||||
<div class="text-center py-10">
|
|
||||||
<p class="text-[var(--text-dim)] mb-4">Synology Office 문서</p>
|
|
||||||
<a
|
|
||||||
href={doc.edit_url || 'https://link.hyungi.net'}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)]"
|
|
||||||
>
|
>
|
||||||
새 창에서 열기
|
Synology 편집
|
||||||
</a>
|
</Button>
|
||||||
</div>
|
{/if}
|
||||||
{:else if viewerType === 'article'}
|
<Button variant="secondary" size="sm" icon={Download} onclick={downloadFile}>
|
||||||
<!-- 뉴스 전용 뷰어 -->
|
다운로드
|
||||||
<div>
|
</Button>
|
||||||
<h1 class="text-xl font-bold mb-3">{doc.title}</h1>
|
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
|
||||||
<div class="flex items-center gap-2 mb-4 text-xs text-[var(--text-dim)]">
|
링크 복사
|
||||||
<span>출처: {doc.source_channel}</span>
|
</Button>
|
||||||
<span>·</span>
|
</div>
|
||||||
<span>{new Date(doc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric' })}</span>
|
|
||||||
|
<!-- 뷰어 -->
|
||||||
|
<Card class="min-h-[500px]">
|
||||||
|
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||||
|
<div class="prose prose-invert prose-sm max-w-none markdown-body">
|
||||||
|
{@html renderMd(doc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
|
||||||
</div>
|
</div>
|
||||||
{#if doc.extracted_text}
|
{:else if viewerType === 'pdf'}
|
||||||
<div class="markdown-body mb-6">
|
<iframe
|
||||||
{@html renderMd(doc.extracted_text)}
|
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||||
</div>
|
class="w-full h-[80vh] rounded"
|
||||||
{/if}
|
title={doc.title}
|
||||||
{#if doc.edit_url}
|
></iframe>
|
||||||
<a
|
{:else if viewerType === 'image'}
|
||||||
href={doc.edit_url}
|
<img
|
||||||
|
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||||
|
alt={doc.title}
|
||||||
|
class="max-w-full rounded"
|
||||||
|
/>
|
||||||
|
{:else if viewerType === 'synology'}
|
||||||
|
<EmptyState
|
||||||
|
icon={ExternalLink}
|
||||||
|
title="Synology Office 문서"
|
||||||
|
description="외부 편집기에서 열어야 합니다."
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
href={doc.edit_url || 'https://link.hyungi.net'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)]"
|
새 창에서 열기
|
||||||
>원문 보기 →</a>
|
</Button>
|
||||||
{/if}
|
</EmptyState>
|
||||||
</div>
|
{:else if viewerType === 'article'}
|
||||||
{:else}
|
<div>
|
||||||
<div class="text-center py-10">
|
<h1 class="text-xl font-bold text-text mb-3">{doc.title}</h1>
|
||||||
<p class="text-[var(--text-dim)] mb-2">이 문서 형식은 인앱 미리보기를 지원하지 않습니다</p>
|
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||||
<p class="text-xs text-[var(--text-dim)]">포맷: {doc.file_format}</p>
|
<span>출처: {doc.source_channel}</span>
|
||||||
</div>
|
<span class="text-faint">·</span>
|
||||||
{/if}
|
<span>
|
||||||
|
{new Date(doc.created_at).toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if doc.extracted_text}
|
||||||
|
<div class="markdown-body mb-6">
|
||||||
|
{@html renderMd(doc.extracted_text)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if doc.edit_url}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={ExternalLink}
|
||||||
|
href={doc.edit_url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
원문 보기
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="인앱 미리보기 미지원"
|
||||||
|
description="포맷: {doc.file_format}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 메타데이터 패널 (1/3) -->
|
<!-- 오른쪽 (1/3) — editors stack -->
|
||||||
<div class="space-y-4">
|
<aside class="space-y-4">
|
||||||
<!-- 기본 정보 -->
|
<Card>
|
||||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
|
<NoteEditor {doc} />
|
||||||
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">문서 정보</h3>
|
</Card>
|
||||||
<dl class="space-y-2 text-sm">
|
<Card>
|
||||||
<div class="flex justify-between">
|
<EditUrlEditor {doc} />
|
||||||
<dt class="text-[var(--text-dim)]">포맷</dt>
|
</Card>
|
||||||
<dd class="uppercase">{doc.file_format}</dd>
|
<Card>
|
||||||
</div>
|
<TagsEditor {doc} />
|
||||||
<div class="flex justify-between">
|
</Card>
|
||||||
<dt class="text-[var(--text-dim)]">크기</dt>
|
<Card>
|
||||||
<dd>{doc.file_size ? (doc.file_size / 1024).toFixed(1) + ' KB' : '-'}</dd>
|
<AIClassificationEditor {doc} />
|
||||||
</div>
|
</Card>
|
||||||
<div class="flex justify-between">
|
<Card>
|
||||||
<dt class="text-[var(--text-dim)]">도메인</dt>
|
<FileInfoView {doc} />
|
||||||
<dd>{doc.ai_domain || '미분류'}</dd>
|
</Card>
|
||||||
</div>
|
<Card>
|
||||||
<div class="flex justify-between">
|
<ProcessingStatusView {doc} />
|
||||||
<dt class="text-[var(--text-dim)]">출처</dt>
|
</Card>
|
||||||
<dd>{doc.source_channel || '-'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 요약 -->
|
<!-- E.3 관련 문서 stub -->
|
||||||
{#if doc.ai_summary}
|
<Card>
|
||||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
|
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">관련 문서</h4>
|
||||||
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">AI 요약</h3>
|
<!-- TODO(backend): GET /documents/{id}/related?limit=10 (벡터 유사도) -->
|
||||||
<div class="text-sm leading-relaxed markdown-body">{@html renderMd(doc.ai_summary)}</div>
|
<EmptyState
|
||||||
</div>
|
icon={FileText}
|
||||||
{/if}
|
title="추후 지원"
|
||||||
|
description="관련 문서 추천은 backend 연동 후 제공됩니다."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- 태그 -->
|
<Card>
|
||||||
{#if doc.ai_tags?.length > 0}
|
<DocumentDangerZone {doc} ondelete={handleDocDelete} />
|
||||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
|
</Card>
|
||||||
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">태그</h3>
|
</aside>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
{#each doc.ai_tags as tag}
|
|
||||||
<TagPill {tag} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- 가공 이력 -->
|
|
||||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">가공 이력</h3>
|
|
||||||
<dl class="space-y-2 text-xs">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-[var(--text-dim)]">텍스트 추출</dt>
|
|
||||||
<dd>{doc.extracted_at ? new Date(doc.extracted_at).toLocaleDateString('ko') : '대기'}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-[var(--text-dim)]">AI 분류</dt>
|
|
||||||
<dd>{doc.ai_processed_at ? new Date(doc.ai_processed_at).toLocaleDateString('ko') : '대기'}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-[var(--text-dim)]">벡터 임베딩</dt>
|
|
||||||
<dd>{doc.embedded_at ? new Date(doc.embedded_at).toLocaleDateString('ko') : '대기'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user