feat(clause-kb): 코드북 리더 r2 — 세이지 코드북 미감(인덱스/세리프/책내검색/양방향 백링크/페이저)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-30 05:02:35 +00:00
parent 7487739aec
commit c44692fddc
+151 -143
View File
@@ -1,7 +1,7 @@
<script>
// ASME 절-지식베이스: 유기적 단일-책 리더. parent 표준의 절-문서들을 한 권의 책처럼 탐색.
// 좌: Part-그룹 TOC / 우: 선택 절 본문 + breadcrumb + 이전/다음 + 양방향 백링크.
import { onMount } from 'svelte';
// ASME/법령 절-KB — 코드북·공부 리더 (r2). parent 표준/법령을 한 권의 책처럼.
// 좌 인덱스(Part/章→절/조) · 중 본문(MarkdownDoc=공식·표·이미지) · breadcrumb·이전다음·양방향 백링크.
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
@@ -15,6 +15,7 @@
let links = $state(null);
let expanded = $state({});
let loading = $state(false);
let q = $state('');
let parts = $derived.by(() => {
const out = [], idx = {};
@@ -25,8 +26,15 @@
}
return out;
});
let visibleParts = $derived.by(() => {
const term = q.trim().toLowerCase();
if (!term) return parts;
return parts.map(g => ({ part: g.part, items: g.items.filter(c =>
(c.clause_code || '').toLowerCase().includes(term) || (c.title || '').toLowerCase().includes(term)) }))
.filter(g => g.items.length);
});
let selMeta = $derived(clauses.find((c) => c.id === selectedId) || null);
const strip = (title, code) => (title || '').replace(code || '', '').trim();
const strip = (t, c) => (t || '').replace(c || '', '').replace(/^[(\s)]+|[(\s)]+$/g, '').trim();
async function loadBook() {
const r = await api(`/documents/${parentId}/clauses`);
@@ -41,21 +49,14 @@
loading = true;
selectedId = id;
try {
const [d, l] = await Promise.all([
api(`/documents/${id}`),
api(`/documents/${id}/backlinks`)
]);
clauseDoc = d;
links = l;
const [d, l] = await Promise.all([api(`/documents/${id}`), api(`/documents/${id}/backlinks`)]);
clauseDoc = d; links = l;
const sel = clauses.find((c) => c.id === id);
if (sel) expanded = { ...expanded, [sel.clause_part || '·']: true };
goto(`/book/${parentId}?c=${id}`, { replaceState: true, keepFocus: true, noScroll: true });
window.scrollTo({ top: 0 });
} finally {
loading = false;
}
await tick(); window.scrollTo({ top: 0 });
} finally { loading = false; }
}
onMount(async () => {
parentId = Number($page.params.id);
await loadBook();
@@ -65,137 +66,144 @@
</script>
<div class="book">
<aside class="toc">
<a class="btitle" href={`/documents/${parentId}`}>{parentTitle || 'ASME 표준'}</a>
<div class="hint"> {clauses.length}개 · 한 권의 책처럼 탐색</div>
{#each parts as g (g.part)}
<div class="part">
<button class="phead" onclick={() => (expanded = { ...expanded, [g.part]: !expanded[g.part] })}>
<span class="caret">{expanded[g.part] ? '▾' : '▸'}</span>
<span class="pname">{g.part}</span>
<span class="cnt">{g.items.length}</span>
</button>
{#if expanded[g.part]}
<ul>
{#each g.items as c (c.id)}
<li>
<button class="citem" class:active={c.id === selectedId} onclick={() => loadClause(c.id)}>
<b>{c.clause_code}</b><span class="ct">{strip(c.title, c.clause_code)}</span>
</button>
</li>
{/each}
</ul>
<!-- top bar -->
<div class="bar">
<span class="brand">-KB</span>
<span class="crumbs">{parentTitle} {#if selMeta}<b class="sep"></b> {selMeta.clause_part} <b class="sep"></b> <b>{links?.clause_code ?? selMeta.clause_code}</b>{/if}</span>
<div class="search"><input placeholder="절·조 번호 또는 키워드" bind:value={q} /></div>
<div class="tools"><span class="tool on">읽기</span><span class="tool">형광펜</span><span class="tool">노트</span><span class="tool">암기카드</span></div>
</div>
<div class="main">
<!-- left index -->
<aside class="idx">
<a class="btitle" href={`/documents/${parentId}`}>{parentTitle || '표준'}</a>
<div class="bmeta">{clauses.length} · 한 권의 책처럼 탐색</div>
{#each visibleParts as g (g.part)}
<div class="parttab" role="button" tabindex="0" onclick={() => (expanded = { ...expanded, [g.part]: !expanded[g.part] })}>
<span class="bar2"></span><span class="pname">{g.part}</span><span class="ct">{g.items.length}</span>
</div>
{#if expanded[g.part] || q.trim()}
{#each g.items as c (c.id)}
<div class="ci" class:on={c.id === selectedId} role="button" tabindex="0" onclick={() => loadClause(c.id)}>
<span class="no">{c.clause_code}</span><span class="tt">{strip(c.title, c.clause_code)}</span>
</div>
{/each}
{/if}
{/each}
</aside>
<!-- reader -->
<section class="read">
<div class="col">
{#if clauseDoc}
<div class="studybar">
<button class="sbtn" title="형광펜"></button>
<button class="sbtn" title="노트"></button>
<button class="sbtn" title="암기카드 추가"></button>
</div>
<div class="kicker"><span class="pth">{selMeta?.clause_part}</span></div>
<div class="h-no">{links?.clause_code ?? selMeta?.clause_code}</div>
<h1 class="h-title">{strip(clauseDoc.title, links?.clause_code ?? '')}</h1>
<div class="flow">
<button class="fl" disabled={!links?.prev} onclick={() => loadClause(links?.prev?.id)}>{links?.prev?.clause_code ?? ''}</button>
<button class="fl next" disabled={!links?.next} onclick={() => loadClause(links?.next?.id)}>{links?.next?.clause_code ?? ''}</button>
</div>
{#key clauseDoc.id}
<div class="docbody">
<MarkdownDoc documentId={clauseDoc.id} mdContent={clauseDoc.md_content ?? clauseDoc.extracted_text} mdStatus={null} class="prose prose-base max-w-none" />
</div>
{/key}
{#if links && (links.forward.length || links.back.length)}
<section class="conn">
{#if links.forward.length}
<div><h4>이 절이 참조 <span>{links.forward.length}</span></h4>
<div class="chiprow">{#each links.forward as f}
{#if f.doc_id}<button class="ref" onclick={() => loadClause(f.doc_id)}>{f.code}</button>
{:else}<span class="ref dg" title="외부/미분해">{f.code}</span>{/if}
{/each}</div></div>
{/if}
{#if links.back.length}
<div><h4>이 절을 참조 <span>{links.back.length}</span></h4>
<div class="chiprow">{#each links.back as b}<button class="ref" onclick={() => loadClause(b.doc_id)}>{b.code}</button>{/each}</div></div>
{/if}
</section>
{/if}
<div class="pager">
<button class="pg" disabled={!links?.prev} onclick={() => loadClause(links?.prev?.id)}>
<div class="d">← 이전</div><div class="t"><span class="pno">{links?.prev?.clause_code ?? '—'}</span> {strip(links?.prev?.title, links?.prev?.clause_code)}</div></button>
<button class="pg next" disabled={!links?.next} onclick={() => loadClause(links?.next?.id)}>
<div class="d">다음 →</div><div class="t"><span class="pno">{links?.next?.clause_code ?? '—'}</span> {strip(links?.next?.title, links?.next?.clause_code)}</div></button>
</div>
{:else}
<p class="empty">{loading ? '불러오는 중…' : '왼쪽에서 절을 선택하세요'}</p>
{/if}
</div>
{/each}
</aside>
<main class="reader">
{#if clauseDoc}
<nav class="crumb">
<a href={`/documents/${parentId}`}>{parentTitle}</a>
<span class="sep"></span><span>{selMeta?.clause_part}</span>
<span class="sep"></span><b>{links?.clause_code ?? selMeta?.clause_code}</b>
</nav>
<div class="flow">
<button disabled={!links?.prev} onclick={() => loadClause(links?.prev?.id)}>
{links?.prev?.clause_code ?? ''}
</button>
<span class="flowmid">{selMeta?.clause_part}</span>
<button disabled={!links?.next} onclick={() => loadClause(links?.next?.id)}>
{links?.next?.clause_code ?? ''}
</button>
</div>
<h1 class="ctitle">{clauseDoc.title}</h1>
{#key clauseDoc.id}
<MarkdownDoc
documentId={clauseDoc.id}
mdContent={clauseDoc.md_content ?? clauseDoc.extracted_text}
mdStatus={null}
class="prose prose-base max-w-none text-text"
/>
{/key}
{#if links && (links.forward.length || links.back.length)}
<section class="xlinks">
{#if links.forward.length}
<div class="xcol">
<h3>이 절이 참조 <span>{links.forward.length}</span></h3>
<ul>
{#each links.forward as f}
<li>
{#if f.doc_id}
<button class="xref" onclick={() => loadClause(f.doc_id)}>{f.code}</button>
{:else}
<span class="xref dangling" title="외부/미분해 참조">{f.code}</span>
{/if}
{#if f.title}<span class="xt">{strip(f.title, f.code)}</span>{/if}
</li>
{/each}
</ul>
</div>
{/if}
{#if links.back.length}
<div class="xcol">
<h3>이 절을 참조 <span>{links.back.length}</span></h3>
<ul>
{#each links.back as b}
<li>
<button class="xref" onclick={() => loadClause(b.doc_id)}>{b.code}</button>
{#if b.title}<span class="xt">{strip(b.title, b.code)}</span>{/if}
</li>
{/each}
</ul>
</div>
{/if}
</section>
{/if}
{:else}
<p class="empty">{loading ? '로딩…' : '왼쪽에서 절을 선택하세요'}</p>
{/if}
</main>
</section>
</div>
</div>
<style>
.book { display: grid; grid-template-columns: 300px 1fr; gap: 0; min-height: 100vh; align-items: start; }
.toc { position: sticky; top: 0; max-height: 100vh; overflow-y: auto; border-right: 1px solid #e5e7eb; padding: 16px 12px; background: #fafafa; }
.btitle { display: block; font-weight: 700; font-size: 15px; color: #111827; text-decoration: none; line-height: 1.35; }
.btitle:hover { text-decoration: underline; }
.hint { font-size: 11.5px; color: #6b7280; margin: 4px 0 14px; }
.part { margin-bottom: 2px; }
.phead { display: flex; align-items: center; gap: 7px; width: 100%; background: none; border: 0; cursor: pointer; padding: 5px 6px; font-size: 13px; font-weight: 600; color: #374151; border-radius: 6px; }
.phead:hover { background: #f0f0f0; }
.caret { color: #9ca3af; width: 10px; }
.pname { flex: 1; text-align: left; }
.cnt { font-size: 11px; color: #9ca3af; }
.toc ul { list-style: none; margin: 0 0 4px; padding: 0 0 0 16px; }
.citem { display: block; width: 100%; text-align: left; background: none; border: 0; cursor: pointer; padding: 3px 7px; font-size: 12.5px; color: #4b5563; border-radius: 5px; line-height: 1.4; }
.citem:hover { background: #eef2ff; }
.citem.active { background: #e0e7ff; color: #1e3a8a; }
.citem b { color: #1d4ed8; margin-right: 5px; }
.citem.active b { color: #1e3a8a; }
.ct { color: #6b7280; }
.citem.active .ct { color: #334155; }
.reader { padding: 26px 34px 80px; max-width: 880px; }
.crumb { font-size: 12.5px; color: #6b7280; margin-bottom: 12px; }
.crumb a { color: #2563eb; text-decoration: none; }
.crumb a:hover { text-decoration: underline; }
.crumb .sep { margin: 0 6px; color: #cbd5e1; }
.flow { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 18px; }
.flow button { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 7px; padding: 6px 12px; font-size: 12.5px; color: #374151; cursor: pointer; }
.flow button:hover:not(:disabled) { background: #e5e7eb; }
.flow button:disabled { opacity: 0.4; cursor: default; }
.flowmid { font-size: 11.5px; color: #9ca3af; }
.ctitle { font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 18px; letter-spacing: -0.2px; }
.xlinks { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; }
@media (max-width: 700px) { .book { grid-template-columns: 1fr; } .toc { position: static; max-height: none; } .xlinks { grid-template-columns: 1fr; } }
.xcol h3 { font-size: 13px; color: #374151; margin: 0 0 8px; }
.xcol h3 span { color: #9ca3af; font-weight: 400; }
.xcol ul { list-style: none; margin: 0; padding: 0; }
.xcol li { display: flex; align-items: baseline; gap: 7px; padding: 2px 0; font-size: 12.5px; }
.xref { background: #eff6ff; border: 1px solid #dbeafe; color: #1d4ed8; border-radius: 5px; padding: 1px 7px; font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.xref:hover { background: #dbeafe; }
.xref.dangling { background: #f9fafb; border-color: #e5e7eb; color: #9ca3af; cursor: default; }
.xt { color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { color: #9ca3af; padding: 60px 0; text-align: center; }
:global(body) { background: var(--bg); }
.book { --paper:#fbfcf9; --serif:"Iowan Old Style","Palatino Linotype","Noto Serif KR",Georgia,serif;
display:flex; flex-direction:column; min-height:100vh; }
.bar { display:flex; align-items:center; gap:14px; height:50px; padding:0 18px; background:var(--paper); border-bottom:1px solid var(--border); }
.brand { font-weight:700; font-size:13.5px; color:var(--text); }
.crumbs { color:var(--text-dim); font-size:12.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:46%; }
.crumbs b { color:var(--text); font-weight:600; } .crumbs .sep { color:#c8d6c0; margin:0 5px; }
.search { margin-left:auto; }
.search input { width:280px; background:var(--surface); border:1px solid var(--border); border-radius:9px; padding:7px 12px; font-size:13px; color:var(--text); outline:none; }
.search input:focus { border-color:var(--accent); }
.tools { display:flex; gap:2px; }
.tool { font-size:12px; color:var(--text-dim); padding:6px 10px; border-radius:8px; border:1px solid transparent; cursor:pointer; }
.tool:hover { background:var(--surface); } .tool.on { background:#ecf0e8; border-color:var(--border); color:var(--accent-hover); font-weight:600; }
.main { display:flex; align-items:flex-start; flex:1; }
.idx { width:264px; flex-shrink:0; align-self:stretch; border-right:1px solid var(--border);
background:linear-gradient(180deg,#f6f8f3,#f1f4ec); padding:16px 10px 30px 16px; position:sticky; top:0; max-height:100vh; overflow:auto; }
.btitle { display:block; font-family:var(--serif); font-size:15.5px; font-weight:600; color:var(--text); text-decoration:none; line-height:1.32; }
.btitle:hover { text-decoration:underline; }
.bmeta { font-size:11px; color:#9aa090; margin:3px 0 14px; }
.parttab { display:flex; align-items:center; gap:8px; margin:11px 0 4px; padding:3px 4px; border-radius:6px; cursor:pointer;
font-size:11px; font-weight:700; letter-spacing:.5px; color:var(--text-dim); text-transform:uppercase; }
.parttab:hover { background:#fff; } .parttab .bar2 { width:3px; height:12px; border-radius:2px; background:var(--domain-engineering); }
.parttab .pname { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .parttab .ct { color:#9aa090; font-weight:600; letter-spacing:0; }
.ci { display:flex; gap:9px; align-items:baseline; padding:4px 9px; border-radius:7px; cursor:pointer; line-height:1.4; }
.ci .no { font-family:ui-monospace,Menlo,monospace; font-size:11px; color:var(--accent); font-weight:600; min-width:52px; white-space:nowrap; }
.ci .tt { font-size:12.5px; color:var(--text-dim); overflow:hidden; text-overflow:ellipsis; }
.ci:hover { background:#fff; }
.ci.on { background:#fff; box-shadow:inset 3px 0 0 var(--accent), 0 1px 2px rgba(35,41,31,.05); }
.ci.on .no { color:var(--accent-hover); font-weight:700; } .ci.on .tt { color:var(--text); font-weight:600; }
.read { flex:1; min-width:0; padding:34px 40px 80px; }
.col { max-width:680px; margin:0 auto; position:relative; }
.studybar { position:absolute; right:-30px; top:4px; display:flex; flex-direction:column; gap:6px; }
.sbtn { width:34px; height:34px; border-radius:9px; border:1px solid var(--border); background:var(--paper); color:var(--text-dim); font-size:13px; cursor:pointer; }
.sbtn:hover { background:var(--surface); color:var(--accent-hover); }
.kicker { margin-bottom:5px; } .kicker .pth { font-size:11.5px; color:#9aa090; font-weight:600; letter-spacing:.3px; }
.h-no { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--accent); font-weight:700; letter-spacing:.5px; }
.h-title { font-family:var(--serif); font-size:26px; line-height:1.24; font-weight:600; margin:2px 0 14px; letter-spacing:-.2px; color:var(--text); }
.flow { display:flex; justify-content:space-between; gap:8px; margin-bottom:18px; }
.flow .fl { font-size:11.5px; color:var(--text-dim); background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:5px 11px; cursor:pointer; }
.flow .fl:hover:not(:disabled) { background:#ecf0e8; } .flow .fl:disabled { opacity:.35; cursor:default; }
.docbody { font-size:15.5px; }
.docbody :global(.prose) { color:#2a3024; line-height:1.78; }
.docbody :global(.prose h1), .docbody :global(.prose h2), .docbody :global(.prose h3) { font-family:var(--serif); }
.docbody :global(a) { color:var(--accent-hover); }
.conn { margin-top:34px; padding-top:18px; border-top:1px solid var(--border); display:grid; grid-template-columns:1fr 1fr; gap:22px; }
.conn h4 { font-size:11px; font-weight:700; color:var(--text-dim); letter-spacing:.4px; margin:0 0 9px; } .conn h4 span { color:#9aa090; font-weight:500; }
.chiprow { display:flex; flex-wrap:wrap; gap:5px; }
.ref { font-family:ui-monospace,Menlo,monospace; font-size:11.5px; font-weight:600; color:var(--accent-hover); background:#eef4ec; border:1px solid #d9e6d8; border-radius:6px; padding:2px 8px; cursor:pointer; }
.ref:hover { background:#e2efe0; } .ref.dg { color:#9aa090; background:var(--surface); border-color:var(--border); cursor:default; }
.pager { display:flex; gap:10px; margin-top:30px; }
.pg { flex:1; text-align:left; border:1px solid var(--border); border-radius:11px; padding:11px 14px; background:var(--paper); cursor:pointer; }
.pg.next { text-align:right; } .pg:hover:not(:disabled) { border-color:#cfd7c6; background:#fff; } .pg:disabled { opacity:.4; cursor:default; }
.pg .d { font-size:10.5px; color:#9aa090; } .pg .t { font-size:12.5px; color:var(--text-dim); font-weight:600; margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pg .pno { font-family:ui-monospace,Menlo,monospace; color:var(--accent); }
.empty { color:#9aa090; text-align:center; padding:80px 0; }
@media(max-width:820px){ .idx{display:none} .read{padding:24px 18px} .conn{grid-template-columns:1fr} .studybar{display:none} .crumbs{max-width:30%} .search input{width:150px} }
</style>