feat(docpage): D3 상세 페이지를 확정 시안 그대로 재구현
기존 컴포넌트 재사용/배치변경(불충실)을 폐기하고 deepened 시안을 충실히 구현: - 좌 절 트리: 유형 색칩(정의/절차/요건)·신뢰도 dot·저신뢰 경고·레벨 들여쓰기·클릭=절 선택 - 중 절 집중 뷰: breadcrumb + 제목 + 유형 배지 + 신뢰도 막대 + 절 요약 인용 + 절 본문 (md_content 를 char_start 로 슬라이스) + 이전/다음 절 - 우 슬림 레일: TL;DR · 핵심점 · 심층(DEEP) · 불일치 · 분류 · 태그 (읽기) + 정보/관리 접이(편집 보존) - 절 없음 fallback: 전체 본문/뷰어 + 레일 (D3 빈 절 graceful) - 모바일: 본문(절 집중) 메인 + 절구조/인사이트 접이 svelte-check 0. 시안=comparisons/2026-06-13-ds-docpage-d3-deepened.html. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,17 @@
|
||||
<script>
|
||||
// Phase E.2 — detail 페이지 inline 편집.
|
||||
// 기존 read-only 메타 패널(L138–201)을 editors/* 스택으로 교체.
|
||||
// + E.3 관련 문서 stub, + 헤더 affordance row.
|
||||
// 문서 상세 /documents/[id] — D3 절 구조 탐색기 (확정 시안 충실 구현).
|
||||
// 레이아웃: [좌 절 트리][중 절 집중 뷰][우 슬림 전역 인사이트 레일]. 절 없으면 fallback(본문+레일).
|
||||
// 절 본문 = md_content 를 char_start 로 슬라이스. 기존 편집/필기/인접네비/회독은 보존(관리·정보 접이).
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { resolveAnchorMap } from '$lib/utils/resolveAnchorMap';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ExternalLink, Download, Link2, FileText, PenLine, X, ChevronLeft, ChevronRight, Check } 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 HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
|
||||
@@ -27,13 +25,13 @@
|
||||
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
|
||||
import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte';
|
||||
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
|
||||
import AnalysisPanel from '$lib/components/AnalysisPanel.svelte';
|
||||
import ReadCounter from '$lib/components/ReadCounter.svelte';
|
||||
import SectionOutline from '$lib/components/SectionOutline.svelte';
|
||||
import { cleanHeading, pathSegments, sectionTypeLabel } from '$lib/utils/headingPath';
|
||||
import { domainLabel } from '$lib/utils/domainSlug';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text), {
|
||||
return DOMPurify.sanitize(marked(text || ''), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
@@ -43,52 +41,36 @@
|
||||
|
||||
let doc = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null); // 'not_found' | 'network' | null
|
||||
let rawMarkdown = $state(''); // fallback: extracted_text 없을 때 원본 .md
|
||||
|
||||
let error = $state(null);
|
||||
let rawMarkdown = $state('');
|
||||
let docId = $derived($page.params.id);
|
||||
|
||||
// 손글씨 노트 (자료별 1:1) — "필기" 토글 시 사이드 캔버스 띄움.
|
||||
// 손글씨 노트
|
||||
let noteOpen = $state(false);
|
||||
let noteStrokes = $state(null); // { version, strokes }
|
||||
let noteStrokes = $state(null);
|
||||
let noteLoaded = $state(false);
|
||||
async function ensureNoteLoaded() {
|
||||
if (noteLoaded) return;
|
||||
try {
|
||||
const r = await api(`/documents/${docId}/note`);
|
||||
noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] };
|
||||
} catch {
|
||||
noteStrokes = { version: 1, strokes: [] };
|
||||
}
|
||||
} catch { noteStrokes = { version: 1, strokes: [] }; }
|
||||
noteLoaded = true;
|
||||
}
|
||||
async function saveNote(strokesJson) {
|
||||
try {
|
||||
await api(`/documents/${docId}/note`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ strokes_json: strokesJson }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('필기 저장 실패', err);
|
||||
}
|
||||
}
|
||||
async function toggleNote() {
|
||||
if (!noteOpen) await ensureNoteLoaded();
|
||||
noteOpen = !noteOpen;
|
||||
try { await api(`/documents/${docId}/note`, { method: 'PUT', body: JSON.stringify({ strokes_json: strokesJson }) }); }
|
||||
catch (err) { console.warn('필기 저장 실패', err); }
|
||||
}
|
||||
async function toggleNote() { if (!noteOpen) await ensureNoteLoaded(); noteOpen = !noteOpen; }
|
||||
|
||||
// 인접 자료 (같은 library_path 내 이전/다음) — 학습 흐름 네비게이션
|
||||
// 인접 자료 (학습 흐름)
|
||||
let neighbors = $state({ prev: null, next: null });
|
||||
async function loadNeighbors() {
|
||||
try {
|
||||
neighbors = await api(`/documents/${docId}/library-neighbors`);
|
||||
} catch {
|
||||
neighbors = { prev: null, next: null };
|
||||
}
|
||||
try { neighbors = await api(`/documents/${docId}/library-neighbors`); }
|
||||
catch { neighbors = { prev: null, next: null }; }
|
||||
}
|
||||
|
||||
// 절(hier section) 목차 — 본문 로드와 독립, 실패(404 포함) 무해.
|
||||
// reqId guard: 문서 전환 race 시 stale 결과가 새 문서에 붙지 않게.
|
||||
// 절(section) 목차
|
||||
let sections = $state([]);
|
||||
let hasSections = $derived(sections.length > 0);
|
||||
async function loadSections() {
|
||||
@@ -97,22 +79,14 @@
|
||||
const r = await api(`/documents/${reqId}/sections`);
|
||||
if (reqId === docId) sections = r?.sections ?? [];
|
||||
} catch {
|
||||
if (reqId === docId) sections = []; // Phase 1 미배포 시 404 → 목차 숨김(graceful)
|
||||
if (reqId === docId) sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
// "1회독 완료 + 다음 자료로" 한 번에
|
||||
async function readAndGoNext() {
|
||||
try {
|
||||
await api(`/documents/${docId}/read`, { method: 'POST' });
|
||||
addToast('success', '1회독 완료');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '회독 기록 실패');
|
||||
return;
|
||||
}
|
||||
if (neighbors.next) {
|
||||
goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
try { await api(`/documents/${docId}/read`, { method: 'POST' }); addToast('success', '1회독 완료'); }
|
||||
catch (err) { addToast('error', err?.detail || '회독 기록 실패'); return; }
|
||||
if (neighbors.next) goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -123,16 +97,11 @@
|
||||
try {
|
||||
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
|
||||
if (resp.ok) rawMarkdown = await resp.text();
|
||||
} catch (e) {
|
||||
rawMarkdown = '';
|
||||
}
|
||||
} catch { rawMarkdown = ''; }
|
||||
}
|
||||
} catch (err) {
|
||||
error = err?.status === 404 ? 'not_found' : 'network';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
// 자료실 자료면 인접 자료 미리 fetch (학습 흐름 네비)
|
||||
} finally { loading = false; }
|
||||
if (doc && doc.category === 'library') loadNeighbors();
|
||||
if (doc) loadSections();
|
||||
});
|
||||
@@ -140,67 +109,17 @@
|
||||
let viewerType = $derived(
|
||||
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
|
||||
);
|
||||
let canShowMarkdown = $derived(!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim()));
|
||||
// D3 절 집중 뷰 조건: 절 + 마크다운 본문 둘 다 있을 때. 아니면 fallback(본문/뷰어 + 레일).
|
||||
let useSectionView = $derived(hasSections && canShowMarkdown && !!doc?.md_content);
|
||||
|
||||
// PDF 분기 전용: marker_worker 가 만든 canonical markdown 이 있으면 기본으로 그것을 보여줌.
|
||||
// Phase 1B 산출물의 95% 가 PDF 라 1D pilot 평가가 실사용 화면 기반이 되도록 markdown-first.
|
||||
// 사용자가 "PDF 원본" 토글하면 iframe. lastDocId 로 문서 전환만 감지해서 사용자 토글이
|
||||
// reactive cycle 에 덮이지 않도록 보호.
|
||||
let pdfViewMode = $state('markdown'); // 'markdown' | 'pdf'
|
||||
// PDF 폴백 토글
|
||||
let pdfViewMode = $state('markdown');
|
||||
let lastDocId = $state(null);
|
||||
let canShowMarkdown = $derived(
|
||||
!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim())
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!doc) return;
|
||||
if (doc.id !== lastDocId) {
|
||||
lastDocId = doc.id;
|
||||
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
|
||||
}
|
||||
// 같은 문서 안에서 markdown 이 사라지면 (success → failed 재처리 등) PDF 로 보호.
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') {
|
||||
pdfViewMode = 'pdf';
|
||||
}
|
||||
});
|
||||
|
||||
// ── 개요 점프 (경로 B: BE char_start primary + string-match 폴백) ──
|
||||
// 이 사이트는 항상 md_content basis(canShowMarkdown && doc.md_content) → trustBE=true.
|
||||
// BE char_start 가 있으면 채택, 비면(non-PASS/미백필) resolveAnchorMap 내부에서 buildAnchorMap 로 폴백.
|
||||
let anchorMap = $derived(
|
||||
hasSections && canShowMarkdown && doc?.md_content
|
||||
? resolveAnchorMap(doc.md_content, sections, { trustBE: true }).anchors
|
||||
: {}
|
||||
);
|
||||
let activeKey = $state(null);
|
||||
function jumpToSection(chunkId) {
|
||||
const el = document.getElementById(`sec-${chunkId}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
|
||||
$effect(() => {
|
||||
void anchorMap; // 문서/섹션 변화 시 재바인딩
|
||||
if (typeof window === 'undefined') return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
let cur = null;
|
||||
document.querySelectorAll('.md-anchor').forEach((a) => {
|
||||
if (a.getBoundingClientRect().top <= 120) cur = a;
|
||||
});
|
||||
if (cur) {
|
||||
const m = cur.id.match(/^sec-(\d+)$/);
|
||||
if (m) activeKey = Number(m[1]);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
if (doc.id !== lastDocId) { lastDocId = doc.id; pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf'; }
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
|
||||
});
|
||||
|
||||
function getViewerType(format) {
|
||||
@@ -212,30 +131,197 @@
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// E.2 affordance row 핸들러
|
||||
// ── 절 집중 뷰 상태 ──
|
||||
let selectedSectionId = $state(null);
|
||||
$effect(() => {
|
||||
// 문서/섹션 로드 시 첫 절 선택. 선택이 현재 섹션집합에 없으면 첫 절로.
|
||||
if (sections.length && !sections.some((s) => s.chunk_id === selectedSectionId)) {
|
||||
selectedSectionId = sections[0].chunk_id;
|
||||
}
|
||||
});
|
||||
let selectedSection = $derived(sections.find((s) => s.chunk_id === selectedSectionId) ?? sections[0] ?? null);
|
||||
let selIdx = $derived(sections.findIndex((s) => s.chunk_id === selectedSection?.chunk_id));
|
||||
// char_start 정렬 — 절 본문 슬라이스용
|
||||
let sortedSecs = $derived(
|
||||
[...sections].filter((s) => s.char_start != null).sort((a, b) => a.char_start - b.char_start)
|
||||
);
|
||||
function sectionBodyHtml(sec) {
|
||||
if (!doc?.md_content || !sec || sec.char_start == null) return '';
|
||||
const idx = sortedSecs.findIndex((s) => s.chunk_id === sec.chunk_id);
|
||||
const start = sec.char_start;
|
||||
const end = idx >= 0 && idx + 1 < sortedSecs.length ? sortedSecs[idx + 1].char_start : doc.md_content.length;
|
||||
return renderMd(doc.md_content.slice(start, end));
|
||||
}
|
||||
let selectedBodyHtml = $derived(sectionBodyHtml(selectedSection));
|
||||
|
||||
const TYPE_META = {
|
||||
definition: { label: '정의', color: '#2f7d8f' },
|
||||
procedure: { label: '절차', color: '#4f8a6b' },
|
||||
requirement: { label: '요건', color: '#b5840a' },
|
||||
};
|
||||
function typeMeta(t) { return TYPE_META[t] ?? { label: sectionTypeLabel(t) || '', color: '#9aa090' }; }
|
||||
function confColor(c) { return c == null ? '#9aa090' : c < 0.6 ? '#b5840a' : '#1f9d6b'; }
|
||||
function isLowConf(c) { return c != null && c < 0.5; }
|
||||
function secTitle(s) { return cleanHeading(s.section_title) || pathSegments(s.heading_path).at(-1) || '(제목 없음)'; }
|
||||
function secDepth(s) { return Math.max(0, (s.level ?? 1) - 1); }
|
||||
function selectSection(id) { selectedSectionId = id; }
|
||||
|
||||
// 추출 품질 요약(있으면)
|
||||
let quality = $derived(doc?.md_extraction_quality?.metrics ?? doc?.md_extraction_quality ?? null);
|
||||
|
||||
// 헤더/affordance
|
||||
function copyLink() {
|
||||
const url = `${window.location.origin}/documents/${docId}`;
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => addToast('success', '링크 복사됨'))
|
||||
.catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
|
||||
function downloadOriginal() {
|
||||
window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.open(`/api/documents/${docId}/preview?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function handleDocDelete() {
|
||||
addToast('success', '문서가 삭제되어 목록으로 이동합니다.');
|
||||
goto('/documents');
|
||||
navigator.clipboard.writeText(url).then(() => addToast('success', '링크 복사됨')).catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
function downloadOriginal() { window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`); }
|
||||
function handleDocDelete() { addToast('success', '문서가 삭제되어 목록으로 이동합니다.'); goto('/documents'); }
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<!-- ════ 절 트리 (좌) ════ -->
|
||||
{#snippet treeNav()}
|
||||
<div class="text-xs">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-[11px] font-bold text-dim uppercase tracking-wide">절 구조</span>
|
||||
<span class="text-faint text-[11px]">{sections.length}절</span>
|
||||
</div>
|
||||
<!-- 유형 범례 -->
|
||||
<div class="flex items-center gap-3 px-1 pb-2 mb-1 border-b border-default text-[10px] text-dim">
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#2f7d8f"></span>정의</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#4f8a6b"></span>절차</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#b5840a"></span>요건</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
{#each sections as s (s.chunk_id)}
|
||||
{@const tm = typeMeta(s.section_type)}
|
||||
{@const active = s.chunk_id === selectedSection?.chunk_id}
|
||||
{@const low = isLowConf(s.confidence)}
|
||||
<li>
|
||||
<button type="button" onclick={() => selectSection(s.chunk_id)} aria-current={active ? 'true' : undefined}
|
||||
style="padding-left:{8 + secDepth(s) * 13}px"
|
||||
class="w-full text-left pr-2 py-1.5 rounded-md flex items-center gap-2 transition-colors border border-transparent
|
||||
{active ? 'bg-surface-active text-text border-accent/40 font-semibold' : 'text-dim hover:bg-surface-hover hover:text-text'}
|
||||
{secDepth(s) > 0 ? 'text-[11px]' : 'text-xs'}">
|
||||
<span class="w-2 h-2 rounded-sm shrink-0" style="background:{tm.color}"></span>
|
||||
<span class="flex-1 min-w-0 truncate leading-snug">{secTitle(s)}</span>
|
||||
{#if low}
|
||||
<span class="shrink-0 w-3.5 h-3.5 rounded-full border border-warning text-warning text-[9px] font-extrabold flex items-center justify-center" title="저신뢰 — 추출 불완전">!</span>
|
||||
{:else}
|
||||
<span class="shrink-0 w-1.5 h-1.5 rounded-full" style="background:{confColor(s.confidence)}" title="신뢰도 {s.confidence != null ? s.confidence.toFixed(2) : '—'}"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if quality}
|
||||
<div class="mt-3 pt-2 border-t border-default text-[10px] text-faint leading-relaxed">
|
||||
추출 품질
|
||||
{#if quality.headings != null}· 제목 {quality.headings}{/if}
|
||||
{#if quality.tables != null}· 표 {quality.tables}{/if}
|
||||
{#if quality.images != null}· 이미지 {quality.images}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 슬림 전역 인사이트 레일 (우) ════ -->
|
||||
{#snippet rail()}
|
||||
<div class="space-y-3 text-xs">
|
||||
<!-- TL;DR -->
|
||||
{#if doc.ai_tldr || doc.ai_summary}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-warning uppercase tracking-wide mb-1.5">TL;DR</div>
|
||||
<p class="text-text leading-relaxed">{doc.ai_tldr || doc.ai_summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 핵심점 -->
|
||||
{#if doc.ai_bullets && doc.ai_bullets.length}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-2">핵심점</div>
|
||||
<ul class="space-y-1.5">
|
||||
{#each doc.ai_bullets as b}
|
||||
<li class="flex gap-1.5 text-text leading-relaxed"><span class="text-accent mt-px">·</span><span class="flex-1 min-w-0">{b}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 심층 -->
|
||||
{#if doc.ai_detail_summary}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="text-[10px] font-bold text-dim uppercase tracking-wide">심층</span>
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span class="text-[9px] font-bold rounded px-1.5 py-px" style="background:#e3ebdf;color:#3d7256">DEEP</span>{/if}
|
||||
</div>
|
||||
<p class="text-dim leading-relaxed whitespace-pre-line">{doc.ai_detail_summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 불일치 -->
|
||||
{#if doc.ai_inconsistencies && doc.ai_inconsistencies.length}
|
||||
<div class="bg-surface border border-warning/50 rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-warning uppercase tracking-wide mb-1.5">불일치 {doc.ai_inconsistencies.length}</div>
|
||||
<ul class="space-y-1">
|
||||
{#each doc.ai_inconsistencies as inc}
|
||||
<li class="text-text leading-relaxed">· {typeof inc === 'string' ? inc : inc.desc || inc.kind}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 분류 -->
|
||||
{#if doc.ai_domain}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-1.5">분류</div>
|
||||
<div class="text-text leading-relaxed">
|
||||
<strong>{domainLabel(doc.ai_domain)}</strong>{#if doc.ai_sub_group} · {doc.ai_sub_group}{/if}
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
{#if doc.ai_confidence != null}<span class="text-success font-bold">신뢰도 {doc.ai_confidence.toFixed(2)}</span>{/if}
|
||||
{#if doc.ai_analysis_tier}<span class="text-[9px] font-bold rounded px-1.5 py-px" style="background:#e3ebdf;color:#3d7256">{doc.ai_analysis_tier}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 태그 -->
|
||||
{#if doc.ai_tags && doc.ai_tags.length}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-2">태그</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each doc.ai_tags as t}<span class="text-[10.5px] text-accent-hover bg-accent/12 rounded-full px-2.5 py-0.5">{t}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 관련 문서 (v1 자리) -->
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-1.5">관련 문서</div>
|
||||
<p class="text-faint leading-relaxed text-[11px]">벡터 유사도 기반 — 준비 중</p>
|
||||
</div>
|
||||
<!-- 정보 (접이) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-[10px] font-bold text-dim uppercase tracking-wide select-none">
|
||||
<span>문서 정보</span><ChevronRight size={13} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
{#if doc.category === 'library'}<ReadCounter documentId={doc.id} initialCount={doc.read_count ?? 0} initialLastReadAt={doc.last_read_at ?? null} />{/if}
|
||||
<FileInfoView {doc} />
|
||||
<ProcessingStatusView {doc} />
|
||||
</div>
|
||||
</details>
|
||||
<!-- 관리 (접이) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-[10px] font-bold text-dim uppercase tracking-wide select-none">
|
||||
<span>관리 · 분류 편집</span><ChevronRight size={13} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
<AIClassificationEditor {doc} />
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
<div class="pt-2 border-t border-default"><DocumentDangerZone {doc} ondelete={handleDocDelete} /></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="p-4 lg:p-6 max-w-[1360px] mx-auto">
|
||||
<!-- breadcrumb -->
|
||||
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a>
|
||||
@@ -244,320 +330,191 @@
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
</div>
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
{:else if error === 'not_found'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 찾을 수 없습니다"
|
||||
description="삭제되었거나 접근 권한이 없을 수 있습니다."
|
||||
>
|
||||
<EmptyState icon={FileText} title="문서를 찾을 수 없습니다" description="삭제되었거나 접근 권한이 없을 수 있습니다.">
|
||||
<Button variant="ghost" size="sm" href="/documents">목록으로 돌아가기</Button>
|
||||
</EmptyState>
|
||||
{:else if error === 'network'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 불러올 수 없습니다"
|
||||
description="네트워크 오류가 발생했습니다."
|
||||
>
|
||||
<EmptyState icon={FileText} title="문서를 불러올 수 없습니다" description="네트워크 오류가 발생했습니다.">
|
||||
<Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button>
|
||||
</EmptyState>
|
||||
{:else if doc}
|
||||
<div class="mx-auto grid grid-cols-1 gap-6 {hasSections ? 'max-w-7xl xl:grid-cols-[18rem_minmax(0,1fr)_20rem]' : 'max-w-6xl lg:grid-cols-3'}">
|
||||
{#if hasSections}
|
||||
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
|
||||
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
|
||||
<Card>
|
||||
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
|
||||
</Card>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<!-- 본문 (좌측 목차 없을 때 lg 2/3) -->
|
||||
<div class="{hasSections ? '' : 'lg:col-span-2'} space-y-4">
|
||||
{#if hasSections}
|
||||
<!-- xl 미만: 절 목차 접이식 -->
|
||||
<details class="xl:hidden">
|
||||
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
|
||||
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
|
||||
</details>
|
||||
{/if}
|
||||
<!-- Affordance row -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
Synology 편집
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>
|
||||
원본 다운로드
|
||||
</Button>
|
||||
{#if doc.preview_status === 'ready'}
|
||||
<Button variant="secondary" size="sm" icon={FileText} onclick={downloadPdf}>
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
|
||||
링크 복사
|
||||
</Button>
|
||||
<!-- ════ 상단 띠: 문서 헤더 ════ -->
|
||||
<div class="bg-surface border border-default rounded-card px-4 py-3 mb-4">
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1.5">
|
||||
{#if doc.ai_domain}
|
||||
<span class="inline-flex items-center gap-1 text-[11px] font-semibold" style="color:#b5840a">
|
||||
<span class="w-2 h-2 rounded-full" style="background:#b5840a"></span>{domainLabel(doc.ai_domain)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if doc.ai_sub_group}<span class="text-[11px] text-faint">{doc.ai_sub_group}</span>{/if}
|
||||
<span class="text-[10px] font-bold rounded px-1.5 py-px uppercase" style="background:#f7e7e3;color:#c0564a">{doc.file_format}{#if canShowMarkdown}→MD{/if}</span>
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span class="text-[10px] font-bold rounded px-1.5 py-px" style="background:#e3ebdf;color:#3d7256">tier DEEP</span>{/if}
|
||||
{#if doc.ai_confidence != null}<span class="text-[11px] text-dim">신뢰도 <b class="text-success">{doc.ai_confidence.toFixed(2)}</b></span>{/if}
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-text leading-snug">{doc.title}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
{#if doc.edit_url}<Button variant="secondary" size="sm" icon={ExternalLink} href={doc.edit_url} target="_blank">Synology</Button>{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>원본</Button>
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>링크</Button>
|
||||
{#if doc.category === 'library'}
|
||||
<Button
|
||||
variant={noteOpen ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
icon={noteOpen ? X : PenLine}
|
||||
onclick={toggleNote}
|
||||
>
|
||||
{noteOpen ? '필기 닫기' : '필기'}
|
||||
</Button>
|
||||
<Button variant={noteOpen ? 'primary' : 'secondary'} size="sm" icon={noteOpen ? X : PenLine} onclick={toggleNote}>{noteOpen ? '필기 닫기' : '필기'}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if useSectionView}
|
||||
<!-- ════ 3영역: 트리 | 절 집중 | 레일 ════ -->
|
||||
<!-- 모바일: 본문(절 집중) 메인 + 절 구조/인사이트 접이 -->
|
||||
<details class="xl:hidden bg-surface border border-default rounded-card mb-3 group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-dim select-none">절 구조 ({sections.length}절)<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" /></summary>
|
||||
<div class="px-4 pb-3">{@render treeNav()}</div>
|
||||
</details>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[252px_minmax(0,1fr)_336px] gap-4 items-start">
|
||||
<!-- 좌 트리 (xl+ sticky) -->
|
||||
<aside class="hidden xl:block xl:sticky xl:top-4 xl:self-start xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto bg-surface border border-default rounded-card p-3.5">
|
||||
{@render treeNav()}
|
||||
</aside>
|
||||
|
||||
<!-- 중 절 집중 뷰 -->
|
||||
<div class="min-w-0 bg-surface border border-default rounded-card p-5">
|
||||
{#if selectedSection}
|
||||
{@const tm = typeMeta(selectedSection.section_type)}
|
||||
<!-- breadcrumb -->
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-faint mb-2 flex-wrap">
|
||||
<span class="truncate max-w-[180px]">{doc.title}</span>
|
||||
{#each pathSegments(selectedSection.heading_path) as seg}<span>/</span><span class="text-dim">{seg}</span>{/each}
|
||||
</div>
|
||||
<!-- 제목 + 유형 배지 -->
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<h2 class="text-xl font-bold text-text leading-snug min-w-0">{secTitle(selectedSection)}</h2>
|
||||
{#if tm.label}<span class="shrink-0 text-[11px] font-semibold rounded-full px-2.5 py-1" style="background:{tm.color}1a;color:{tm.color}">{tm.label}</span>{/if}
|
||||
</div>
|
||||
<!-- 신뢰도 막대 -->
|
||||
{#if selectedSection.confidence != null}
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<span class="text-[11px] text-dim">신뢰도</span>
|
||||
<span class="flex-1 max-w-[260px] h-1.5 rounded-full bg-default overflow-hidden">
|
||||
<span class="block h-full rounded-full" style="width:{Math.round(selectedSection.confidence * 100)}%;background:{confColor(selectedSection.confidence)}"></span>
|
||||
</span>
|
||||
<span class="text-xs font-bold tabular-nums" style="color:{confColor(selectedSection.confidence)}">{selectedSection.confidence.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 저신뢰 경고 -->
|
||||
{#if isLowConf(selectedSection.confidence)}
|
||||
<div class="flex items-start gap-2 bg-surface-hover border border-warning/50 rounded-lg px-3 py-2 mb-3 text-xs text-warning">
|
||||
<span class="shrink-0 w-4 h-4 rounded-full border border-warning text-warning text-[10px] font-extrabold flex items-center justify-center mt-px">!</span>
|
||||
<span>저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 절 요약 (인용) -->
|
||||
{#if selectedSection.summary}
|
||||
<div class="border-l-[3px] border-accent bg-surface-hover rounded-r-lg px-4 py-3 mb-4">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-1">절 요약</div>
|
||||
<p class="text-text leading-relaxed whitespace-pre-line">{selectedSection.summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 절 본문 -->
|
||||
{#if selectedBodyHtml}
|
||||
<div class="prose prose-base max-w-none text-text">{@html selectedBodyHtml}</div>
|
||||
{:else}
|
||||
<p class="text-faint text-sm italic">이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.</p>
|
||||
{/if}
|
||||
<!-- 풋터: 이전/다음 절 -->
|
||||
<div class="flex items-center justify-between mt-5 pt-3 border-t border-default text-xs">
|
||||
<button type="button" disabled={selIdx <= 0} onclick={() => selIdx > 0 && selectSection(sections[selIdx - 1].chunk_id)}
|
||||
class="flex items-center gap-1 text-dim hover:text-accent disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft size={14} />이전 절</button>
|
||||
<span class="text-faint tabular-nums">{selIdx + 1} / {sections.length}</span>
|
||||
<button type="button" disabled={selIdx >= sections.length - 1} onclick={() => selIdx < sections.length - 1 && selectSection(sections[selIdx + 1].chunk_id)}
|
||||
class="flex items-center gap-1 text-dim hover:text-accent disabled:opacity-30 disabled:cursor-not-allowed">다음 절<ChevronRight size={14} /></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 뷰어 — 모바일 가독성: 본문 폰트 키우고 line-height 늘림 -->
|
||||
<Card class="min-h-[500px]">
|
||||
<!-- 우 슬림 레일 -->
|
||||
<aside class="min-w-0">
|
||||
<!-- 모바일: 인사이트 접이 / xl: 상시 -->
|
||||
<details open class="xl:hidden bg-surface border border-default rounded-card mb-2 group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-dim select-none">인사이트<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" /></summary>
|
||||
<div class="px-3 pb-3">{@render rail()}</div>
|
||||
</details>
|
||||
<div class="hidden xl:block">{@render rail()}</div>
|
||||
</aside>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ════ Fallback: 절 없음 — 본문/뷰어 + 레일 (D3 빈 절 graceful) ════ -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_336px] gap-4 items-start">
|
||||
<div class="min-w-0 bg-surface border border-default rounded-card p-5 min-h-[400px]">
|
||||
{#if !hasSections && canShowMarkdown}
|
||||
<p class="text-[11px] text-faint mb-3">이 문서는 절 분석이 없습니다 (짧은 문서이거나 분석 전) — 전체 본문으로 표시합니다.</p>
|
||||
{/if}
|
||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
extractedText={doc.extracted_text || rawMarkdown}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text || rawMarkdown} class="prose prose-base max-w-none" />
|
||||
{:else if viewerType === 'pdf'}
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<MarkdownStatusBadge
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
/>
|
||||
<MarkdownStatusBadge mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} />
|
||||
{#if canShowMarkdown}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'markdown')}
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'pdf')}
|
||||
>
|
||||
PDF 원본
|
||||
</Button>
|
||||
<Button size="sm" variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'markdown')}>Markdown</Button>
|
||||
<Button size="sm" variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'pdf')}>PDF 원본</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pdfViewMode === 'markdown' && canShowMarkdown}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />
|
||||
{:else}
|
||||
<iframe
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
class="w-full h-[80vh] rounded"
|
||||
title={doc.title}
|
||||
></iframe>
|
||||
<iframe src="/api/documents/{doc.id}/file?token={getAccessToken()}" class="w-full h-[80vh] rounded" title={doc.title}></iframe>
|
||||
{/if}
|
||||
{:else if viewerType === 'image'}
|
||||
<img
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
alt={doc.title}
|
||||
class="max-w-full rounded"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
새 창에서 열기
|
||||
</Button>
|
||||
<EmptyState icon={ExternalLink} title="Synology Office 문서" description="외부 편집기에서 열어야 합니다.">
|
||||
<Button variant="primary" size="sm" href={doc.edit_url || 'https://link.hyungi.net'} target="_blank">새 창에서 열기</Button>
|
||||
</EmptyState>
|
||||
{:else if viewerType === 'article'}
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text mb-3">{doc.title}</h1>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||
<span>출처: {doc.source_channel}</span>
|
||||
<span class="text-faint">·</span>
|
||||
<span>
|
||||
{new Date(doc.created_at).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{#if doc.md_content || doc.extracted_text}
|
||||
<!-- article = 텍스트 네이티브(markdown 변환 비대상). md_status='skipped' 라도
|
||||
"Markdown 제외" badge 를 띄우지 않도록 mdStatus 미전달(badge 는 mdStatus 로만 구동). -->
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={null}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="mb-6"
|
||||
/>
|
||||
{/if}
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
원문 보기
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if doc.md_content || doc.extracted_text}
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={null} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />
|
||||
{/if}
|
||||
{#if doc.edit_url}<div class="mt-4"><Button variant="primary" size="sm" icon={ExternalLink} href={doc.edit_url} target="_blank">원문 보기</Button></div>{/if}
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="인앱 미리보기 미지원"
|
||||
description="포맷: {doc.file_format}"
|
||||
/>
|
||||
<EmptyState icon={FileText} title="인앱 미리보기 미지원" description="포맷: {doc.file_format}" />
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<Card class="overflow-hidden p-0">
|
||||
<div class="h-[60vh] min-h-[400px] flex flex-col">
|
||||
<HandwriteCanvas
|
||||
sessionId={doc.id}
|
||||
initialStrokes={noteStrokes}
|
||||
onChange={(strokes) => saveNote(strokes)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
<aside class="min-w-0">
|
||||
<details open class="xl:hidden bg-surface border border-default rounded-card mb-2 group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-dim select-none">인사이트<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" /></summary>
|
||||
<div class="px-3 pb-3">{@render rail()}</div>
|
||||
</details>
|
||||
<div class="hidden xl:block">{@render rail()}</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 오른쪽 — 슬림 전역 인사이트 레일 (D3: 탭 게이트 제거, 요약·심층·불일치 상시 노출).
|
||||
정보/관리는 접이(<details>) — 데스크탑은 인사이트 상시, 모바일은 본문 메인 + 열어서 확인. -->
|
||||
<aside class="min-w-0 space-y-3">
|
||||
{#if doc.category === 'library'}
|
||||
<Card>
|
||||
<ReadCounter
|
||||
documentId={doc.id}
|
||||
initialCount={doc.read_count ?? 0}
|
||||
initialLastReadAt={doc.last_read_at ?? null}
|
||||
/>
|
||||
</Card>
|
||||
{/if}
|
||||
<!-- 손글씨 노트 -->
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<div class="bg-surface border border-default rounded-card overflow-hidden mt-4">
|
||||
<div class="h-[60vh] min-h-[400px] flex flex-col">
|
||||
<HandwriteCanvas sessionId={doc.id} initialStrokes={noteStrokes} onChange={(strokes) => saveNote(strokes)} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 요약·분석 — 기본 펼침(데스크탑 상시감, 모바일 접기 가능) -->
|
||||
<details open class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-xs font-semibold text-dim uppercase tracking-wide select-none">
|
||||
<span>요약 · 분석</span>
|
||||
<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-4">
|
||||
<AnalysisPanel docId={doc.id} doc={doc} />
|
||||
<AIClassificationEditor {doc} />
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">관련 문서</h4>
|
||||
<!-- TODO(backend): GET /documents/{id}/related?limit=10 (벡터 유사도) — v1 제외(자리만) -->
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="추후 지원"
|
||||
description="관련 문서 추천은 backend 연동 후 제공됩니다."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 문서 정보 — 접이(기본 닫힘) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-xs font-semibold text-dim uppercase tracking-wide select-none">
|
||||
<span>문서 정보</span>
|
||||
<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
<FileInfoView {doc} />
|
||||
<ProcessingStatusView {doc} />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 관리 — 접이(기본 닫힘) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-xs font-semibold text-dim uppercase tracking-wide select-none">
|
||||
<span>관리</span>
|
||||
<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
<div class="pt-2 border-t border-default">
|
||||
<DocumentDangerZone {doc} ondelete={handleDocDelete} />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 sticky 하단 바 — 자료실 자료의 학습 흐름 네비게이션 -->
|
||||
<!-- 모바일 학습 흐름 네비 (자료실) -->
|
||||
{#if doc.category === 'library'}
|
||||
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 bg-surface border-t border-default px-3 py-2 flex items-center gap-2 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)}
|
||||
disabled={!neighbors.prev}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="이전 자료"
|
||||
><ChevronLeft size={20} /></button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={readAndGoNext}
|
||||
disabled={!neighbors.next}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Check size={16} />
|
||||
{#if neighbors.next}
|
||||
1회독 완료 + 다음
|
||||
{:else}
|
||||
1회독 완료 (마지막 자료)
|
||||
{/if}
|
||||
<button type="button" onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)} disabled={!neighbors.prev}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30" aria-label="이전 자료"><ChevronLeft size={20} /></button>
|
||||
<button type="button" onclick={readAndGoNext} disabled={!neighbors.next}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50">
|
||||
<Check size={16} />{#if neighbors.next}1회독 완료 + 다음{:else}1회독 완료 (마지막){/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)}
|
||||
disabled={!neighbors.next}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="다음 자료 (회독 카운트 안 함)"
|
||||
><ChevronRight size={20} /></button>
|
||||
<button type="button" onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)} disabled={!neighbors.next}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30" aria-label="다음 자료"><ChevronRight size={20} /></button>
|
||||
</div>
|
||||
<!-- 본문이 sticky 바 뒤에 가리지 않도록 패딩 -->
|
||||
<div class="lg:hidden h-20"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user