가장 큰 위험 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
345 lines
12 KiB
Svelte
345 lines
12 KiB
Svelte
<script>
|
||
import { X, ExternalLink, Plus, Save, Trash2 } from 'lucide-svelte';
|
||
import { api } from '$lib/api';
|
||
import { addToast } from '$lib/stores/toast';
|
||
import FormatIcon from './FormatIcon.svelte';
|
||
import TagPill from './TagPill.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">
|
||
<!-- 헤더 -->
|
||
<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>
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<a href="/documents/{doc.id}" class="p-1 rounded hover:bg-surface text-dim" title="전체 보기">
|
||
<ExternalLink size={14} />
|
||
</a>
|
||
<button onclick={onclose} class="p-1 rounded hover:bg-surface text-dim" 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>
|
||
|
||
<!-- 삭제 -->
|
||
<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}
|
||
</div>
|
||
</div>
|
||
</aside>
|