feat(book): 공부도구 배선 — 노트/형광펜/암기카드(clause_study) + 책 리더 패널
This commit is contained in:
@@ -2048,3 +2048,91 @@ async def get_related_documents(
|
||||
doc_id=doc_id,
|
||||
related=[RelatedItem(**{k: r[k] for k in ("id", "title", "ai_domain", "material_type", "year")}, sim=float(r["sim"]) if r["sim"] is not None else None) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
# ─── 절 공부도구 (노트/형광펜/암기카드) — clause_study ───
|
||||
class StudyItem(BaseModel):
|
||||
id: int
|
||||
kind: str
|
||||
payload: dict = {}
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class StudyListResponse(BaseModel):
|
||||
doc_id: int
|
||||
items: list[StudyItem]
|
||||
|
||||
|
||||
class StudyCreate(BaseModel):
|
||||
kind: str # note | highlight | card
|
||||
payload: dict = {}
|
||||
|
||||
|
||||
def _parse_payload(p):
|
||||
import json
|
||||
if isinstance(p, str):
|
||||
try:
|
||||
return json.loads(p)
|
||||
except Exception:
|
||||
return {}
|
||||
return p or {}
|
||||
|
||||
|
||||
@router.get("/{doc_id}/study", response_model=StudyListResponse)
|
||||
async def list_study(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""절-문서의 공부도구 항목(노트/형광펜/암기카드) 목록."""
|
||||
from sqlalchemy import text as sql_text
|
||||
rows = (
|
||||
await session.execute(
|
||||
sql_text("SELECT id, kind, payload, created_at FROM clause_study "
|
||||
"WHERE doc_id = :id ORDER BY created_at DESC").bindparams(id=doc_id)
|
||||
)
|
||||
).mappings().all()
|
||||
return StudyListResponse(
|
||||
doc_id=doc_id,
|
||||
items=[StudyItem(id=r["id"], kind=r["kind"], payload=_parse_payload(r["payload"]),
|
||||
created_at=r["created_at"]) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{doc_id}/study", response_model=StudyItem, status_code=201)
|
||||
async def add_study(
|
||||
doc_id: int,
|
||||
body: StudyCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""노트/형광펜/암기카드 1건 추가."""
|
||||
import json
|
||||
from sqlalchemy import text as sql_text
|
||||
if body.kind not in ("note", "highlight", "card"):
|
||||
raise HTTPException(status_code=400, detail="kind 는 note/highlight/card")
|
||||
row = (
|
||||
await session.execute(
|
||||
sql_text("INSERT INTO clause_study(doc_id, kind, payload) "
|
||||
"VALUES (:d, :k, cast(:p AS jsonb)) RETURNING id, kind, payload, created_at")
|
||||
.bindparams(d=doc_id, k=body.kind, p=json.dumps(body.payload, ensure_ascii=False))
|
||||
)
|
||||
).mappings().first()
|
||||
await session.commit()
|
||||
return StudyItem(id=row["id"], kind=row["kind"], payload=_parse_payload(row["payload"]),
|
||||
created_at=row["created_at"])
|
||||
|
||||
|
||||
@router.delete("/{doc_id}/study/{study_id}", status_code=204)
|
||||
async def delete_study(
|
||||
doc_id: int,
|
||||
study_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
from sqlalchemy import text as sql_text
|
||||
await session.execute(
|
||||
sql_text("DELETE FROM clause_study WHERE id = :s AND doc_id = :d")
|
||||
.bindparams(s=study_id, d=doc_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -17,6 +17,33 @@
|
||||
let loading = $state(false);
|
||||
let q = $state('');
|
||||
|
||||
// 공부도구 (노트/형광펜/암기카드) — clause_study
|
||||
let studyItems = $state([]);
|
||||
let studyOpen = $state(false);
|
||||
let noteDraft = $state('');
|
||||
const KLABEL = { note: '노트', highlight: '형광펜', card: '암기카드' };
|
||||
async function loadStudy(id) {
|
||||
try { const r = await api(`/documents/${id}/study`); studyItems = r?.items ?? []; }
|
||||
catch { studyItems = []; }
|
||||
}
|
||||
async function addStudy(kind, payload) {
|
||||
if (!selectedId) return;
|
||||
try { await api(`/documents/${selectedId}/study`, { method: 'POST', body: JSON.stringify({ kind, payload }) }); await loadStudy(selectedId); }
|
||||
catch (e) { console.warn(e); }
|
||||
}
|
||||
function selText() { return (typeof window !== 'undefined' && window.getSelection ? window.getSelection().toString() : '').trim(); }
|
||||
function addNote() { const t = noteDraft.trim(); if (!t) return; addStudy('note', { text: t }); noteDraft = ''; }
|
||||
function addHighlight() { const s = selText(); if (!s) { studyOpen = true; alert('본문에서 형광펜 칠할 부분을 먼저 드래그하세요'); return; } addStudy('highlight', { text: s }); studyOpen = true; }
|
||||
function addCard() {
|
||||
const s = selText();
|
||||
const code = links?.clause_code ?? selMeta?.clause_code ?? '';
|
||||
addStudy('card', { cue: `${code} ${strip(clauseDoc?.title, code)}`.trim(), fact: s || (clauseDoc?.md_content ?? clauseDoc?.extracted_text ?? '').replace(/[#*>]/g, '').slice(0, 280).trim() });
|
||||
studyOpen = true;
|
||||
}
|
||||
async function delStudy(id) {
|
||||
try { await api(`/documents/${selectedId}/study/${id}`, { method: 'DELETE' }); await loadStudy(selectedId); } catch {}
|
||||
}
|
||||
|
||||
let parts = $derived.by(() => {
|
||||
const out = [], idx = {};
|
||||
for (const c of clauses) {
|
||||
@@ -51,6 +78,7 @@
|
||||
try {
|
||||
const [d, l] = await Promise.all([api(`/documents/${id}`), api(`/documents/${id}/backlinks`)]);
|
||||
clauseDoc = d; links = l;
|
||||
loadStudy(id);
|
||||
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 });
|
||||
@@ -98,9 +126,10 @@
|
||||
<div class="col">
|
||||
{#if clauseDoc}
|
||||
<div class="studybar">
|
||||
<button class="sbtn" title="형광펜">▰</button>
|
||||
<button class="sbtn" title="노트">✎</button>
|
||||
<button class="sbtn" title="암기카드 추가">+</button>
|
||||
<button class="sbtn" title="선택 형광펜" onclick={addHighlight}>▰</button>
|
||||
<button class="sbtn" class:on={studyOpen} title="노트/공부" onclick={() => (studyOpen = !studyOpen)}>✎</button>
|
||||
<button class="sbtn" title="암기카드 추가" onclick={addCard}>+</button>
|
||||
{#if studyItems.length}<span class="scount">{studyItems.length}</span>{/if}
|
||||
</div>
|
||||
<div class="kicker"><span class="pth">{selMeta?.clause_part}</span></div>
|
||||
<div class="h-no">{links?.clause_code ?? selMeta?.clause_code}</div>
|
||||
@@ -133,6 +162,29 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if studyOpen}
|
||||
<section class="study">
|
||||
<div class="slab">공부 — 노트 · 형광펜 · 암기카드{#if studyItems.length} <span>{studyItems.length}</span>{/if}</div>
|
||||
<div class="noteadd">
|
||||
<textarea bind:value={noteDraft} placeholder="이 절에 노트…" rows="2"></textarea>
|
||||
<button class="nbtn" onclick={addNote}>노트 저장</button>
|
||||
</div>
|
||||
{#if studyItems.length}
|
||||
<ul class="slist">
|
||||
{#each studyItems as it (it.id)}
|
||||
<li class="sitem">
|
||||
<span class="skind k-{it.kind}">{KLABEL[it.kind] ?? it.kind}</span>
|
||||
<span class="stext">{it.payload?.text ?? it.payload?.cue ?? ''}</span>
|
||||
<button class="sdel" title="삭제" onclick={() => delStudy(it.id)}>×</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="shint">본문을 드래그한 뒤 형광펜(▰)/암기카드(+), 또는 위에 노트를 적으세요.</p>
|
||||
{/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>
|
||||
@@ -205,5 +257,25 @@
|
||||
.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} }
|
||||
.sbtn.on { background:#ecf0e8; color:var(--accent-hover,#3d7256); border-color:var(--border); }
|
||||
.scount { font-size:9px; font-weight:700; color:#fff; background:var(--accent,#4f8a6b); border-radius:8px; padding:1px 5px; text-align:center; }
|
||||
.study { margin-top:24px; padding:14px; border:1px solid var(--border); border-radius:12px; background:var(--surface); }
|
||||
.slab { font-size:11px; font-weight:700; color:var(--text-dim); letter-spacing:.3px; margin-bottom:9px; }
|
||||
.slab span { color:var(--accent-hover,#3d7256); }
|
||||
.noteadd { display:flex; gap:8px; align-items:flex-end; margin-bottom:10px; }
|
||||
.noteadd textarea { flex:1; resize:vertical; border:1px solid var(--border); border-radius:8px; padding:7px 9px; font-size:12.5px; font-family:inherit; color:var(--text); background:var(--paper,#fbfcf9); outline:none; }
|
||||
.noteadd textarea:focus { border-color:var(--accent); }
|
||||
.nbtn { flex-shrink:0; font-size:12px; color:#fff; background:var(--accent,#4f8a6b); border:0; border-radius:8px; padding:8px 12px; cursor:pointer; }
|
||||
.nbtn:hover { background:var(--accent-hover,#3d7256); }
|
||||
.slist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:5px; }
|
||||
.sitem { display:flex; align-items:baseline; gap:8px; padding:6px 8px; border-radius:8px; background:var(--paper,#fbfcf9); border:1px solid var(--border); }
|
||||
.skind { flex-shrink:0; font-size:9.5px; font-weight:700; border-radius:4px; padding:1px 6px; }
|
||||
.k-note { color:#3d7256; background:#e3efe2; border:1px solid #cfe3cd; }
|
||||
.k-highlight { color:#8a6306; background:#faf3e2; border:1px solid #ecdca3; }
|
||||
.k-card { color:#1d4ed8; background:#eef4fc; border:1px solid #d7e4f7; }
|
||||
.stext { flex:1; font-size:12px; line-height:1.5; color:var(--text); white-space:pre-wrap; word-break:break-word; }
|
||||
.sdel { flex-shrink:0; background:none; border:0; color:var(--faint,#9aa090); cursor:pointer; font-size:14px; }
|
||||
.sdel:hover { color:var(--error,#c0392b); }
|
||||
.shint { font-size:11.5px; color:var(--faint,#9aa090); margin:0; }
|
||||
@media(max-width:820px){ .idx{display:none} .read{padding:24px 18px} .conn{grid-template-columns:1fr} .studybar{position:static;flex-direction:row} .crumbs{max-width:30%} .search input{width:150px} }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 380_clause_study.sql — 절-문서 공부도구(노트/형광펜/암기카드) 저장. FK 없음(documents 락 회피).
|
||||
CREATE TABLE IF NOT EXISTS clause_study (
|
||||
id bigserial PRIMARY KEY,
|
||||
doc_id bigint NOT NULL,
|
||||
kind text NOT NULL, -- 'note' | 'highlight' | 'card'
|
||||
payload jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clause_study_doc ON clause_study(doc_id, kind);
|
||||
Reference in New Issue
Block a user