Compare commits

..

9 Commits

Author SHA1 Message Date
hyungi c5bc1f773d fix(docpage): 비인접 window 를 parent_id 로 split-parent 에 흡수 (빈 본문 절 수정)
split-parent(절 헤딩)와 그 window 조각이 chunk_index 상 비인접인 경우(예: 5180 FOREWORD
헤딩 idx 1143, window idx 1233~)가 있어, 인접 흡수만 하던 collapseWindows 가 split-parent 를
빈 본문 행으로 남기고 window 들은 따로 대표 행을 만들어 "같은 제목 2행(빈 것 + 본문 있는 것)" 이
됐다. 사용자가 "본문 없는 절" 로 본 것.

- /sections API 에 parent_id 반환 (window.parent_id = 그 split-parent chunk_id, 100% _split 링크)
- collapseWindows 가 window 를 parent_id 로 split-parent 대표에 흡수(비인접 허용), 인접 heading
  fallback 유지(legacy window). 흡수 멤버에서 본문/분석 집계.
- 회귀 테스트: 비인접 parent_id 흡수 (12/12 pass)

실데이터 검증(빈 본문→0): 5180 outline 85→58·5210 318→277·5178 73→49·5151 45→40, 전부 EMPTY_BODY=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 07:46:18 +09:00
hyungi d007ad5492 fix(docpage): windowed 절에 조각별 분석(유형/신뢰도/요약) 집계 노출
절-레벨 분석(chunk_section_analysis)은 코퍼스 전역에 이미 있으나(절 보유 344문서 중 336)
window 조각의 chunk_id 에 붙어 있고, D3 는 window 를 split-parent 대표로 collapse 하며 버려서
windowed 절은 요약/유형/신뢰도가 안 떴다(분석은 대표가 아닌 조각에 있음).

- collapseWindows 가 멤버(대표+흡수 window)에서 절-레벨 분석 집계:
  sectionType=다수결(동률 첫등장) · confidence=평균 · summaries=조각 요약 배열(빈 것 제외)
- D3 트리/focus/모바일카드/이전다음이 it.sectionType/it.confidence/it.summaries 사용
- 요약은 단일 절=문단, windowed 절="절 요약 · N개 부분" 번호목록
- headingPath.test.ts: 집계 회귀 테스트 추가 (11/11 pass)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 07:27:41 +09:00
hyungi b6a4821cac fix(docpage): 절 본문을 청크 text로 렌더 + window 조각 collapse
대형 split 문서는 marker 가 md_content 를 앞 5만 자만 보존하고 char_start 도 NULL 이라
char_start 슬라이스로는 절 본문이 비었다. 전체 본문은 document_chunks.text 에 절별로 보존됨.

- /sections API 가 청크 text 반환 (SectionItem.text; 소비자=D3 단독, additive)
- collapseWindows 가 window 조각 본문을 대표 절 bodyText 로 합본 (split-parent heading 제외)
- D3 페이지가 outline(collapseWindows) 단위로 렌더 → window 파편화 제거
  (5180 = 27 논리 절이 562 동일제목 조각으로 쪼개지던 문제)
