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:
hyungi
2026-06-13 15:53:34 +09:00
parent c1555fd6ab
commit 74e29e510e
+369 -412
View File
@@ -1,19 +1,17 @@
<script>
// Phase E.2 — detail 페이지 inline 편집.
// 기존 read-only 메타 패널(L138201)을 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}