feat(clause-kb): 코드북 리더 r2 — 세이지 코드북 미감(인덱스/세리프/책내검색/양방향 백링크/페이저)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user