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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
// 문서 상세 /documents/[id] — D3 절 구조 탐색기 (확정 시안 충실 구현).
|
||||
// 레이아웃: [좌 절 트리][중 절 집중 뷰][우 슬림 전역 인사이트 레일]. 절 없으면 fallback(본문+레일).
|
||||
// 절 본문 = md_content 를 char_start 로 슬라이스. 기존 편집/필기/인접네비/회독은 보존(관리·정보 접이).
|
||||
// 문서 상세 /documents/[id] — 확정 시안(d3-deepened) 스타일을 그대로 포팅, 데이터만 바인딩.
|
||||
// 데스크탑: 상단 헤더 띠 + [좌 절 트리(색바+연결선)][중 절 집중 뷰][우 슬림 레일]. 절 없으면 fallback.
|
||||
// 모바일: 헤더 + 나란한 토글 pill(절구조|인사이트) + 본문 절 카드 연속(+탭 이동). 편집/필기/네비 보존.
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -10,7 +10,7 @@
|
||||
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 EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
@@ -21,21 +21,15 @@
|
||||
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 ReadCounter from '$lib/components/ReadCounter.svelte';
|
||||
import { cleanHeading, pathSegments, sectionTypeLabel } from '$lib/utils/headingPath';
|
||||
import { domainLabel } from '$lib/utils/domainSlug';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text || ''), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'], FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,75 +39,54 @@
|
||||
let rawMarkdown = $state('');
|
||||
let docId = $derived($page.params.id);
|
||||
|
||||
// 손글씨 노트
|
||||
// 필기
|
||||
let noteOpen = $state(false);
|
||||
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 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; }
|
||||
|
||||
// 인접 자료 (학습 흐름)
|
||||
// 인접 자료
|
||||
let neighbors = $state({ prev: null, next: null });
|
||||
async function loadNeighbors() {
|
||||
try { neighbors = await api(`/documents/${docId}/library-neighbors`); }
|
||||
catch { neighbors = { prev: null, next: null }; }
|
||||
}
|
||||
|
||||
// 절(section) 목차
|
||||
let sections = $state([]);
|
||||
let hasSections = $derived(sections.length > 0);
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 절 목차
|
||||
let sections = $state([]);
|
||||
let hasSections = $derived(sections.length > 0);
|
||||
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 = []; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
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 { 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; }
|
||||
} 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()));
|
||||
// D3 절 집중 뷰 조건: 절 + 마크다운 본문 둘 다 있을 때. 아니면 fallback(본문/뷰어 + 레일).
|
||||
let useSectionView = $derived(hasSections && canShowMarkdown && !!doc?.md_content);
|
||||
|
||||
// PDF 폴백 토글
|
||||
let pdfViewMode = $state('markdown');
|
||||
let lastDocId = $state(null);
|
||||
$effect(() => {
|
||||
@@ -131,23 +104,15 @@
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// ── 절 집중 뷰 상태 ──
|
||||
// 절 집중/모바일 상태
|
||||
let selectedSectionId = $state(null);
|
||||
// 모바일 토글 (시안: 나란한 pill, 기본 둘 다 접힘 — 본문이 메인)
|
||||
let mTree = $state(false);
|
||||
let mIns = $state(false);
|
||||
$effect(() => {
|
||||
// 문서/섹션 로드 시 첫 절 선택. 선택이 현재 섹션집합에 없으면 첫 절로.
|
||||
if (sections.length && !sections.some((s) => s.chunk_id === selectedSectionId)) {
|
||||
selectedSectionId = sections[0].chunk_id;
|
||||
}
|
||||
});
|
||||
let manageOpen = $state(false);
|
||||
$effect(() => { if (sections.length && !sections.some((s) => s.chunk_id === selectedSectionId)) selectedSectionId = sections[0].chunk_id; });
|
||||
let selectedSection = $derived(sections.find((s) => s.chunk_id === selectedSectionId) ?? sections[0] ?? null);
|
||||
let selIdx = $derived(sections.findIndex((s) => s.chunk_id === selectedSection?.chunk_id));
|
||||
// char_start 정렬 — 절 본문 슬라이스용
|
||||
let sortedSecs = $derived(
|
||||
[...sections].filter((s) => s.char_start != null).sort((a, b) => a.char_start - b.char_start)
|
||||
);
|
||||
let sortedSecs = $derived([...sections].filter((s) => s.char_start != null).sort((a, b) => a.char_start - b.char_start));
|
||||
function sectionBodyHtml(sec) {
|
||||
if (!doc?.md_content || !sec || sec.char_start == null) return '';
|
||||
const idx = sortedSecs.findIndex((s) => s.chunk_id === sec.chunk_id);
|
||||
@@ -157,179 +122,77 @@
|
||||
}
|
||||
let selectedBodyHtml = $derived(sectionBodyHtml(selectedSection));
|
||||
|
||||
// 절 유형 색 (시안: 정의 청 / 절차 올리브 / 요건 황)
|
||||
const TYPE_META = {
|
||||
definition: { label: '정의', color: '#2f7d8f' },
|
||||
procedure: { label: '절차', color: '#4f8a6b' },
|
||||
requirement: { label: '요건', color: '#b5840a' },
|
||||
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) || '', color: '#9aa090' }; }
|
||||
function confColor(c) { return c == null ? '#9aa090' : c < 0.6 ? '#b5840a' : '#1f9d6b'; }
|
||||
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 selectSection(id) { selectedSectionId = id; }
|
||||
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);
|
||||
|
||||
// 헤더/affordance
|
||||
function copyLink() {
|
||||
const url = `${window.location.origin}/documents/${docId}`;
|
||||
navigator.clipboard.writeText(url).then(() => addToast('success', '링크 복사됨')).catch(() => addToast('error', '복사 실패'));
|
||||
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>
|
||||
|
||||
<!-- ════ 절 트리 (좌·모바일 공용. jumpMode=true → 본문 앵커 점프) ════ -->
|
||||
{#snippet treeNav(jumpMode = false)}
|
||||
<div class="text-xs">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-[11px] font-bold text-dim uppercase tracking-wide">절 구조</span>
|
||||
<span class="text-faint text-[11px]">{sections.length}절</span>
|
||||
<!-- ════ 좌 트리 (시안: 색바 + 연결선 + 활성 + 저신뢰 경고) ════ -->
|
||||
{#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;">{sections.length}절</span>
|
||||
</div>
|
||||
<!-- 유형 범례 -->
|
||||
<div class="flex items-center gap-3 px-1 pb-2 mb-1 border-b border-default text-[10px] text-dim">
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#2f7d8f"></span>정의</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#4f8a6b"></span>절차</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#b5840a"></span>요건</span>
|
||||
<div 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>
|
||||
<ul class="space-y-0.5">
|
||||
{#each sections as s (s.chunk_id)}
|
||||
{@const tm = typeMeta(s.section_type)}
|
||||
{@const active = s.chunk_id === selectedSection?.chunk_id}
|
||||
{@const low = isLowConf(s.confidence)}
|
||||
<li>
|
||||
{#if jumpMode}
|
||||
<a href="#m-sec-{s.chunk_id}" style="padding-left:{8 + secDepth(s) * 13}px"
|
||||
class="w-full pr-2 py-1.5 rounded-md flex items-center gap-2 text-dim hover:bg-surface-hover hover:text-text {secDepth(s) > 0 ? 'text-[11px]' : 'text-xs'}">
|
||||
<span class="w-2 h-2 rounded-sm shrink-0" style="background:{tm.color}"></span>
|
||||
<span class="flex-1 min-w-0 truncate leading-snug">{secTitle(s)}</span>
|
||||
{#if low}<span class="shrink-0 w-3.5 h-3.5 rounded-full border border-warning text-warning text-[9px] font-extrabold flex items-center justify-center">!</span>{:else}<span class="shrink-0 w-1.5 h-1.5 rounded-full" style="background:{confColor(s.confidence)}"></span>{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button type="button" onclick={() => selectSection(s.chunk_id)} aria-current={active ? 'true' : undefined}
|
||||
style="padding-left:{8 + secDepth(s) * 13}px"
|
||||
class="w-full text-left pr-2 py-1.5 rounded-md flex items-center gap-2 transition-colors border border-transparent
|
||||
{active ? 'bg-surface-active text-text border-accent/40 font-semibold' : 'text-dim hover:bg-surface-hover hover:text-text'}
|
||||
{secDepth(s) > 0 ? 'text-[11px]' : 'text-xs'}">
|
||||
<span class="w-2 h-2 rounded-sm shrink-0" style="background:{tm.color}"></span>
|
||||
<span class="flex-1 min-w-0 truncate leading-snug">{secTitle(s)}</span>
|
||||
{#if low}
|
||||
<span class="shrink-0 w-3.5 h-3.5 rounded-full border border-warning text-warning text-[9px] font-extrabold flex items-center justify-center" title="저신뢰 — 추출 불완전">!</span>
|
||||
{:else}
|
||||
<span class="shrink-0 w-1.5 h-1.5 rounded-full" style="background:{confColor(s.confidence)}" title="신뢰도 {s.confidence != null ? s.confidence.toFixed(2) : '—'}"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#each sections as s (s.chunk_id)}
|
||||
{@const tm = typeMeta(s.section_type)}
|
||||
{@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id}
|
||||
{@const child = secDepth(s) > 0}
|
||||
{@const low = isMidLow(s.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="신뢰도 {s.confidence != null ? s.confidence.toFixed(2) : '—'}" style="width:7px;height:7px;border-radius:50%;background:{confColor(s.confidence)};flex-shrink:0;"></span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</svelte:element>
|
||||
{/each}
|
||||
{#if quality}
|
||||
<div class="mt-3 pt-2 border-t border-default text-[10px] text-faint leading-relaxed">
|
||||
추출 품질
|
||||
{#if quality.headings != null}· 제목 {quality.headings}{/if}
|
||||
{#if quality.tables != null}· 표 {quality.tables}{/if}
|
||||
{#if quality.images != null}· 이미지 {quality.images}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 슬림 전역 인사이트 레일 (우) ════ -->
|
||||
{#snippet rail()}
|
||||
<div class="space-y-3 text-xs">
|
||||
<!-- TL;DR -->
|
||||
{#if doc.ai_tldr || doc.ai_summary}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-warning uppercase tracking-wide mb-1.5">TL;DR</div>
|
||||
<p class="text-text leading-relaxed">{doc.ai_tldr || doc.ai_summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 핵심점 -->
|
||||
{#if doc.ai_bullets && doc.ai_bullets.length}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-2">핵심점</div>
|
||||
<ul class="space-y-1.5">
|
||||
{#each doc.ai_bullets as b}
|
||||
<li class="flex gap-1.5 text-text leading-relaxed"><span class="text-accent mt-px">·</span><span class="flex-1 min-w-0">{b}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 심층 -->
|
||||
{#if doc.ai_detail_summary}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="text-[10px] font-bold text-dim uppercase tracking-wide">심층</span>
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span class="text-[9px] font-bold rounded px-1.5 py-px" style="background:#e3ebdf;color:#3d7256">DEEP</span>{/if}
|
||||
</div>
|
||||
<p class="text-dim leading-relaxed whitespace-pre-line">{doc.ai_detail_summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 불일치 -->
|
||||
{#if doc.ai_inconsistencies && doc.ai_inconsistencies.length}
|
||||
<div class="bg-surface border border-warning/50 rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-warning uppercase tracking-wide mb-1.5">불일치 {doc.ai_inconsistencies.length}</div>
|
||||
<ul class="space-y-1">
|
||||
{#each doc.ai_inconsistencies as inc}
|
||||
<li class="text-text leading-relaxed">· {typeof inc === 'string' ? inc : inc.desc || inc.kind}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 분류 -->
|
||||
{#if doc.ai_domain}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-1.5">분류</div>
|
||||
<div class="text-text leading-relaxed">
|
||||
<strong>{domainLabel(doc.ai_domain)}</strong>{#if doc.ai_sub_group} · {doc.ai_sub_group}{/if}
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
{#if doc.ai_confidence != null}<span class="text-success font-bold">신뢰도 {doc.ai_confidence.toFixed(2)}</span>{/if}
|
||||
{#if doc.ai_analysis_tier}<span class="text-[9px] font-bold rounded px-1.5 py-px" style="background:#e3ebdf;color:#3d7256">{doc.ai_analysis_tier}</span>{/if}
|
||||
</div>
|
||||
<div 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}
|
||||
<!-- 태그 -->
|
||||
{#if doc.ai_tags && doc.ai_tags.length}
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-2">태그</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each doc.ai_tags as t}<span class="text-[10.5px] text-accent-hover bg-accent/12 rounded-full px-2.5 py-0.5">{t}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 관련 문서 (v1 자리) -->
|
||||
<div class="bg-surface border border-default rounded-card p-3.5">
|
||||
<div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-1.5">관련 문서</div>
|
||||
<p class="text-faint leading-relaxed text-[11px]">벡터 유사도 기반 — 준비 중</p>
|
||||
</div>
|
||||
<!-- 정보 (접이) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-[10px] font-bold text-dim uppercase tracking-wide select-none">
|
||||
<span>문서 정보</span><ChevronRight size={13} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
{#if doc.category === 'library'}<ReadCounter documentId={doc.id} initialCount={doc.read_count ?? 0} initialLastReadAt={doc.last_read_at ?? null} />{/if}
|
||||
<FileInfoView {doc} />
|
||||
<ProcessingStatusView {doc} />
|
||||
</div>
|
||||
</details>
|
||||
<!-- 관리 (접이) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-[10px] font-bold text-dim uppercase tracking-wide select-none">
|
||||
<span>관리 · 분류 편집</span><ChevronRight size={13} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
<AIClassificationEditor {doc} />
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
<div class="pt-2 border-t border-default"><DocumentDangerZone {doc} ondelete={handleDocDelete} /></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -337,262 +200,250 @@
|
||||
{#snippet focusView()}
|
||||
{#if selectedSection}
|
||||
{@const tm = typeMeta(selectedSection.section_type)}
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-faint mb-2 flex-wrap">
|
||||
<span class="truncate max-w-[180px]">{doc.title}</span>
|
||||
{#each pathSegments(selectedSection.heading_path) as seg}<span>/</span><span class="text-dim">{seg}</span>{/each}
|
||||
<div 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 class="flex items-start justify-between gap-3 mb-3">
|
||||
<h2 class="text-xl font-bold text-text leading-snug min-w-0">{secTitle(selectedSection)}</h2>
|
||||
{#if tm.label}<span class="shrink-0 text-[11px] font-semibold rounded-full px-2.5 py-1" style="background:{tm.color}1a;color:{tm.color}">{tm.label}</span>{/if}
|
||||
<div 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 selectedSection.confidence != null}
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<span class="text-[11px] text-dim">신뢰도</span>
|
||||
<span class="flex-1 max-w-[260px] h-1.5 rounded-full bg-default overflow-hidden"><span class="block h-full rounded-full" style="width:{Math.round(selectedSection.confidence * 100)}%;background:{confColor(selectedSection.confidence)}"></span></span>
|
||||
<span class="text-xs font-bold tabular-nums" style="color:{confColor(selectedSection.confidence)}">{selectedSection.confidence.toFixed(2)}</span>
|
||||
<div 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(selectedSection.confidence)}%;height:100%;background:{confColor(selectedSection.confidence)};border-radius:999px;"></div></div>
|
||||
<span style="font-size:13px;font-weight:700;color:{confColor(selectedSection.confidence)};font-variant-numeric:tabular-nums;flex-shrink:0;">{selectedSection.confidence.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLowConf(selectedSection.confidence)}
|
||||
<div class="flex items-start gap-2 bg-surface-hover border border-warning/50 rounded-lg px-3 py-2 mb-3 text-xs text-warning"><span class="shrink-0 w-4 h-4 rounded-full border border-warning text-warning text-[10px] font-extrabold flex items-center justify-center mt-px">!</span><span>저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.</span></div>
|
||||
<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 selectedSection.summary}
|
||||
<div class="border-l-[3px] border-accent bg-surface-hover rounded-r-lg px-4 py-3 mb-4"><div class="text-[10px] font-bold text-dim uppercase tracking-wide mb-1">절 요약</div><p class="text-text leading-relaxed whitespace-pre-line">{selectedSection.summary}</p></div>
|
||||
<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;">절 요약</div>
|
||||
<div style="font-size:15.5px;line-height:1.6;color:#23291f;white-space:pre-line;">{selectedSection.summary}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedBodyHtml}
|
||||
<div class="prose prose-base max-w-none text-text">{@html selectedBodyHtml}</div>
|
||||
{:else}
|
||||
<p class="text-faint text-sm italic">이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.</p>
|
||||
<p style="color:#9aa090;font-size:14px;font-style:italic;">이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.</p>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between mt-5 pt-3 border-t border-default text-xs">
|
||||
<button type="button" disabled={selIdx <= 0} onclick={() => selIdx > 0 && selectSection(sections[selIdx - 1].chunk_id)} class="flex items-center gap-1 text-dim hover:text-accent disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft size={14} />이전 절</button>
|
||||
<span class="text-faint tabular-nums">{selIdx + 1} / {sections.length}</span>
|
||||
<button type="button" disabled={selIdx >= sections.length - 1} onclick={() => selIdx < sections.length - 1 && selectSection(sections[selIdx + 1].chunk_id)} class="flex items-center gap-1 text-dim hover:text-accent disabled:opacity-30 disabled:cursor-not-allowed">다음 절<ChevronRight size={14} /></button>
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;margin-top:20px;padding-top:14px;border-top:1px solid #dde3d6;">
|
||||
{#if selIdx > 0}
|
||||
<button type="button" onclick={() => (selectedSectionId = sections[selIdx - 1].chunk_id)} style="font-size:12px;color:#697061;border:1px solid #dde3d6;border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;">← {secTitle(sections[selIdx - 1])}</button>
|
||||
{:else}<span></span>{/if}
|
||||
{#if selIdx < sections.length - 1}
|
||||
{@const nx = sections[selIdx + 1]}
|
||||
<button type="button" onclick={() => (selectedSectionId = nx.chunk_id)} style="font-size:12px;color:{isMidLow(nx.confidence) ? '#8a6306' : '#697061'};border:1px solid {isMidLow(nx.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">{#if isMidLow(nx.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(s)}
|
||||
{@const tm = typeMeta(s.section_type)}
|
||||
{@const body = sectionBodyHtml(s)}
|
||||
<section id="m-sec-{s.chunk_id}" class="scroll-mt-3 bg-surface border border-default rounded-card p-4">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h2 class="text-base font-bold text-text leading-snug min-w-0">{secTitle(s)}</h2>
|
||||
{#if tm.label}<span class="shrink-0 text-[10.5px] font-semibold rounded-full px-2 py-0.5" style="background:{tm.color}1a;color:{tm.color}">{tm.label}</span>{/if}
|
||||
<div id="m-sec-{s.chunk_id}" style="scroll-margin-top:12px;background:#f4f7f1;border:1px solid {isLowConf(s.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(s.confidence)}
|
||||
<div class="flex items-start gap-2 bg-surface-hover border border-warning/50 rounded-lg px-3 py-2 mb-2.5 text-[11px] text-warning"><span class="shrink-0 w-4 h-4 rounded-full border border-warning text-warning text-[10px] font-extrabold flex items-center justify-center mt-px">!</span><span>저신뢰 — 표·수식 추출 불완전, 원본 확인 권장</span></div>
|
||||
<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 s.summary}
|
||||
<div class="border-l-[3px] border-accent bg-surface-hover rounded-r-lg px-3 py-2 mb-3"><div class="text-[9px] font-bold text-dim uppercase tracking-wide mb-0.5">절 요약</div><p class="text-text text-[13px] leading-relaxed whitespace-pre-line">{s.summary}</p></div>
|
||||
<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;">절 요약</div><div style="font-size:13.5px;line-height:1.55;color:#23291f;white-space:pre-line;">{s.summary}</div></div>
|
||||
{/if}
|
||||
{#if body}<div class="prose prose-sm max-w-none text-text">{@html body}</div>{/if}
|
||||
</section>
|
||||
{#if body}
|
||||
<details class="m-secbody">
|
||||
<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>
|
||||
<div class="prose prose-sm max-w-none text-text" style="margin-top:6px;">{@html body}</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="p-4 lg:p-6 max-w-[1360px] mx-auto">
|
||||
<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}
|
||||
<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="bg-surface border border-default rounded-card px-4 py-3 mb-4">
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1.5">
|
||||
{#if doc.ai_domain}
|
||||
<span class="inline-flex items-center gap-1 text-[11px] font-semibold" style="color:#b5840a">
|
||||
<span class="w-2 h-2 rounded-full" style="background:#b5840a"></span>{domainLabel(doc.ai_domain)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if doc.ai_sub_group}<span class="text-[11px] text-faint">{doc.ai_sub_group}</span>{/if}
|
||||
<span class="text-[10px] font-bold rounded px-1.5 py-px uppercase" style="background:#f7e7e3;color:#c0564a">{doc.file_format}{#if canShowMarkdown}→MD{/if}</span>
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span class="text-[10px] font-bold rounded px-1.5 py-px" style="background:#e3ebdf;color:#3d7256">tier DEEP</span>{/if}
|
||||
{#if doc.ai_confidence != null}<span class="text-[11px] text-dim">신뢰도 <b class="text-success">{doc.ai_confidence.toFixed(2)}</b></span>{/if}
|
||||
<!-- ════ 상단 띠: 문서 헤더 (시안) ════ -->
|
||||
<div 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>
|
||||
<h1 class="text-lg font-bold text-text leading-snug">{doc.title}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
{#if doc.edit_url}<Button variant="secondary" size="sm" icon={ExternalLink} href={doc.edit_url} target="_blank">Synology</Button>{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>원본</Button>
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>링크</Button>
|
||||
{#if doc.category === 'library'}
|
||||
<Button variant={noteOpen ? 'primary' : 'secondary'} size="sm" icon={noteOpen ? X : PenLine} onclick={toggleNote}>{noteOpen ? '필기 닫기' : '필기'}</Button>
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
{#if useSectionView}
|
||||
<!-- ════ 데스크탑(xl+): 트리 | 절 집중 | 레일 ════ -->
|
||||
<div class="hidden xl:grid xl:grid-cols-[252px_minmax(0,1fr)_336px] gap-4 items-start">
|
||||
<aside class="xl:sticky xl:top-4 xl:self-start xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto bg-surface border border-default rounded-card p-3.5">
|
||||
{@render treeNav(false)}
|
||||
</aside>
|
||||
<div class="min-w-0 bg-surface border border-default rounded-card p-5">
|
||||
{@render focusView()}
|
||||
</div>
|
||||
<aside class="min-w-0">{@render rail()}</aside>
|
||||
<!-- 데스크탑(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 + 패널 + 본문 연속 ════ -->
|
||||
<!-- 모바일(<xl): 나란한 토글 pill + 패널 + 본문 연속 -->
|
||||
<div class="xl:hidden">
|
||||
<!-- 토글 pill (절 구조 / 인사이트) — 기본 접힘, 본문이 메인 -->
|
||||
<div class="flex gap-2 mb-2.5">
|
||||
<button type="button" onclick={() => (mTree = !mTree)}
|
||||
class="flex-1 flex items-center justify-between gap-1.5 border rounded-[10px] px-3 py-2.5 text-[12.5px] font-semibold transition-colors {mTree ? 'bg-surface-active border-accent text-text' : 'bg-surface border-default text-dim'}">
|
||||
<span>절 구조 <span class="text-[10px] text-faint font-normal">{sections.length}절</span></span>
|
||||
<ChevronRight size={14} class="text-faint transition-transform {mTree ? 'rotate-90' : ''}" />
|
||||
</button>
|
||||
<button type="button" onclick={() => (mIns = !mIns)}
|
||||
class="flex-1 flex items-center justify-between gap-1.5 border rounded-[10px] px-3 py-2.5 text-[12.5px] font-semibold transition-colors {mIns ? 'bg-surface-active border-accent text-text' : 'bg-surface border-default text-dim'}">
|
||||
<span>인사이트</span>
|
||||
<ChevronRight size={14} class="text-faint transition-transform {mIns ? 'rotate-90' : ''}" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 절 구조 패널 (펼치면 절 목록 — 탭하면 본문 이동) -->
|
||||
{#if mTree}
|
||||
<div class="bg-surface border border-default rounded-xl p-1.5 mb-2.5">
|
||||
<div class="flex items-center gap-3 px-2.5 pb-1.5 mb-1 border-b border-default text-[10px] text-dim">
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#2f7d8f"></span>정의</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#4f8a6b"></span>절차</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="w-2 h-2 rounded-sm" style="background:#b5840a"></span>요건</span>
|
||||
<span class="ml-auto text-faint">탭 → 본문 이동</span>
|
||||
</div>
|
||||
{#each sections as s (s.chunk_id)}
|
||||
{@const tm = typeMeta(s.section_type)}
|
||||
<a href="#m-sec-{s.chunk_id}" style="padding-left:{10 + secDepth(s) * 14}px"
|
||||
class="flex items-center gap-2 pr-2.5 py-2 rounded-lg active:bg-surface-hover {secDepth(s) > 0 ? 'text-[13px] text-dim' : 'text-[13.5px] text-text'}">
|
||||
{#if secDepth(s) === 0}<span class="w-2 h-2 rounded-sm shrink-0" style="background:{tm.color}"></span>{/if}
|
||||
<span class="flex-1 min-w-0 truncate">{secTitle(s)}</span>
|
||||
{#if isLowConf(s.confidence)}
|
||||
<span class="ml-auto inline-flex items-center gap-1 text-[10px] text-warning font-semibold shrink-0">저신뢰<span class="w-3 h-3 rounded-full border border-warning text-[8px] font-extrabold flex items-center justify-center">!</span></span>
|
||||
{:else}
|
||||
<span class="ml-auto w-1.5 h-1.5 rounded-full shrink-0" style="background:{confColor(s.confidence)}"></span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 인사이트 패널 (전역 가공 자료) -->
|
||||
{#if mIns}
|
||||
<div class="bg-surface border border-default rounded-xl p-3.5 mb-2.5 text-xs">
|
||||
{#if doc.ai_tldr || doc.ai_summary}
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wide mb-1">TL;DR</div>
|
||||
<p class="text-text mb-3 leading-relaxed">{doc.ai_tldr || doc.ai_summary}</p>
|
||||
{/if}
|
||||
{#if doc.ai_bullets && doc.ai_bullets.length}
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wide mb-1.5">핵심점</div>
|
||||
<ul class="space-y-1 mb-3">{#each doc.ai_bullets as b}<li class="flex gap-1.5"><span class="text-accent">·</span><span class="flex-1 min-w-0 text-text">{b}</span></li>{/each}</ul>
|
||||
{/if}
|
||||
{#if doc.ai_detail_summary}
|
||||
<div class="flex items-center gap-1.5 mb-1"><span class="text-[11px] font-bold text-dim uppercase tracking-wide">심층</span>{#if doc.ai_analysis_tier === 'deep'}<span class="text-[9px] font-bold rounded-full px-1.5" style="background:#e3ebdf;color:#3d7256">DEEP</span>{/if}</div>
|
||||
<p class="text-dim mb-3 leading-relaxed whitespace-pre-line">{doc.ai_detail_summary}</p>
|
||||
{/if}
|
||||
{#if doc.ai_inconsistencies && doc.ai_inconsistencies.length}
|
||||
<div class="text-[11px] font-bold text-warning uppercase tracking-wide mb-1">불일치 {doc.ai_inconsistencies.length}</div>
|
||||
<ul class="space-y-1 mb-3">{#each doc.ai_inconsistencies as inc}<li class="text-text">· {typeof inc === 'string' ? inc : inc.desc || inc.kind}</li>{/each}</ul>
|
||||
{/if}
|
||||
{#if doc.ai_domain}
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wide mb-1">분류</div>
|
||||
<p class="text-text mb-3"><strong>{domainLabel(doc.ai_domain)}</strong>{#if doc.ai_sub_group} · {doc.ai_sub_group}{/if}{#if doc.ai_confidence != null} · <span class="text-success font-bold">{doc.ai_confidence.toFixed(2)}</span>{/if}</p>
|
||||
{/if}
|
||||
{#if doc.ai_tags && doc.ai_tags.length}
|
||||
<div class="flex flex-wrap gap-1.5">{#each doc.ai_tags as t}<span class="text-[10.5px] text-dim bg-surface-hover border border-default rounded-full px-2 py-0.5">{t}</span>{/each}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 본문 (메인): 절 카드 연속 -->
|
||||
<div class="space-y-3">
|
||||
{#each sections as s (s.chunk_id)}{@render sectionCard(s)}{/each}
|
||||
<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;">{sections.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 sections as s (s.chunk_id)}{@render sectionCard(s)}{/each}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ════ Fallback: 절 없음 — 본문/뷰어 + 레일 (D3 빈 절 graceful) ════ -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_336px] gap-4 items-start">
|
||||
<div class="min-w-0 bg-surface border border-default rounded-card p-5 min-h-[400px]">
|
||||
{#if !hasSections && canShowMarkdown}
|
||||
<p class="text-[11px] text-faint mb-3">이 문서는 절 분석이 없습니다 (짧은 문서이거나 분석 전) — 전체 본문으로 표시합니다.</p>
|
||||
{/if}
|
||||
<!-- 절 없음 fallback: 본문/뷰어 + 레일 -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_336px] gap-3.5 items-start">
|
||||
<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} 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}
|
||||
{#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-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}<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" />
|
||||
{: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'}
|
||||
{#if doc.md_content || doc.extracted_text}
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={null} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />
|
||||
{/if}
|
||||
{#if doc.edit_url}<div class="mt-4"><Button variant="primary" size="sm" icon={ExternalLink} href={doc.edit_url} target="_blank">원문 보기</Button></div>{/if}
|
||||
{:else}
|
||||
<EmptyState icon={FileText} title="인앱 미리보기 미지원" description="포맷: {doc.file_format}" />
|
||||
{/if}
|
||||
{#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>
|
||||
<aside class="min-w-0">
|
||||
<details open class="xl:hidden bg-surface border border-default rounded-card mb-2 group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-dim select-none">인사이트<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" /></summary>
|
||||
<div class="px-3 pb-3">{@render rail()}</div>
|
||||
</details>
|
||||
<div class="hidden xl:block">{@render rail()}</div>
|
||||
</aside>
|
||||
<div>{@render rail()}</div>
|
||||
</div>
|
||||
{/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}
|
||||
|
||||
<!-- 손글씨 노트 -->
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<div class="bg-surface border border-default rounded-card overflow-hidden mt-4">
|
||||
<div class="h-[60vh] min-h-[400px] flex flex-col">
|
||||
<HandwriteCanvas sessionId={doc.id} initialStrokes={noteStrokes} onChange={(strokes) => saveNote(strokes)} />
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
<!-- 모바일 학습 흐름 네비 (자료실) -->
|
||||
{#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" 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" 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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user