feat(library): 자료별 손글씨 노트 (PR-D) — iPad 학습 시 옆에 필기
자료실 자료 detail 에 "필기" 버튼 → 본문 아래에 HandwriteCanvas 띄움.
자료당 사용자별 1개 캔버스 (UNIQUE user×document). upsert 방식.
Backend:
- migrations 177~178: document_notes (user_id, document_id, strokes_json,
canvas 크기) + UNIQUE(user_id, document_id) + 인덱스
- app/models/document_note.py: DocumentNote ORM
- app/api/document_notes.py:
· GET /api/documents/{id}/note — 단건 조회 (없으면 strokes_json=null)
· PUT /api/documents/{id}/note — upsert (PostgreSQL ON CONFLICT)
· DELETE /api/documents/{id}/note
· ownership: WHERE user_id=current_user.id (single-user 가정)
- app/main.py: document_notes_router 등록 (/api/documents prefix)
Frontend:
- routes/documents/[id]/+page.svelte:
· 자료실 자료 (category='library') 의 affordance row 에 "필기" 토글 추가
· 클릭 시 GET /note 로 strokes 로드 → HandwriteCanvas 본문 카드 아래 마운트
· 캔버스 onChange → PUT /note 자동 저장 (HandwriteCanvas 내부 3초 idle 디바운스 활용)
· 60vh / min-h-[400px] 분할. 모바일에선 본문 아래 스크롤로 자연스럽게.
- HandwriteCanvas 재사용 — sessionId prop 에 documentId 전달.
localStorage 키도 그대로 사용 (자료별로 namespacing).
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""자료별 손글씨 노트 API.
|
||||
|
||||
흐름:
|
||||
GET /api/documents/{id}/note → 단건 조회 (없으면 strokes_json=None)
|
||||
PUT /api/documents/{id}/note → upsert (strokes_json + canvas 크기)
|
||||
DELETE /api/documents/{id}/note → 노트 삭제
|
||||
|
||||
ownership:
|
||||
- documents 에 user_id 부재 (single-user). document_notes.user_id 만으로 분리.
|
||||
- GET/PUT/DELETE 모두 WHERE user_id=current_user.id AND document_id=:doc_id.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.document import Document
|
||||
from models.document_note import DocumentNote
|
||||
from models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NoteResponse(BaseModel):
|
||||
document_id: int
|
||||
strokes_json: dict[str, Any] | None
|
||||
canvas_width: int | None
|
||||
canvas_height: int | None
|
||||
schema_version: int
|
||||
updated_at: datetime | None
|
||||
created_at: datetime | None
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
strokes_json: dict[str, Any] | None = None
|
||||
canvas_width: int | None = None
|
||||
canvas_height: int | None = None
|
||||
|
||||
|
||||
async def _verify_document(session: AsyncSession, document_id: int) -> Document:
|
||||
doc = await session.get(Document, document_id)
|
||||
if doc is None or getattr(doc, "deleted_at", None) is not None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
return doc
|
||||
|
||||
|
||||
def _empty_response(document_id: int) -> NoteResponse:
|
||||
return NoteResponse(
|
||||
document_id=document_id,
|
||||
strokes_json=None,
|
||||
canvas_width=None,
|
||||
canvas_height=None,
|
||||
schema_version=1,
|
||||
updated_at=None,
|
||||
created_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _to_response(note: DocumentNote) -> NoteResponse:
|
||||
return NoteResponse(
|
||||
document_id=note.document_id,
|
||||
strokes_json=note.strokes_json,
|
||||
canvas_width=note.canvas_width,
|
||||
canvas_height=note.canvas_height,
|
||||
schema_version=note.schema_version,
|
||||
updated_at=note.updated_at,
|
||||
created_at=note.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/note", response_model=NoteResponse)
|
||||
async def get_note(
|
||||
document_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
await _verify_document(session, document_id)
|
||||
res = await session.execute(
|
||||
select(DocumentNote).where(
|
||||
DocumentNote.user_id == user.id,
|
||||
DocumentNote.document_id == document_id,
|
||||
)
|
||||
)
|
||||
note = res.scalar_one_or_none()
|
||||
if note is None:
|
||||
return _empty_response(document_id)
|
||||
return _to_response(note)
|
||||
|
||||
|
||||
@router.put("/{document_id}/note", response_model=NoteResponse)
|
||||
async def upsert_note(
|
||||
document_id: int,
|
||||
body: NoteUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""upsert — 같은 (user, document) 면 update, 없으면 insert. PostgreSQL ON CONFLICT."""
|
||||
await _verify_document(session, document_id)
|
||||
values: dict[str, Any] = {
|
||||
"user_id": user.id,
|
||||
"document_id": document_id,
|
||||
"strokes_json": body.strokes_json,
|
||||
"canvas_width": body.canvas_width,
|
||||
"canvas_height": body.canvas_height,
|
||||
}
|
||||
stmt = (
|
||||
pg_insert(DocumentNote)
|
||||
.values(**values)
|
||||
.on_conflict_do_update(
|
||||
index_elements=["user_id", "document_id"],
|
||||
set_={
|
||||
"strokes_json": body.strokes_json,
|
||||
"canvas_width": body.canvas_width,
|
||||
"canvas_height": body.canvas_height,
|
||||
"updated_at": datetime.now(),
|
||||
},
|
||||
)
|
||||
.returning(DocumentNote)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
note = result.scalar_one()
|
||||
await session.commit()
|
||||
return _to_response(note)
|
||||
|
||||
|
||||
@router.delete("/{document_id}/note", status_code=204)
|
||||
async def delete_note(
|
||||
document_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
await _verify_document(session, document_id)
|
||||
res = await session.execute(
|
||||
select(DocumentNote).where(
|
||||
DocumentNote.user_id == user.id,
|
||||
DocumentNote.document_id == document_id,
|
||||
)
|
||||
)
|
||||
note = res.scalar_one_or_none()
|
||||
if note is not None:
|
||||
await session.delete(note)
|
||||
await session.commit()
|
||||
@@ -11,6 +11,7 @@ from api.auth import router as auth_router
|
||||
from api.config import router as config_router
|
||||
from api.dashboard import router as dashboard_router
|
||||
from api.digest import router as digest_router
|
||||
from api.document_notes import router as document_notes_router
|
||||
from api.document_reads import router as document_reads_router
|
||||
from api.documents import router as documents_router
|
||||
from api.library import router as library_router
|
||||
@@ -103,6 +104,7 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
|
||||
# 회독 카운트 — /api/documents/{id}/read* 경로. documents_router 와 prefix 같아 충돌 없음.
|
||||
app.include_router(document_reads_router, prefix="/api/documents", tags=["document-reads"])
|
||||
app.include_router(document_notes_router, prefix="/api/documents", tags=["document-notes"])
|
||||
app.include_router(search_router, prefix="/api/search", tags=["search"])
|
||||
|
||||
app.include_router(memos_router, prefix="/api/memos", tags=["memos"])
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""document_notes 테이블 ORM — 자료별 손글씨 노트 (자료 1:1).
|
||||
|
||||
설계:
|
||||
- user×document UNIQUE — 자료당 사용자별 한 캔버스.
|
||||
- upsert 방식. PUT /api/documents/{id}/note 로 strokes_json 전체 갱신.
|
||||
- 회독 (document_reads, append-only log) 와 별개.
|
||||
|
||||
NOTE: documents 에 user_id 부재 (single-user). document_notes.user_id 로
|
||||
ownership. multi-user 전환 시 documents.user_id 추가 후 별도 check 필요.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class DocumentNote(Base):
|
||||
__tablename__ = "document_notes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "document_id", name="document_notes_user_id_document_id_key"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
document_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
strokes_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
canvas_width: Mapped[int | None] = mapped_column(Integer)
|
||||
canvas_height: Mapped[int | None] = mapped_column(Integer)
|
||||
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
|
||||
)
|
||||
@@ -9,11 +9,12 @@
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ExternalLink, Download, Link2, FileText } from 'lucide-svelte';
|
||||
import { ExternalLink, Download, Link2, FileText, PenLine, X } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
|
||||
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
|
||||
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
|
||||
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
|
||||
@@ -42,6 +43,35 @@
|
||||
|
||||
let docId = $derived($page.params.id);
|
||||
|
||||
// 손글씨 노트 (자료별 1:1) — "필기" 토글 시 사이드 캔버스 띄움.
|
||||
let noteOpen = $state(false);
|
||||
let noteStrokes = $state(null); // { version, strokes }
|
||||
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: [] };
|
||||
}
|
||||
noteLoaded = true;
|
||||
}
|
||||
async function saveNote(strokesJson) {
|
||||
try {
|
||||
await api(`/documents/${docId}/note`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ strokes_json: strokesJson }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('필기 저장 실패', err);
|
||||
}
|
||||
}
|
||||
async function toggleNote() {
|
||||
if (!noteOpen) await ensureNoteLoaded();
|
||||
noteOpen = !noteOpen;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
doc = await api(`/documents/${docId}`);
|
||||
@@ -153,6 +183,16 @@
|
||||
<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>
|
||||
|
||||
<!-- 뷰어 -->
|
||||
@@ -227,6 +267,19 @@
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<Card class="overflow-hidden p-0">
|
||||
<div class="h-[60vh] min-h-[400px] flex flex-col">
|
||||
<HandwriteCanvas
|
||||
sessionId={doc.id}
|
||||
initialStrokes={noteStrokes}
|
||||
onChange={(strokes) => saveNote(strokes)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽 (1/3) — editors stack -->
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 177_document_notes.sql
|
||||
-- 자료별 손글씨 노트 — 자료 학습 시 옆에 띄워놓고 필기.
|
||||
-- plan: ~/.claude/plans/scalable-chasing-stonebraker.md (PR-D)
|
||||
--
|
||||
-- 모델: 사용자×자료 1:1. UNIQUE (user_id, document_id) 로 강제.
|
||||
-- strokes_json 은 perfect-freehand input points + style.
|
||||
-- canvas_width/height 는 마지막 저장 시점의 캔버스 표시 크기 (참고용, 렌더는 자체 비율 유지).
|
||||
--
|
||||
-- 회독 (document_reads) 와 별개 — append-only log 가 아닌 upsert 방식.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_notes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
strokes_json JSONB,
|
||||
canvas_width INTEGER,
|
||||
canvas_height INTEGER,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, document_id)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 178_document_notes_idx.sql (2/2)
|
||||
-- (user_id, document_id) UNIQUE 제약이 자체 인덱스 생성하지만 명시 추가 — 조회 단순화.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_notes_user_doc
|
||||
ON document_notes (user_id, document_id);
|
||||
Reference in New Issue
Block a user