@@ -1,19 +1,17 @@
< script >
// Phase E.2 — detail 페이지 inline 편집 .
// 기존 read-only 메타 패널(L138– 201)을 editors/* 스택으로 교체 .
// + E.3 관련 문서 stub, + 헤더 affordance row .
// 문서 상세 /documents/[id] — 확정 시안(d3-deepened) 스타일을 그대로 포팅, 데이터만 바인딩 .
// 데스크탑: 상단 헤더 띠 + [좌 절 트리(색바+연결선)][중 절 집중 뷰][우 슬림 레일]. 절 없으면 fallback .
// 모바일: 헤더 + 나란한 토글 pill(절구조|인사이트) + 본문 절 카드 연속(+탭 이동). 편집/필기/네비 보존 .
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 { ChevronRight , FileText } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
@@ -23,96 +21,57 @@
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
import 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 , collapseWindows } from '$lib/utils/headingPath ';
import { domainLabel } from '$lib/utils/domainSlug ';
marked.use({ mangle : false , headerIds : false } );
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
USE_PROFILES: { html : true } ,
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
return DOMPurify.sanitize(marked(text || '' ), {
USE_PROFILES: { html : true } , FORBID_TAGS: ['style', 'script'], FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
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 : [] } ;
}
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 : [] }; }
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;
}
async function saveNote(s) { try { await api ( `/documents/$ { docId } /note`, { method : 'PUT' , body : JSON.stringify ({ strokes_json : s }) }); } catch ( e ) { console . warn ( e ); } }
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 } ;
}
async function loadNeighbors() { try { neighbors = await api ( `/documents/$ { docId } /library-neighbors`); } catch { neighbors = { prev : null , next : null }; } }
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 } `) ;
}
// 절(hier section) 목차 — 본문 로드와 독립, 실패(404 포함) 무해.
// reqId guard: 문서 전환 race 시 stale 결과가 새 문서에 붙지 않게.
// 절 목차
let sections = $state([]);
let hasSections = $derived(sections.length > 0);
// 과대 절은 builder 가 window 조각(같은 제목·is_leaf)으로 분해하고 부모를 heading 만 남긴 split-parent 로
// 강등한다(예: 5180 = 27개 논리 절 → 562 window). raw sections 를 그대로 그리면 동일 제목 수백 행으로
// 파편화되므로, collapseWindows 로 논리 절 1개(대표=split-parent, bodyText=window 본문 합본)로 합친다.
let outline = $derived(collapseWindows(sections));
async function loadSections() {
const reqId = docId;
try {
const r = await api(`/documents/${ reqId } /sections`);
if (reqId === docId) sections = r?.sections ?? [];
} catch {
if (reqId === docId) sections = []; // Phase 1 미배포 시 404 → 목차 숨김(graceful)
}
}
// "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 { const r = await api ( `/documents/$ { reqId } /sections`); if (reqId === docId) sections = r?.sections ?? []; }
catch { if ( reqId === docId ) sections = []; }
}
onMount(async () => {
@@ -120,87 +79,26 @@
doc = await api(`/documents/${ docId } `);
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
try {
const resp = await fetch(`/api/documents/${ docId } /file?token=${ getAccessToken ()} `);
if (resp.ok) rawMarkdown = await resp.text();
} catch (e) {
rawMarkdown = '';
}
try { const resp = await fetch ( `/api/documents/$ { docId } /file?token=$ { getAccessToken ()} `); if (resp.ok) rawMarkdown = await resp.text(); } catch { rawMarkdown = '' ; }
}
} catch (err) {
error = err?.status === 404 ? 'not_found' : 'network';
} finally {
loading = false;
}
// 자료실 자료면 인접 자료 미리 fetch (학습 흐름 네비)
} catch (err) { error = err ? . status === 404 ? 'not_found' : 'network' ; }
finally { loading = false ; }
if (doc & & doc.category === 'library') loadNeighbors();
if (doc) loadSections();
});
let viewerType = $derived(
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
);
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
let canShowMarkdown = $derived(!!(isMdSuccess(doc?.md_status) & & doc?.md_content?.trim()));
// 절 본문은 청크 text(절별 원문)에서 오므로 md_content 성공/존재와 무관.
// hasSections 만으로 절뷰 사용 → partial / 대형 split(md_content 5만 자 절단) 문서도 절뷰 표시.
let useSectionView = $derived(hasSections);
// PDF 분기 전용: marker_worker 가 만든 canonical markdown 이 있으면 기본으로 그것을 보여줌.
// Phase 1B 산출물의 95% 가 PDF 라 1D pilot 평가가 실사용 화면 기반이 되도록 markdown-first.
// 사용자가 "PDF 원본" 토글하면 iframe. lastDocId 로 문서 전환만 감지해서 사용자 토글이
// reactive cycle 에 덮이지 않도록 보호.
let pdfViewMode = $state('markdown'); // 'markdown' | '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,353 +110,373 @@
return 'unsupported';
}
// E.2 affordance row 핸들러
// 절 집중/모바일 상태
let selectedSectionId = $state(null);
let mTree = $state(false);
let mIns = $state(false);
let manageOpen = $state(false);
$effect(() => { if ( outline . length && ! outline . some (( it ) => it . section . chunk_id === selectedSectionId )) selectedSectionId = outline [ 0 ]. section . chunk_id ; } );
let selectedItem = $derived(outline.find((it) => it.section.chunk_id === selectedSectionId) ?? outline[0] ?? null);
let selectedSection = $derived(selectedItem?.section ?? null);
let selIdx = $derived(outline.findIndex((it) => it.section.chunk_id === selectedItem?.section?.chunk_id));
// 절 본문 = 청크 원문(it.bodyText, window 조각 합본) 직접 렌더. 과거 char_start 로 md_content 를
// 슬라이스했으나, 대형 split 문서는 md_content 가 앞 5만 자만 보존되고 char_start 도 NULL 이라 본문이
// 비었다. 청크 text 는 절 전체를 담으므로(절 보유 문서 344개, 본문 합 평균 68KB·max 1.6MB) 그대로 렌더.
function bodyHtml(it) { return it ? . bodyText ? renderMd ( it . bodyText ) : '' ; }
let selectedBodyHtml = $derived(bodyHtml(selectedItem));
// 모바일 연속 카드: 본문은 '본문 보기' 펼칠 때만 파싱(논리 절 수백 개 × marked 즉시 파싱 회피).
let mBodyOpen = $state({} );
// 절 유형 색 (시안: 정의 청 / 절차 올리브 / 요건 황)
const TYPE_META = {
definition: { label : '정의' , en : 'definition' , color : '#2f7d8f' } ,
procedure: { label : '절차' , en : 'procedure' , color : '#7a8b3f' } ,
requirement: { label : '요건' , en : 'requirement' , color : '#b5840a' } ,
};
function typeMeta(t) { return TYPE_META [ t ] ?? { label : sectionTypeLabel ( t ) || '' , en : t || '' , color : '#9aa090' }; }
function isLowConf(c) { return c != null && c < 0.5 ; }
function isMidLow(c) { return c != null && c < 0.6 ; }
function confColor(c) { return c == null ? '#9aa090' : c < 0.6 ? '#b5840a' : '#1f9d6b' ; }
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 confPct(c) { return c == null ? 0 : Math.round ( c * 100 ); }
// 도메인 색 (시안 도메인 팔레트)
const DOMAIN_COLOR = { Industrial_Safety : '#b5840a' , Engineering : '#2f7d8f' , Programming : '#3d7256' , General : '#7a8b3f' , Reference : '#8a6a3f' , Philosophy : '#7a6a9b' } ;
function domainColor(d) { return DOMAIN_COLOR [( d || '' ). split ( '/' )[ 0 ]] ?? '#697061' ; }
function fmtColor(f) { return f === 'pdf' ? '#c0564a' : f === 'md' ? '#5a8f7a' : [ 'm4a' , 'mp3' , 'wav' ]. includes ( f ) ? '#8a6aa5' : f === 'html' ? '#c2911f' : '#697061' ; }
let quality = $derived(doc?.md_extraction_quality?.metrics ?? doc?.md_extraction_quality ?? null);
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( `${ window . location . origin } /documents/${ docId } `).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 ( jumpMode )}
< div class = "d3tree" style = "font-size:14px;" >
< div style = "display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;" >
< div style = "font-size:12px;font-weight:700;color:#697061;letter-spacing:.4px;" > 절 구조< / div >
< span style = "font-size:10.5px;color:#9aa090;font-variant-numeric:tabular-nums;" > { outline . length } 절</ span >
< / div >
< div style = "display:flex;flex-wrap:wrap;gap:6px 8px;margin-bottom:11px;padding-bottom:10px;border-bottom:1px solid #dde3d6;" >
< span style = "display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;" > < span style = "width:8px;height:8px;border-radius:2px;background:#2f7d8f;" > < / span > 정의< / span >
< span style = "display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;" > < span style = "width:8px;height:8px;border-radius:2px;background:#7a8b3f;" > < / span > 절차< / span >
< span style = "display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;" > < span style = "width:8px;height:8px;border-radius:2px;background:#b5840a;" > < / span > 요건< / span >
< / div >
{ #each outline as it ( it . section . chunk_id )}
{ @const s = it . section }
{ @const tm = typeMeta ( it . sectionType )}
{ @const active = ! jumpMode && s . chunk_id === selectedSection ? . chunk_id }
{ @const child = secDepth ( s ) > 0 }
{ @const low = isMidLow ( it . confidence )}
< svelte:element this = { jumpMode ? 'a' : 'div' } href= { jumpMode ? `#m-sec-$ { s . chunk_id } ` : undefined } role = "button" tabindex = "0"
onclick={() => ! jumpMode && ( selectedSectionId = s . chunk_id )}
onkeydown={( e ) => { if ( ! jumpMode && ( e . key === 'Enter' || e . key === ' ' )) { e . preventDefault (); selectedSectionId = s . chunk_id ; } }}
class="d3node { child ? 'd3child' : '' } { active ? 'd3active' : '' } "
style="display:block;border:1px solid { active ? '#4f8a6b' : low ? '#e7d49a' : 'transparent' } ;border-radius:9px;padding:{ child ? '6px 8px' : '7px 8px' } ;margin-bottom:2px;{ low ? 'background:#fbf6e6;' : '' } text-decoration:none;cursor:pointer;">
< div style = "display:flex;align-items:center;gap:7px;" >
< span style = "width:3px;height: { child ? '13px' : '16px' } ;border-radius:2px;background: { tm . color } ;flex-shrink:0;" ></ span >
< span class = "d3title" style = "font-size: { child ? '11.5px' : '12.5px' } ;flex:1;min-width:0; { child ? 'color:#697061;' : '' }{ active ? 'color:#3d7256;font-weight:600;' : '' } overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" > { secTitle ( s )} </ span >
{ #if low }
< span class = "d3warn" title = "저신뢰 절" style = "display:inline-flex;width:14px;height:14px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;" > !< / span >
{ :else if ! child }
< span title = "신뢰도 { it . confidence != null ? it . confidence . toFixed ( 2 ) : '—' } " style = "width:7px;height:7px;border-radius:50%;background: { confColor ( it . confidence )} ;flex-shrink:0;" ></ span >
{ /if }
< / div >
< / svelte:element >
{ /each }
{ #if quality }
< div style = "margin-top:12px;padding-top:10px;border-top:1px solid #dde3d6;" >
< div style = "font-size:10.5px;font-weight:700;color:#697061;margin-bottom:7px;letter-spacing:.3px;" > 추출 품질< / div >
< div style = "display:grid;grid-template-columns:1fr 1fr;gap:5px;font-size:10.5px;color:#697061;font-variant-numeric:tabular-nums;" >
{ #if quality . headings != null } < span > headings < b style = "color:#23291f;" > { quality . headings } </ b ></ span > { /if }
{ #if quality . tables != null } < span > tables < b style = "color:#23291f;" > { quality . tables } </ b ></ span > { /if }
{ #if quality . images != null } < span > images < b style = "color:#23291f;" > { quality . images } </ b ></ span > { /if }
< / div >
< / div >
{ /if }
< / div >
{ /snippet }
<!-- ════ 절 집중 뷰 (데스크탑 중앙) ════ -->
{ # snippet focusView ()}
{ #if selectedSection }
{ @const tm = typeMeta ( selectedItem ? . sectionType )}
{ @const conf = selectedItem ? . confidence ?? null }
{ @const summaries = selectedItem ? . summaries ?? []}
< div style = "display:flex;align-items:center;gap:6px;font-size:12px;color:#9aa090;margin-bottom:12px;flex-wrap:wrap;" >
< span class = "truncate" style = "max-width:200px;" > { doc . title } </ span >
{ #each pathSegments ( selectedSection . heading_path ) as seg } < span style = "color:#c8d6c0;" > /</ span >< span style = "color:#697061;font-weight:600;" > { seg } </ span > { /each }
< / div >
< div style = "display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:13px;" >
< h2 style = "margin:0;font-size:22px;font-weight:700;color:#23291f;line-height:1.3;flex:1;min-width:180px;" > { secTitle ( selectedSection )} </ h2 >
{ #if tm . label } < span style = "display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background: { tm . color } 1a;border:1px solid { tm . color } 55;font-size:12px;color: { tm . color } ;font-weight:600;" >< span style = "width:8px;height:8px;border-radius:2px;background: { tm . color } ;" ></ span > { tm . label } { tm . en } </ span > { /if }
< / div >
{ #if conf != null }
< div style = "display:flex;align-items:center;gap:9px;margin-bottom:18px;" >
< span style = "font-size:11px;color:#697061;font-weight:600;flex-shrink:0;" > 신뢰도< / span >
< div style = "flex:1;max-width:300px;height:7px;border-radius:999px;background:#e3ebdf;overflow:hidden;" >< div style = "width: { confPct ( conf )} %;height:100%;background: { confColor ( conf )} ;border-radius:999px;" ></ div ></ div >
< span style = "font-size:13px;font-weight:700;color: { confColor ( conf )} ;font-variant-numeric:tabular-nums;flex-shrink:0;" > { conf . toFixed ( 2 )} </ span >
< / div >
{ /if }
{ #if isLowConf ( conf )}
< div style = "display:flex;align-items:flex-start;gap:8px;background:#faf3e2;border:1px solid #ecdca3;border-radius:10px;padding:10px 12px;margin-bottom:16px;font-size:12.5px;color:#8a6306;" > < span style = "flex-shrink:0;width:16px;height:16px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;" > !< / span > < span > 저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.< / span > < / div >
{ /if }
{ #if summaries . length }
< div style = "background:#ecf0e8;border-left:3px solid #4f8a6b;border-radius:0 10px 10px 0;padding:14px 16px;margin-bottom:20px;" >
< div style = "font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.6px;margin-bottom:6px;" > 절 요약{ #if summaries . length > 1 } · { summaries . length } 개 부분{ /if } </ div >
{ #if summaries . length === 1 }
< div style = "font-size:15.5px;line-height:1.6;color:#23291f;white-space:pre-line;" > { summaries [ 0 ]} </ div >
{ : else }
< ul style = "margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:8px;" >
{ #each summaries as sm , i } < li style = "font-size:13.5px;line-height:1.55;color:#23291f;display:flex;gap:8px;" >< span style = "flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;" > { i + 1 } </ span >< span style = "white-space:pre-line;" > { sm } </ span ></ li > { /each }
< / ul >
{ /if }
< / div >
{ /if }
{ #if selectedBodyHtml }
< div class = "prose prose-base max-w-none text-text" > { @html selectedBodyHtml } </ div >
{ : else }
< p style = "color:#9aa090;font-size:14px;font-style:italic;" > 이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.< / p >
{ /if }
< div style = "display:flex;justify-content:space-between;gap:10px;margin-top:20px;padding-top:14px;border-top:1px solid #dde3d6;" >
{ #if selIdx > 0 }
{ @const pv = outline [ selIdx - 1 ]. section }
< button type = "button" onclick = {() => ( selectedSectionId = pv . chunk_id )} style="font-size:12px;color:#697061;border:1px solid # dde3d6 ; border-radius:9px ; padding:8px 12px ; background: # fff ; cursor:pointer ;" > ← { secTitle ( pv )} </ button >
{ : else } < span ></ span > { /if }
{ #if selIdx >= 0 && selIdx < outline . length - 1 }
{ @const nxIt = outline [ selIdx + 1 ]}
{ @const nx = nxIt . section }
< button type = "button" onclick = {() => ( selectedSectionId = nx . chunk_id )} style="font-size:12px;color: { isMidLow ( nxIt . confidence ) ? '#8a6306' : '#697061' } ;border:1px solid { isMidLow ( nxIt . confidence ) ? '#e7d49a' : '#dde3d6' } ; border-radius:9px ; padding:8px 12px ; background: # fff ; cursor:pointer ; display:inline-flex ; align-items:center ; gap:6px ;" > { #if isMidLow ( nxIt . confidence )} < span style = "display:inline-flex;width:13px;height:13px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:8px;font-weight:700;" > !</ span > { /if }{ secTitle ( nx )} →</ button >
{ : else } < span ></ span > { /if }
< / div >
{ /if }
{ /snippet }
<!-- ════ 우 슬림 레일 (시안 카드 스타일) ════ -->
{ # snippet rail ()}
< div style = "display:flex;flex-direction:column;gap:11px;font-size:14px;" >
{ #if doc . ai_tldr || doc . ai_summary }
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;" >
< div style = "font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:7px;" > TL;DR< / div >
< div style = "font-size:12px;line-height:1.5;color:#23291f;" > { doc . ai_tldr || doc . ai_summary } </ div >
< / div >
{ /if }
{ #if doc . ai_bullets && doc . ai_bullets . length }
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;" >
< div style = "font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;" > 핵심점< / div >
< ul style = "margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:7px;" >
{ #each doc . ai_bullets as b } < li style = "font-size:12px;line-height:1.4;display:flex;gap:6px;" >< span style = "color:#b5840a;font-weight:700;flex-shrink:0;" > ·</ span >< span style = "flex:1;min-width:0;color:#23291f;" > { b } </ span ></ li > { /each }
< / ul >
< / div >
{ /if }
{ #if doc . ai_detail_summary }
< div style = "background:#f4f7f1;border:1px solid #c8d6c0;border-radius:14px;padding:13px;" >
< div style = "display:flex;align-items:center;gap:6px;margin-bottom:7px;" >
< span style = "font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.4px;" > 심층< / span >
{ #if doc . ai_analysis_tier === 'deep' } < span style = "font-size:9px;color:#fff;background:#4f8a6b;border-radius:999px;padding:1px 7px;font-weight:600;" > DEEP</ span > { /if }
< / div >
< div style = "font-size:11.5px;line-height:1.5;color:#23291f;white-space:pre-line;" > { doc . ai_detail_summary } </ div >
< / div >
{ /if }
{ #if doc . ai_inconsistencies && doc . ai_inconsistencies . length }
< div style = "background:#fbf6e6;border:1px solid #e7d49a;border-radius:14px;padding:13px;" >
< div style = "font-size:10.5px;font-weight:700;color:#8a6306;letter-spacing:.4px;margin-bottom:7px;" > 불일치 { doc . ai_inconsistencies . length } </ div >
< ul style = "margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:5px;" > { #each doc . ai_inconsistencies as inc } < li style = "font-size:11.5px;line-height:1.45;color:#23291f;" > · { typeof inc === 'string' ? inc : inc.desc || inc . kind } </ li > { /each } </ ul >
< / div >
{ /if }
{ #if doc . ai_domain }
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;" >
< div style = "font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;" > 분류< / div >
< div style = "display:flex;flex-direction:column;gap:6px;font-size:11.5px;" >
< div style = "display:flex;justify-content:space-between;gap:8px;" >< span style = "color:#697061;" > 도메인</ span >< span style = "display:inline-flex;align-items:center;gap:5px;color:#23291f;font-weight:600;text-align:right;" >< span style = "width:7px;height:7px;border-radius:50%;background: { domainColor ( doc . ai_domain )} ;" ></ span > { domainLabel ( doc . ai_domain )} </ span ></ div >
{ #if doc . ai_sub_group } < div style = "display:flex;justify-content:space-between;gap:8px;" >< span style = "color:#697061;" > 하위</ span >< span style = "color:#23291f;font-weight:600;" > { doc . ai_sub_group } </ span ></ div > { /if }
{ #if doc . ai_analysis_tier } < div style = "display:flex;justify-content:space-between;gap:8px;" >< span style = "color:#697061;" > tier</ span >< span style = "color:#3d7256;font-weight:600;" > { doc . ai_analysis_tier } </ span ></ div > { /if }
{ #if doc . ai_confidence != null } < div style = "display:flex;justify-content:space-between;gap:8px;" >< span style = "color:#697061;" > 신뢰도</ span >< span style = "color:#1f9d6b;font-weight:700;font-variant-numeric:tabular-nums;" > { doc . ai_confidence . toFixed ( 2 )} </ span ></ div > { /if }
< / div >
< / div >
{ /if }
{ #if doc . ai_tags && doc . ai_tags . length }
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;" >
< div style = "font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;" > 태그< / div >
< div style = "display:flex;flex-wrap:wrap;gap:5px;" > { #each doc . ai_tags as t } < span style = "font-size:11px;padding:3px 8px;border-radius:999px;background:#fff;border:1px solid #dde3d6;color:#697061;" > { t } </ span > { /each } </ div >
< / div >
{ /if }
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;" >
< div style = "font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:6px;" > 관련 문서< / div >
< div style = "font-size:11px;color:#9aa090;line-height:1.5;" > 벡터 유사도 기반 — 준비 중< / div >
< / div >
< / div >
{ /snippet }
<!-- ════ 절 카드 (모바일 연속 본문) ════ -->
{ # snippet sectionCard ( it )}
{ @const s = it . section }
{ @const tm = typeMeta ( it . sectionType )}
< div id = "m-sec- { s . chunk_id } " style = "scroll-margin-top:12px;background:#f4f7f1;border:1px solid { isLowConf ( it . confidence ) ? '#e7d49a' : '#dde3d6' } ;border-radius:14px;padding:14px 15px;" >
< div style = "display:flex;align-items:center;gap:7px;margin-bottom:7px;" >
< h2 style = "margin:0;font-size:16px;font-weight:700;color:#23291f;flex:1;min-width:0;line-height:1.3;" > { secTitle ( s )} </ h2 >
{ #if tm . label } < span style = "flex-shrink:0;font-size:10.5px;font-weight:650;padding:2px 8px;border-radius:999px;background: { tm . color } 1a;color: { tm . color } ;white-space:nowrap;" > { tm . label } </ span > { /if }
< / div >
{ #if isLowConf ( it . confidence )}
< div style = "display:flex;align-items:flex-start;gap:7px;background:#faf3e2;border:1px solid #ecdca3;border-radius:9px;padding:8px 10px;margin-bottom:10px;font-size:12px;color:#8a6306;" > < span style = "flex-shrink:0;width:15px;height:15px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;" > !< / span > < span > 저신뢰 — 표·수식 추출 불완전, 원본 확인 권장< / span > < / div >
{ /if }
{ #if it . summaries . length }
< div style = "border-left:3px solid #4f8a6b;background:#ecf0e8;border-radius:0 8px 8px 0;padding:9px 12px;margin-bottom:12px;" >
< div style = "font-size:9.5px;font-weight:700;color:#3d7256;letter-spacing:.5px;margin-bottom:3px;" > 절 요약{ #if it . summaries . length > 1 } · { it . summaries . length } 개 부분{ /if } </ div >
{ #if it . summaries . length === 1 }
< div style = "font-size:13.5px;line-height:1.55;color:#23291f;white-space:pre-line;" > { it . summaries [ 0 ]} </ div >
{ : else }
< ul style = "margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;" > { #each it . summaries as sm , i } < li style = "font-size:12.5px;line-height:1.5;color:#23291f;display:flex;gap:6px;" >< span style = "flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;" > { i + 1 } </ span >< span style = "white-space:pre-line;" > { sm } </ span ></ li > { /each } </ ul >
{ /if }
< / div >
{ /if }
{ #if it . bodyText }
< details class = "m-secbody" ontoggle = {( e ) => { if ( e . currentTarget . open ) mBodyOpen [ s . chunk_id ] = true ; }} >
< summary style = "cursor:pointer;list-style:none;font-size:12px;color:#697061;padding:5px 0;user-select:none;display:flex;align-items:center;gap:5px;" > 본문 보기 < span class = "m-chev" style = "transition:transform .16s;color:#9aa090;" > › < / span > < / summary >
{ #if mBodyOpen [ s . chunk_id ]} < div class = "prose prose-sm max-w-none text-text" style = "margin-top:6px;" > { @html bodyHtml ( it )} </ div > { /if }
< / details >
{ /if }
< / div >
{ /snippet }
< div style = "background:#e7ebe4;min-height:100%;" class = "p-4 lg:p-6" >
< div style = "max-width:1360px;margin:0 auto;" >
<!-- breadcrumb -->
< div class = "flex items-center gap-2 text-sm mb-4 text-dim" >
< a href = "/documents" class = "hover:text-text" > 문서< / a >
< span class = "text-faint" > /< / span >
< div class = "flex items-center gap-2 text-sm mb-3 text-dim" >
< a href = "/documents" class = "hover:text-text" > 문서< / a > < span class = "text-faint" > / < / span >
< span class = "truncate max-w-md text-text" > { doc ? . title || '로딩...' } </ span >
< / 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="삭제되었거나 접근 권한이 없을 수 있습니다."
>
< Button variant = "ghost" size = "sm" href = "/documents" > 목록으로 돌아가기< / Button >
< / EmptyState >
< EmptyState icon = { FileText } title="문서를 찾을 수 없습니다 " description = "삭제되었거나 접근 권한이 없을 수 있습니다." >< Button variant = "ghost" size = "sm" href = "/documents" > 목록으로 </ Button ></ EmptyState >
{ :else if error === 'network' }
< EmptyState
icon={ FileText }
title="문서를 불러올 수 없습니다"
description="네트워크 오류가 발생했습니다."
>
< Button variant = "secondary" size = "sm" onclick = {() => location . reload ()} > 다시 시도 </ Button >
< / EmptyState >
< 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 >
{ #if doc . category === 'library' }
< Button
variant={ noteOpen ? 'primary' : 'secondary' }
size="sm"
icon={ noteOpen ? X : PenLine }
onclick={ toggleNote }
>
{ noteOpen ? '필기 닫기' : '필기' }
< / Button >
{ /if }
<!-- ════ 상단 띠: 문서 헤더 (시안) ════ -->
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-bottom:14px;" >
< div style = "display:flex;align-items:flex-start;gap:13px;flex-wrap:wrap;" >
< div style = "width:40px;height:40px;border-radius:10px;background: { fmtColor ( doc . file_format )} ;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10.5px;letter-spacing:.5px;flex-shrink:0;text-transform:uppercase;" > { doc . file_format } </ div >
< div style = "flex:1;min-width:0;" >
< div style = "font-size:17px;font-weight:700;line-height:1.35;color:#23291f;" > { doc . title } </ div >
< div style = "display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center;" >
{ #if doc . ai_domain } < span style = "display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#23291f;" >< span style = "width:7px;height:7px;border-radius:50%;background: { domainColor ( doc . ai_domain )} ;" ></ span > { domainLabel ( doc . ai_domain )} </ span > { /if }
{ #if doc . ai_sub_group } < span style = "padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#697061;" > { doc . ai_sub_group } </ span > { /if }
{ #if doc . ai_analysis_tier === 'deep' } < span style = "padding:3px 9px;border-radius:999px;background:#4f8a6b;color:#fff;font-size:11.5px;font-weight:600;letter-spacing:.3px;" > tier DEEP</ span > { /if }
{ #if doc . ai_confidence != null } < span style = "padding:3px 9px;border-radius:999px;background:#e3ebdf;border:1px solid #c8d6c0;font-size:11.5px;color:#3d7256;font-variant-numeric:tabular-nums;" > 신뢰도 { doc . ai_confidence . toFixed ( 2 )} </ span > { /if }
{ #if canShowMarkdown } < span style = "padding:3px 9px;border-radius:999px;background:#eafaf2;border:1px solid #b8e3cc;font-size:11.5px;color:#1f9d6b;" > PDF→MD success</ span > { /if }
< / div >
< / div >
< div style = "display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;" >
{ #if doc . edit_url } < button type = "button" onclick = {() => window . open ( doc . edit_url , '_blank' )} style="font-size:11.5px;color:#697061;border:1px solid # dde3d6 ; border-radius:8px ; padding:5px 9px ; background: # fff ; cursor:pointer ;" > Synology</ button > { /if }
< button type = "button" onclick = { downloadOriginal } style="font-size:11.5px;color:#697061;border:1px solid # dde3d6 ; border-radius:8px ; padding:5px 9px ; background: # fff ; cursor:pointer ;" > 원본</ button >
< button type = "button" onclick = { copyLink } style="font-size:11.5px;color:#697061;border:1px solid # dde3d6 ; border-radius:8px ; padding:5px 9px ; background: # fff ; cursor:pointer ;" > 링크</ button >
{ #if doc . category === 'library' } < button type = "button" onclick = { toggleNote } style="font-size:11.5px;color: { noteOpen ? '#fff' : '#697061' } ;border:1px solid { noteOpen ? '#4f8a6b' : '#dde3d6' } ; border-radius:8px ; padding:5px 9px ; background: { noteOpen ? '#4f8a6b' : '#fff' } ; cursor:pointer ;" > { noteOpen ? '필기 닫기' : '필기' } </ button > { /if }
< button type = "button" onclick = {() => ( manageOpen = ! manageOpen )} style="font-size:11.5px;color:#697061;border:1px solid # dde3d6 ; border-radius:8px ; padding:5px 9px ; background: # fff ; cursor:pointer ;" > 관리</ button >
< / div >
< / div >
< / div >
<!-- 뷰어 — 모바일 가독성: 본문 폰트 키우고 line - height 늘림 -->
< Card class = "min-h-[500px]" >
{ #if useSectionView }
<!-- 데스크탑(xl+): 3영역 -->
< div class = "hidden xl:grid" style = "grid-template-columns:252px minmax(0,1fr) 336px;gap:13px;align-items:start;" >
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh-2rem);overflow-y:auto;" > { @render treeNav ( false )} </ div >
< div style = "min-width:0;" >< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:20px 22px;" > { @render focusView ()} </ div ></ div >
< div style = "position:sticky;top:14px;" > { @render rail ()} </ div >
< / div >
<!-- 모바일(<xl): 나란한 토글 pill + 패널 + 본문 연속 -->
< div class = "xl:hidden" >
< div style = "display:flex;gap:8px;margin-bottom:10px;position:sticky;top:0;z-index:5;background:#e7ebe4;padding:6px 0;" >
< button type = "button" onclick = {() => ( mTree = ! mTree )} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px ; font-size:12 . 5px ; font-weight:600 ; cursor:pointer ; background: { mTree ? '#e3ebdf' : '#f4f7f1' } ; border:1px solid { mTree ? '#4f8a6b' : '#dde3d6' } ; color: { mTree ? '#23291f' : '#697061' } ;" > 절 구조 < span style = "font-size:10px;color:#9aa090;font-weight:500;" > { outline . length } 절</ span >< span style = "transition:transform .16s;transform:rotate( { mTree ? 90 : 0 } deg);color:#9aa090;font-weight:700;" > › </ span ></ button >
< button type = "button" onclick = {() => ( mIns = ! mIns )} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px ; font-size:12 . 5px ; font-weight:600 ; cursor:pointer ; background: { mIns ? '#e3ebdf' : '#f4f7f1' } ; border:1px solid { mIns ? '#4f8a6b' : '#dde3d6' } ; color: { mIns ? '#23291f' : '#697061' } ;" > 인사이트< span style = "transition:transform .16s;transform:rotate( { mIns ? 90 : 0 } deg);color:#9aa090;font-weight:700;" > › </ span ></ button >
< / div >
{ #if mTree } < div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:6px;margin-bottom:10px;" > { @render treeNav ( true )} </ div > { /if }
{ #if mIns } < div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:13px 14px;margin-bottom:10px;" > { @render rail ()} </ div > { /if }
< div style = "display:flex;flex-direction:column;gap:10px;" > { #each outline as it ( it . section . chunk_id )}{ @render sectionCard ( it )}{ /each } </ div >
< / div >
{ : else }
<!-- 절 없음 fallback: 절이 없어도 인사이트는 항상 보이게 (모바일=인사이트 상단 / 데스크탑=우측 레일) -->
{ # snippet fbViewer ()}
< div style = "min-width:0;background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:18px 20px;min-height:360px;" >
{ #if ! hasSections && canShowMarkdown } < p style = "font-size:11px;color:#9aa090;margin-bottom:12px;" > 이 문서는 절 분석이 없어 전체 본문으로 표시합니다. 위/옆 인사이트는 그대로 제공됩니다.</ 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 }
/>
{ #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 >
{ /if }
< 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 > { /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"
/>
{ : else }
< iframe
src="/api/documents/{ doc . id } /file?token={ getAccessToken ()} "
class="w-full h-[80vh] rounded"
title={ doc . title }
>< / iframe >
{ /if }
< 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 > { /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 >
< EmptyState icon = { FileText } 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 >
{ : else }
< EmptyState
icon={ FileText }
title="인앱 미리보기 미지원"
description="포맷: { doc . file_format } "
/>
{ /if }
< / Card >
{ #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" href = { doc . edit_url } target="_blank" > 원문 보기 </ Button ></ div > { /if }
{ : else } < EmptyState icon = { FileText } title="인앱 미리보기 미지원 " description = "포맷: { doc . file_format } " /> { /if }
< / div >
{ /snippet }
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
{ #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 class = "hidden xl:grid xl:grid-cols-[minmax(0,1fr)_336px] gap-3.5 items-start" >
{ @render fbViewer ()}
< div style = "position:sticky;top:14px;" > { @render rail ()} </ div >
< / div >
<!-- 모바일: 인사이트(상단 상시) + 본문 -->
< div class = "xl:hidden" >
< div style = "margin-bottom:12px;" > { @render rail ()} </ div >
{ @render fbViewer ()}
< / 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 manageOpen }
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-top:14px; " >
< div style = "font-size:12px;font-weight:700;color:#697061;margin-bottom:12px;letter-spacing:.3px;" > 관리 · 분류 편집< / div >
< div class = "grid grid-cols-1 md:grid-cols-2 gap-4" >
< AIClassificationEditor { doc } />
< LibraryPathEditor { doc } />
< NoteEditor { doc } />
< EditUrlEditor { doc } />
< TagsEditor { doc } />
< / div >
< div class = "pt-3 mt-3 border-t border-default" >< DocumentDangerZone { doc } ondelete = { handleDocDelete } / ></ 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 >
{ #if noteOpen && doc . category === 'library' && noteLoaded }
< div style = "background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px; overflow: hidden;margin-top:14px;" >< div class = "h-[60vh] min-h-[400px] flex flex-col" >< HandwriteCanvas sessionId = { doc . id } initialStrokes= { noteStrokes } onChange = {( s ) => saveNote ( s )} / ></ div ></ div >
{ /if }
<!-- 문서 정보 — 접이(기본 닫힘) -->
< 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 >
< 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 . prev && goto ( `/documents/$ { neighbors . prev . id } `) } disabled= { ! neighbors . prev } class = "px-3 py-2 rounded text-dim disabled:opacity-30" aria-label = "이전" > ‹ </ button >
< button type = "button" onclick = { readAndGoNext } disabled= { ! neighbors . next } class = "flex-1 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50" > { #if neighbors . next } 1회독 완료 + 다음{ : else } 1회독 완료 (마지막){ /if } </ button >
< button type = "button" onclick = {() => neighbors . next && goto ( `/documents/$ { neighbors . next . id } `) } disabled= { ! neighbors . next } class = "px-3 py-2 rounded text-dim disabled:opacity-30" aria-label = "다음" > › </ button >
< / div >
<!-- 본문이 sticky 바 뒤에 가리지 않도록 패딩 -->
< div class = "lg:hidden h-20" > < / div >
{ /if }
{ /if }
< / div >
< / div >
< style >
.d3node:hover { background : # ecf0e8 ; }
.d3active:hover { background : # e3ebdf ; }
.d3child { position : relative ; }
.d3child::before { content : "" ; position : absolute ; left : 2px ; top : - 3 px ; bottom : 50 % ; width : 1px ; background : # cdd6c4 ; }
.d3child::after { content : "" ; position : absolute ; left : 2px ; top : 50 % ; width : 7px ; height : 1px ; background : # cdd6c4 ; }
.m-secbody[open] .m-chev { transform : rotate ( 90 deg ); }
.d3warn { animation : d3pulse 2.4 s ease - in - out infinite ; }
@keyframes d3pulse { 0 % , 100 % { box - shadow : 0 0 0 0 rgba ( 181 , 132 , 10 , . 35 ); } 50 % { box - shadow : 0 0 0 3 px rgba ( 181 , 132 , 10 , 0 ); } }
< / style >