- useSectionView=hasSections 로 단순화(partial/대형 문서도 절뷰), 모바일 본문 lazy 파싱
- headingPath.test.ts: bodyText 누적 회귀 테스트 추가 (10/10 pass)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 07:10:59 +09:00
hyungi b461559d2f fix(docpage): 절 없는 문서도 인사이트 항상 표시 (fallback 개선)
사용자 "절이 없더라도 인사이트는 보여야지" — fallback(절 데이터 없는 ~92% 문서)이
모바일에서 인사이트 레일을 긴 본문 아래에 묻던 문제 수정. bodyViewer 스니펫 분리 후:
- 모바일: 인사이트 레일을 본문 위에 상시 표시
- 데스크탑: 본문 | 인사이트 레일(sticky)
(별개: 절 트리/집중 뷰는 절 분석 있는 문서에서만 활성 — 현재 4358중 333. 커버리지 확대는 후속.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:39:44 +09:00
hyungi 9b9790f05d fix(docpage): D3 시안 스타일 그대로 포팅 + 모바일 길이/접근성 수정
사용자 "시안대로 안했다" → 앱 토큰 재해석을 폐기하고 d3-deepened 시안의 inline
스타일을 그대로 포팅(데이터만 바인딩): 트리 좌측 색바(3×16)+연결선(ㄴ자)+활성+
저신뢰 맥동배지, 절차색 #7a8b3f, 헤더 PDF아이콘+pill칩+분류/원본/링크/관리, 절 집중
뷰(요건 requirement 배지·신뢰도 바·절요약 인용박스), 슬림 레일 카드(시안 동일).
모바일: 절구조/인사이트 안보임+무한길이("쭉 아래까지") → pill sticky + 절 본문
카드마다 접기('본문 보기', 기본 요약만)로 컴팩트화. svelte-check 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:29:04 +09:00
hyungi b49596135e fix(docpage): 모바일을 확정 시안 그대로 — 나란한 토글 pill + 패널 + 본문 연속
직전 모바일이 세로 details 2개라 시안(나란한 pill 토글)과 불일치
(사용자 "시안에 모바일용도 있잖아 그걸 안 만들었다") → d3-deepened 모바일 프래그먼트
충실 복제: 절 구조|인사이트 나란한 pill(기본 둘 다 접힘) + 절 구조 패널(유형 범례·
점프 링크·저신뢰·들여쓰기) + 인사이트 패널(TL;DR·핵심점·심층DEEP·불일치·분류·태그) +
본문 절 카드 연속(#m-sec 앵커, pill 탭→본문 이동). svelte-check 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:12:59 +09:00
hyungi 0a82a5b1bc feat(docpage): 모바일을 시안대로 — 본문 연속 절 카드 + 접이 + 탭 이동
기존 모바일(데스크탑 focus 단일절)이 시안 모바일과 불일치(사용자 "모바일은 변한게
없잖아") → 시안 모바일 충실 구현:
- 모바일(<xl) = 절 구조/인사이트 접이(기본 절구조 닫힘·인사이트 열림) + 본문이 절
  카드로 연속(각 절 제목·유형배지·절요약·본문) + 절 구조 탭하면 #m-sec 본문 앵커 이동
- 데스크탑(xl+) = 트리 | 절 집중 | 레일 (focusView 스니펫으로 분리)
- treeNav(jumpMode): 데스크탑=절 선택 / 모바일=앵커 점프
svelte-check 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:03:59 +09:00
hyungi 74e29e510e 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>
2026-06-13 15:53:34 +09:00
hyungi c1555fd6ab feat(docpage): 전체 문서 목록 클릭 시 인라인 미리보기 대신 D3 상세로 이동
사용자 결정 "개선된 페이지가 앞으로 표시되야지" — /documents 브라우저에서
문서를 열면 인라인 DocumentViewer(구) 대신 개선된 /documents/[id](D3 절 구조
탐색기)로 이동. /documents = 브라우즈/검색/필터/일괄 목록(풀폭 중앙) 역할로 정리:
- selectDoc → goto(/documents/[id]) (행 클릭·키보드 enter 공통)
- 인라인 리더(DocumentViewer)·인스펙터 패널 제거, 목록 max-w-5xl 중앙
- AI 답변 카드(질문형 검색)는 목록 상단 고정으로 이동(보존)
- 검색·필터칩·일괄작업·업로드·페이지네이션 전부 유지

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:41:59 +09:00
5 changed files with 555 additions and 525 deletions
+8 -3
View File
@@ -680,7 +680,12 @@ class SectionItem(BaseModel):
level: int | None = None
node_type: str | None = None # window | chapter_split | clause_split | section_split | null
is_leaf: bool
parent_id: int | None = None # 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent.
# 프런트 collapseWindows 가 비인접 window 를 split-parent 에 흡수할 때 사용.
char_start: int | None = None # md_content 내 heading offset(UTF-16). jump-target 만 값, 그 외 None (Path B)
text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존
# (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라
# md_content 슬라이스로는 본문이 비므로, 청크 text 를 직접 렌더한다.
section_type: str | None = None
summary: str | None = None # status='summarized' 인 분석행에만, 그 외 None
confidence: float | None = None
@@ -719,12 +724,12 @@ async def get_document_sections(
await session.execute(
sql_text(
"""
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start,
section_type, summary, confidence
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, parent_id, char_start,
text, section_type, summary, confidence
FROM (
SELECT DISTINCT ON (c.id)
c.id AS chunk_id, c.chunk_index, c.section_title, c.heading_path,
c.level, c.node_type, c.is_leaf, c.char_start,
c.level, c.node_type, c.is_leaf, c.parent_id, c.char_start, c.text,
a.section_type,
CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary,
a.confidence
@@ -83,6 +83,74 @@ test('[C2] collapseWindows: split-parent + window 들 → rail 1행, 대표=spli
assert.equal(out[0].fragmentCount, 2, 'window 조각 수 = 2 (split-parent 자신 제외)');
});
test('collapseWindows: bodyText — 정상 leaf 는 자기 본문, split-parent 는 window 본문만 이어붙임', () => {
// 정상 leaf → 자기 text 가 본문
const leaf = collapseWindows([sec({ heading_path: 'Intro', node_type: null, text: '서론 본문' })]);
assert.equal(leaf[0].bodyText, '서론 본문');
// split-parent(heading 줄뿐) + window 2개 → window 본문만 순서대로 합침(헤딩 제외)
const split = collapseWindows([
sec({ heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120, text: '# Article 5' }),
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각1' }),
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각2' }),
]);
assert.equal(split.length, 1);
assert.equal(split[0].bodyText, '본문 조각1\n\n본문 조각2', 'split-parent heading 제외, window 본문만 합침');
// legacy window 런(선행 split-parent 없음) → 첫 window 자기 본문 + 흡수 조각
const legacy = collapseWindows([
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p1' }),
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p2' }),
]);
assert.equal(legacy.length, 1);
assert.equal(legacy[0].bodyText, 'p1\n\np2');
});
test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤버에서 type 다수결/conf 평균/summaries 합본', () => {
// split-parent(분석 없음) + window 3개(요약·유형·신뢰도 보유) → 대표에 집계
const out = collapseWindows([
sec({ heading_path: 'Sec A', node_type: 'section_split', is_leaf: false, char_start: 10, text: '# Sec A', section_type: null, summary: null, confidence: null }),
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b1', section_type: 'requirement', summary: '요약1', confidence: 0.9 }),
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b2', section_type: 'requirement', summary: '요약2', confidence: 0.8 }),
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b3', section_type: 'overview', summary: '', confidence: 1.0 }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].sectionType, 'requirement', '다수결 = requirement(2) > overview(1)');
assert.ok(Math.abs(out[0].confidence! - 0.9) < 1e-9, '평균 (0.9+0.8+1.0)/3 = 0.9');
assert.deepEqual(out[0].summaries, ['요약1', '요약2'], '빈 요약 제외, 순서 유지');
// 단일 leaf 는 대표 자신의 분석
const single = collapseWindows([sec({ heading_path: 'X', node_type: null, text: 'body', section_type: 'definition', summary: '정의 요약', confidence: 0.7 })]);
assert.equal(single[0].sectionType, 'definition');
assert.equal(single[0].confidence, 0.7);
assert.deepEqual(single[0].summaries, ['정의 요약']);
// 분석 전혀 없는 절 → null/빈
const none = collapseWindows([sec({ heading_path: 'Y', node_type: null, text: 'body' })]);
assert.equal(none[0].sectionType, null);
assert.equal(none[0].confidence, null);
assert.deepEqual(none[0].summaries, []);
});
test('collapseWindows: 비인접 window 도 parent_id 로 split-parent 에 흡수 (빈 split 행 방지)', () => {
// 실데이터 버그: split-parent(chunk_index 1143)와 그 window(1233~)가 비인접 → 인접 흡수 실패로
// 빈 split 행 + 별도 window-그룹 행 2개로 쪼개짐. parent_id 링크로 정확히 합친다.
const out = collapseWindows([
sec({ chunk_id: 10, heading_path: 'FOREWORD', node_type: 'section_split', is_leaf: false, char_start: 5, text: '# FOREWORD' }),
sec({ chunk_id: 11, heading_path: 'POLICY', node_type: null, text: '정책 본문' }), // 사이에 낀 다른 절
sec({ chunk_id: 12, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각1', section_type: 'overview', summary: '요약A', confidence: 0.9 }),
sec({ chunk_id: 13, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각2', section_type: 'overview', summary: '요약B', confidence: 0.8 }),
]);
assert.equal(out.length, 2, 'FOREWORD(split, window 흡수) + POLICY = 2행 (빈 split 행 없음)');
assert.equal(out[0].section.chunk_id, 10, '대표 = split-parent(char_start 보유)');
assert.equal(out[0].bodyText, '서문 조각1\n\n서문 조각2', '비인접 window 본문을 split-parent 에 흡수');
assert.equal(out[0].fragmentCount, 2);
assert.equal(out[0].sectionType, 'overview');
assert.deepEqual(out[0].summaries, ['요약A', '요약B']);
assert.equal(out[1].section.chunk_id, 11, '사이 낀 절은 별도 행 유지');
assert.equal(out[1].bodyText, '정책 본문');
});
test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => {
// 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0%
const sections: DocumentSection[] = [];
+81 -10
View File
@@ -14,8 +14,12 @@ export interface DocumentSection {
level: number | null;
node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
is_leaf: boolean;
/** 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent (비인접 흡수에 사용). */
parent_id?: number | null;
/** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
char_start?: number | null;
/** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
text?: string | null;
section_type: string | null;
summary: string | null;
confidence: number | null;
@@ -25,6 +29,17 @@ export interface DocumentSection {
export interface OutlineItem {
section: DocumentSection;
fragmentCount: number; // >1 이면 "(n조각)" 배지
/** + window child .
* split-parent heading (text) ( ) window . */
bodyText: string;
/** - . windowed window child(chunk_section_analysis)
* =split-parent . .
* - sectionType: 멤버 section_type (= )
* - confidence: 멤버 confidence
* - summaries: 멤버 ( , chunk_index ) =1, windowed=N개( ) */
sectionType: string | null;
confidence: number | null;
summaries: string[];
}
export interface OutlineGroup {
@@ -107,22 +122,78 @@ function topSegment(s: DocumentSection): string {
* fragmentCount: split-parent 0 ( ) + child = ;
* legacy window 1 ( ).
*/
/** 멤버 section_type 다수결(동률은 첫 등장 우선). 비어있으면 null. */
function majorityType(types: (string | null)[]): string | null {
const vals = types.filter((t): t is string => !!t);
if (!vals.length) return null;
const count = new Map<string, number>();
for (const t of vals) count.set(t, (count.get(t) ?? 0) + 1);
let best: string | null = null;
let bestN = -1;
for (const t of vals) {
const n = count.get(t)!;
if (n > bestN) { bestN = n; best = t; } // 첫 등장 우선 tie-break
}
return best;
}
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
const out: OutlineItem[] = [];
const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child)
const repByChunkId = new Map<number, number>(); // split-parent chunk_id → out index (window 가 parent_id 로 흡수)
// window child 본문/멤버를 out[idx] 대표에 흡수.
const absorb = (idx: number, s: DocumentSection) => {
out[idx].fragmentCount += 1;
const t = (s.text ?? '').trim();
if (t) out[idx].bodyText = out[idx].bodyText ? `${out[idx].bodyText}\n\n${t}` : t;
members[idx].push(s);
};
for (const s of sections) {
const prev = out[out.length - 1];
const h = cleanHeading(s.heading_path);
const prevAbsorbs =
prev &&
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
h !== '' &&
cleanHeading(prev.section.heading_path) === h;
if (s.node_type === 'window' && prevAbsorbs) {
prev!.fragmentCount += 1; // window child 흡수 — 대표(split-parent 우선)는 그대로 유지
if (s.node_type === 'window') {
// 1) parent_id 로 split-parent 대표에 흡수 — split-parent 와 window 가 chunk_index 상 비인접일 수
// 있으므로(예: 헤딩 1143, window 1233) 인접 가정 대신 트리 부모 링크로 정확히 연결한다.
let idx = s.parent_id != null ? repByChunkId.get(s.parent_id) ?? -1 : -1;
// 2) fallback: 인접 대표(legacy window run / 같은 heading split)면 흡수
if (idx < 0) {
const prev = out[out.length - 1];
const h = cleanHeading(s.heading_path);
if (
prev &&
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
h !== '' &&
cleanHeading(prev.section.heading_path) === h
) {
idx = out.length - 1;
}
}
if (idx >= 0) {
absorb(idx, s);
continue;
}
// 3) legacy: 부모 없는 window → 자기 대표(자기 본문으로 시작)
out.push({ section: s, fragmentCount: 1, bodyText: s.text ?? '', sectionType: null, confidence: null, summaries: [] });
members.push([s]);
} else {
out.push({ section: s, fragmentCount: s.node_type?.endsWith('_split') ? 0 : 1 });
const isSplit = !!s.node_type?.endsWith('_split');
// split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
out.push({
section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? ''),
sectionType: null, confidence: null, summaries: [],
});
members.push([s]);
if (isSplit) repByChunkId.set(s.chunk_id, out.length - 1); // window 가 parent_id 로 찾아 흡수
}
}
// 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
for (let i = 0; i < out.length; i++) {
const mem = members[i];
out[i].sectionType = majorityType(mem.map((m) => m.section_type));
const confs = mem.map((m) => m.confidence).filter((c): c is number => c != null);
out[i].confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : null;
out[i].summaries = mem.map((m) => (m.summary ?? '').trim()).filter((x) => x !== '');
}
return out;
}
+22 -54
View File
@@ -8,8 +8,7 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { Info, X, Plus, Trash2, Tag, FolderTree, Sparkles, ChevronLeft, ArrowUpDown } from 'lucide-svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import { X, Plus, Trash2, Tag, FolderTree, Sparkles, ArrowUpDown } from 'lucide-svelte';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
import { isMdStatusVisible } from '$lib/utils/mdStatus';
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
@@ -233,15 +232,12 @@
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
}
async function selectDoc(doc) {
if (selectedDoc?.id === doc.id) { selectedDoc = null; return; }
selectedDoc = doc; // 즉시 표시(리더 + 기본 인스펙터)
// 인스펙터 풀 메타 하이드레이션 — 검색 결과(SearchResult)는 메타가 빈약(태그/크기/하위/md상태/읽음 없음).
// 풀 문서를 조회해 채운다(기존 GET /documents/{id}, 백엔드 무변). 리스트 모드도 md상태 등 보강.
try {
const full = await api(`/documents/${doc.id}`);
if (selectedDoc?.id === doc.id) selectedDoc = { ...doc, ...full };
} catch { /* 실패 시 기본 정보 유지 */ }
// 문서 열기 = 개선된 상세 페이지(D3 절 구조 탐색기)로 이동.
// 사용자 결정: "개선된 페이지가 앞으로 표시되야지" — 인라인 미리보기 폐기.
// /documents = 브라우즈/검색/필터/일괄 목록, 문서 열기 = /documents/[id] D3 리더.
function selectDoc(doc) {
if (!doc) return;
goto(`/documents/${doc.id}`);
}
// bulk 선택
@@ -386,8 +382,8 @@
<div class="flex h-full min-h-0">
<!-- ═══ 좌: 리스트 컬럼 ═══ -->
<div class="{selectedDoc ? 'hidden lg:flex' : 'flex'} flex-col w-full lg:w-[340px] lg:shrink-0 lg:border-r border-default min-h-0">
<!-- ═══ 문서 목록 (풀폭 중앙) — 클릭 시 D3 상세로 이동 ═══ -->
<div class="flex flex-col w-full max-w-5xl mx-auto min-h-0">
<UploadDropzone onupload={loadDocuments} />
<!-- 검색바 -->
@@ -487,6 +483,19 @@
{/if}
</div>
<!-- AI 답변 (질문형 검색) — 목록 상단 고정, 아래로 목록 스크롤 -->
{#if showAskCard}
<div class="px-3 py-2 shrink-0 border-b border-default max-h-[55vh] overflow-y-auto">
<AskAnswerCard
data={askData}
loading={askLoading}
error={askError}
onCitationClick={(docId) => goto(`/documents/${docId}`)}
onDismiss={() => { askDismissed = true; }}
/>
</div>
{/if}
<!-- 선택 toolbar -->
{#if selectionCount > 0}
<div class="flex flex-wrap items-center gap-2 px-3 py-2 shrink-0 bg-accent/10 border-y border-accent/30">
@@ -587,47 +596,6 @@
</div>
</div>
<!-- ═══ 중앙: 리더 ═══ -->
<div class="{selectedDoc ? 'flex' : 'hidden lg:flex'} flex-1 min-w-0 flex-col min-h-0">
{#if selectedDoc}
<!-- 리더 상단 바: (모바일) 뒤로 / (lg) 인스펙터 토글 -->
<div class="flex items-center gap-2 px-3 py-1.5 shrink-0 border-b border-default bg-sidebar">
<button type="button" onclick={() => { selectedDoc = null; if (ui.isDrawerOpen('meta')) ui.closeDrawer(); }}
class="lg:hidden flex items-center gap-1 text-xs text-accent-hover font-medium" aria-label="목록으로">
<ChevronLeft size={15} /> 문서
</button>
<div class="flex-1"></div>
<button type="button" onclick={toggleInfoPanel} aria-pressed={isPanelActive} title="문서 정보"
class="p-1.5 rounded-lg border transition-colors {isPanelActive ? 'border-accent text-accent bg-accent/10' : 'border-default text-dim hover:text-accent hover:border-accent'}">
<Info size={16} />
</button>
</div>
<div class="flex-1 min-h-0">
<DocumentViewer doc={selectedDoc} />
</div>
{:else if showAskCard}
<div class="p-4 lg:p-6 overflow-y-auto">
<AskAnswerCard
data={askData}
loading={askLoading}
error={askError}
onCitationClick={(docId) => goto(`/documents/${docId}`)}
onDismiss={() => { askDismissed = true; }}
/>
</div>
{:else}
<div class="hidden lg:flex flex-1 items-center justify-center text-dim text-sm">
왼쪽에서 문서를 선택하세요
</div>
{/if}
</div>
<!-- ═══ 우: 인스펙터 (xl+ inline) ═══ -->
{#if selectedDoc && inspectorOpen}
<aside class="hidden xl:flex flex-col w-[300px] shrink-0 border-l border-default bg-sidebar overflow-y-auto" aria-label="문서 정보">
{@render inspector(selectedDoc)}
</aside>
{/if}
</div>
<!-- < xl 폴백: Drawer (정보 하단/측면 시트) -->
+376 -458
View File
@@ -1,19 +1,17 @@
<script>
// Phase E.2 — detail 페이지 inline 편집.
// 기존 read-only 메타 패널(L138201)을 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: -3px; 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(90deg); }
.d3warn { animation: d3pulse 2.4s 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 3px rgba(181, 132, 10, 0); } }
</style>