diff --git a/backend/app/main.py b/backend/app/main.py
index b69a375..67f9d75 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine
-from .routers import auth, pdfs
+from .routers import annotations, auth, pdfs
@asynccontextmanager
@@ -26,3 +26,4 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api")
+app.include_router(annotations.router, prefix="/api")
diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py
new file mode 100644
index 0000000..fa580e4
--- /dev/null
+++ b/backend/app/routers/annotations.py
@@ -0,0 +1,91 @@
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..dependencies import get_current_user
+from ..models import Annotation, PDF, User
+from ..schemas import AnnotationIn, AnnotationOut
+
+router = APIRouter(prefix="/annotations", tags=["annotations"])
+
+
+def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF:
+ pdf = db.get(PDF, pdf_id)
+ if not pdf or pdf.user_id != user_id:
+ raise HTTPException(status_code=404, detail="PDF not found.")
+ return pdf
+
+
+# ── GET /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
+
+@router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut)
+def get_annotation(
+ pdf_id: int,
+ page_number: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ _verify_pdf_ownership(db, pdf_id, current_user.id)
+
+ ann = (
+ db.query(Annotation)
+ .filter(
+ Annotation.pdf_id == pdf_id,
+ Annotation.user_id == current_user.id,
+ Annotation.page_number == page_number,
+ )
+ .first()
+ )
+
+ # Return empty canvas if no annotation exists yet — not an error
+ if ann is None:
+ return AnnotationOut(
+ id=0,
+ pdf_id=pdf_id,
+ page_number=page_number,
+ canvas_data={},
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ return ann
+
+
+# ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
+
+@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
+def upsert_annotation(
+ pdf_id: int,
+ page_number: int,
+ body: AnnotationIn,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ _verify_pdf_ownership(db, pdf_id, current_user.id)
+
+ ann = (
+ db.query(Annotation)
+ .filter(
+ Annotation.pdf_id == pdf_id,
+ Annotation.user_id == current_user.id,
+ Annotation.page_number == page_number,
+ )
+ .first()
+ )
+
+ if ann is None:
+ ann = Annotation(
+ pdf_id=pdf_id,
+ user_id=current_user.id,
+ page_number=page_number,
+ canvas_data=body.canvas_data,
+ )
+ db.add(ann)
+ else:
+ ann.canvas_data = body.canvas_data
+ ann.updated_at = datetime.now(timezone.utc)
+
+ db.commit()
+ db.refresh(ann)
+ return ann
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 7fa4647..6eea75a 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -63,3 +63,19 @@ class PDFUploadResult(BaseModel):
success: bool
pdf: PDFOut | None = None
error: str | None = None
+
+
+# ── Annotations ───────────────────────────────────────────────────────────────
+
+class AnnotationIn(BaseModel):
+ canvas_data: dict
+
+
+class AnnotationOut(BaseModel):
+ id: int
+ pdf_id: int
+ page_number: int
+ canvas_data: dict
+ updated_at: datetime
+
+ model_config = {"from_attributes": True}
diff --git a/frontend/package.json b/frontend/package.json
index 32d1746..0f9fca4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,7 +10,9 @@
"dependencies": {
"next": "14.2.18",
"react": "^18",
- "react-dom": "^18"
+ "react-dom": "^18",
+ "fabric": "^5.3.0",
+ "pdfjs-dist": "^4.9.124"
},
"devDependencies": {
"@types/node": "^20",
diff --git a/frontend/src/app/workbook/[id]/page.tsx b/frontend/src/app/workbook/[id]/page.tsx
new file mode 100644
index 0000000..d0a3f75
--- /dev/null
+++ b/frontend/src/app/workbook/[id]/page.tsx
@@ -0,0 +1,23 @@
+import dynamic from "next/dynamic";
+
+// Disable SSR — WorkbookViewer uses pdfjs-dist and fabric which require browser APIs
+const WorkbookViewer = dynamic(() => import("@/components/WorkbookViewer"), {
+ ssr: false,
+ loading: () => (
+
+ ),
+});
+
+export default function WorkbookPage({
+ params,
+}: {
+ params: { id: string };
+}) {
+ const pdfId = parseInt(params.id, 10);
+ return ;
+}
diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx
new file mode 100644
index 0000000..7a6cbb1
--- /dev/null
+++ b/frontend/src/components/WorkbookViewer.tsx
@@ -0,0 +1,671 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { api } from "@/lib/api";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Types
+// ─────────────────────────────────────────────────────────────────────────────
+
+type Tool = "select" | "pen" | "highlighter" | "text";
+type ViewMode = "pan" | "draw";
+
+interface Props {
+ pdfId: number;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+function hexToRgba(hex: string, alpha: number): string {
+ let h = hex.replace("#", "");
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
+ const r = parseInt(h.substring(0, 2), 16);
+ const g = parseInt(h.substring(2, 4), 16);
+ const b = parseInt(h.substring(4, 6), 16);
+ return `rgba(${r},${g},${b},${alpha})`;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Toolbar button
+// ─────────────────────────────────────────────────────────────────────────────
+
+function ToolBtn({
+ id,
+ current,
+ onClick,
+ title,
+ children,
+}: {
+ id: Tool;
+ current: Tool;
+ onClick: (t: Tool) => void;
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+ onClick(id)}
+ title={title}
+ className={`p-1.5 rounded-lg transition ${
+ current === id
+ ? "bg-blue-600 text-white shadow"
+ : "text-gray-500 hover:bg-gray-100 hover:text-gray-800"
+ }`}
+ >
+
+ {children}
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Main component
+// ─────────────────────────────────────────────────────────────────────────────
+
+export default function WorkbookViewer({ pdfId }: Props) {
+ const router = useRouter();
+
+ // DOM refs
+ const scrollContainerRef = useRef(null);
+ const pdfCanvasRef = useRef(null);
+ const fabricElRef = useRef(null);
+
+ // Library instances (dynamic imports to avoid SSR)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fabricRef = useRef(null);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fabricNSRef = useRef(null); // fabric namespace object
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const pdfDocRef = useRef(null);
+
+ // Per-page annotation cache (local, not yet flushed to server)
+ const localAnnotations = useRef>({});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const addedObjects = useRef([]);
+ const skipObjectTracking = useRef(false);
+
+ // Mutable refs kept in sync with state (used inside closures/callbacks)
+ const currentToolRef = useRef("pen");
+ const penColorRef = useRef("#e63946");
+ const strokeWidthRef = useRef(3);
+
+ // UI state
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(0);
+ const [mode, setViewMode] = useState("pan");
+ const [tool, setTool] = useState("pen");
+ const [penColor, setPenColor] = useState("#e63946");
+ const [strokeWidth, setStrokeWidth] = useState(3);
+ const [saving, setSaving] = useState(false);
+ const [saveNotice, setSaveNotice] = useState(false);
+ const [isReady, setIsReady] = useState(false);
+ const [pdfTitle, setPdfTitle] = useState("PDF");
+ const [loadError, setLoadError] = useState("");
+ const [rendering, setRendering] = useState(false);
+
+ // Keep mutable refs in sync
+ useEffect(() => { currentToolRef.current = tool; }, [tool]);
+ useEffect(() => { penColorRef.current = penColor; }, [penColor]);
+ useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // renderPageWithAnnotations
+ // ─────────────────────────────────────────────────────────────────────────
+
+ const renderPageWithAnnotations = useCallback(
+ async (pageNum: number) => {
+ const fc = fabricRef.current;
+ const doc = pdfDocRef.current;
+ if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return;
+
+ setRendering(true);
+ try {
+ // ── 1. Render PDF page ─────────────────────────────────────────────
+ const page = await doc.getPage(pageNum);
+ const containerWidth = Math.max(scrollContainerRef.current.clientWidth - 32, 300);
+ const viewport1 = page.getViewport({ scale: 1 });
+ const scale = containerWidth / viewport1.width;
+ const viewport = page.getViewport({ scale });
+
+ const pdfCvs = pdfCanvasRef.current;
+ pdfCvs.width = Math.floor(viewport.width);
+ pdfCvs.height = Math.floor(viewport.height);
+
+ await page.render({
+ canvasContext: pdfCvs.getContext("2d")!,
+ viewport,
+ }).promise;
+
+ // ── 2. Resize Fabric canvas to match ──────────────────────────────
+ fc.setWidth(pdfCvs.width);
+ fc.setHeight(pdfCvs.height);
+ if (fc.wrapperEl) {
+ fc.wrapperEl.style.width = `${pdfCvs.width}px`;
+ fc.wrapperEl.style.height = `${pdfCvs.height}px`;
+ }
+
+ // ── 3. Load annotation data ───────────────────────────────────────
+ const cached = localAnnotations.current[pageNum];
+ let annotationData: object | null = cached ?? null;
+
+ if (!annotationData) {
+ try {
+ const ann = await api.getAnnotation(pdfId, pageNum);
+ if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) {
+ annotationData = ann.canvas_data;
+ localAnnotations.current[pageNum] = annotationData;
+ }
+ } catch { /* no annotation yet */ }
+ }
+
+ // Clear and load
+ skipObjectTracking.current = true;
+ fc.clear();
+ addedObjects.current = [];
+
+ if (annotationData) {
+ await new Promise((resolve) => {
+ fc.loadFromJSON(annotationData, () => {
+ fc.renderAll();
+ skipObjectTracking.current = false;
+ resolve();
+ });
+ });
+ } else {
+ fc.renderAll();
+ skipObjectTracking.current = false;
+ }
+ } finally {
+ setRendering(false);
+ }
+ },
+ [pdfId]
+ );
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Initialization (mount)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function init() {
+ try {
+ // Fetch PDF title
+ try {
+ const pdfs = await api.listPdfs();
+ const found = pdfs.find((p) => p.id === pdfId);
+ if (found) setPdfTitle(found.title);
+ } catch { /* ignore */ }
+
+ if (cancelled || !fabricElRef.current) return;
+
+ // ── Init Fabric.js ────────────────────────────────────────────────
+ const fabricModule = await import("fabric");
+ const fabric = fabricModule.fabric;
+ fabricNSRef.current = fabric;
+
+ if (cancelled) return;
+
+ const fc = new fabric.Canvas(fabricElRef.current, {
+ isDrawingMode: false,
+ selection: false,
+ enableRetinaScaling: false,
+ });
+ fabricRef.current = fc;
+
+ // Position Fabric wrapper absolutely over PDF canvas
+ if (fc.wrapperEl) {
+ fc.wrapperEl.style.position = "absolute";
+ fc.wrapperEl.style.top = "0";
+ fc.wrapperEl.style.left = "0";
+ fc.wrapperEl.style.pointerEvents = "none"; // start in pan mode
+ }
+
+ // Track added objects for undo (skip objects loaded from JSON)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fc.on("object:added", (e: any) => {
+ if (!skipObjectTracking.current) {
+ addedObjects.current.push(e.target);
+ }
+ });
+
+ // Highlighter: reduce opacity of drawn path after creation
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fc.on("path:created", (options: any) => {
+ if (currentToolRef.current === "highlighter") {
+ options.path.set({ opacity: 0.42 });
+ fc.renderAll();
+ }
+ });
+
+ // ── Init PDF.js ───────────────────────────────────────────────────
+ const pdfjs = await import("pdfjs-dist");
+ pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
+
+ if (cancelled) return;
+
+ const doc = await pdfjs
+ .getDocument({ url: `/api/pdfs/${pdfId}/file`, withCredentials: true })
+ .promise;
+
+ if (cancelled) { doc.destroy(); return; }
+
+ pdfDocRef.current = doc;
+ setTotalPages(doc.numPages);
+ setIsReady(true);
+
+ // ── Render first page ─────────────────────────────────────────────
+ await renderPageWithAnnotations(1);
+ setCurrentPage(1);
+
+ } catch (err: unknown) {
+ if (!cancelled) {
+ setLoadError(
+ err instanceof Error ? err.message : "Failed to load PDF."
+ );
+ }
+ }
+ }
+
+ init();
+
+ return () => {
+ cancelled = true;
+ fabricRef.current?.dispose();
+ fabricRef.current = null;
+ pdfDocRef.current?.destroy();
+ pdfDocRef.current = null;
+ };
+ }, [pdfId, renderPageWithAnnotations]);
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Apply tool / mode to Fabric canvas
+ // ─────────────────────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ const fc = fabricRef.current;
+ const fabric = fabricNSRef.current;
+ if (!isReady || !fc || !fabric) return;
+
+ // Clean up previous mouse:down handler (added for text tool)
+ fc.off("mouse:down");
+
+ if (mode === "pan") {
+ fc.isDrawingMode = false;
+ fc.selection = false;
+ if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "none";
+ return;
+ }
+
+ // Draw mode — enable pointer events
+ if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "all";
+
+ switch (tool) {
+ case "select":
+ fc.isDrawingMode = false;
+ fc.selection = true;
+ break;
+
+ case "pen": {
+ fc.isDrawingMode = true;
+ fc.selection = false;
+ const brush = new fabric.PencilBrush(fc);
+ brush.color = penColor;
+ brush.width = strokeWidth;
+ fc.freeDrawingBrush = brush;
+ break;
+ }
+
+ case "highlighter": {
+ fc.isDrawingMode = true;
+ fc.selection = false;
+ const hBrush = new fabric.PencilBrush(fc);
+ // Full-opacity color during drawing; path:created handler applies opacity:0.42
+ hBrush.color = hexToRgba(penColor, 0.99);
+ hBrush.width = strokeWidth * 7;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (hBrush as any).strokeLineCap = "square";
+ fc.freeDrawingBrush = hBrush;
+ break;
+ }
+
+ case "text":
+ fc.isDrawingMode = false;
+ fc.selection = false;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fc.on("mouse:down", (options: any) => {
+ if (options.target) return; // clicked existing object → let Fabric handle it
+ const pointer = fc.getPointer(options.e);
+ const textObj = new fabric.IText("Text", {
+ left: pointer.x,
+ top: pointer.y,
+ fontSize: 20,
+ fill: penColorRef.current,
+ fontFamily: "Arial, sans-serif",
+ padding: 4,
+ });
+ fc.add(textObj);
+ fc.setActiveObject(textObj);
+ textObj.enterEditing();
+ textObj.selectAll();
+ fc.renderAll();
+ });
+ break;
+ }
+ }, [isReady, mode, tool, penColor, strokeWidth]);
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Page navigation
+ // ─────────────────────────────────────────────────────────────────────────
+
+ const goToPage = useCallback(
+ async (newPage: number) => {
+ const fc = fabricRef.current;
+ if (!fc || newPage < 1 || newPage > totalPages || rendering) return;
+
+ // Cache current page before leaving
+ localAnnotations.current[currentPage] = fc.toJSON();
+
+ await renderPageWithAnnotations(newPage);
+ setCurrentPage(newPage);
+ },
+ [currentPage, totalPages, rendering, renderPageWithAnnotations]
+ );
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Save
+ // ─────────────────────────────────────────────────────────────────────────
+
+ const handleSave = useCallback(async () => {
+ const fc = fabricRef.current;
+ if (!fc || !isReady || saving) return;
+
+ setSaving(true);
+ const canvasData = fc.toJSON();
+
+ try {
+ await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData });
+ localAnnotations.current[currentPage] = canvasData;
+ setSaveNotice(true);
+ setTimeout(() => setSaveNotice(false), 2500);
+ } catch (err: unknown) {
+ alert(err instanceof Error ? err.message : "Save failed.");
+ } finally {
+ setSaving(false);
+ }
+ }, [pdfId, currentPage, isReady, saving]);
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Undo / Clear
+ // ─────────────────────────────────────────────────────────────────────────
+
+ const handleUndo = useCallback(() => {
+ const fc = fabricRef.current;
+ if (!fc || addedObjects.current.length === 0) return;
+ const last = addedObjects.current.pop();
+ if (last) { fc.remove(last); fc.renderAll(); }
+ }, []);
+
+ const handleClear = useCallback(() => {
+ const fc = fabricRef.current;
+ if (!fc) return;
+ if (!confirm("Clear all annotations on this page?")) return;
+ fc.clear();
+ addedObjects.current = [];
+ fc.renderAll();
+ }, []);
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Render
+ // ─────────────────────────────────────────────────────────────────────────
+
+ if (loadError) {
+ return (
+
+
+ {loadError}
+
+
router.push("/dashboard")}
+ className="text-blue-600 hover:underline text-sm"
+ >
+ ← Back to Dashboard
+
+
+ );
+ }
+
+ return (
+
+
+ {/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
+
+
+
+ {/* Back button + title */}
+
router.push("/dashboard")}
+ title="Back"
+ className="p-1 text-gray-500 hover:text-blue-600 transition rounded"
+ >
+
+
+
+
+
+ {pdfTitle}
+
+
+
+
+ {/* Page navigation */}
+
+
goToPage(currentPage - 1)}
+ disabled={currentPage <= 1 || rendering || !isReady}
+ className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
+ title="Previous page"
+ >
+
+
+
+
+
+ {isReady ? `${currentPage} / ${totalPages}` : "…"}
+
+
goToPage(currentPage + 1)}
+ disabled={currentPage >= totalPages || rendering || !isReady}
+ className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
+ title="Next page"
+ >
+
+
+
+
+
+
+
+
+ {/* Pan / Draw mode toggle */}
+
+ setViewMode("pan")}
+ className={`text-xs px-2.5 py-1 rounded-md font-medium transition ${
+ mode === "pan" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
+ }`}
+ >
+ Pan
+
+ setViewMode("draw")}
+ className={`text-xs px-2.5 py-1 rounded-md font-medium transition ${
+ mode === "draw" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
+ }`}
+ >
+ Draw
+
+
+
+ {/* Drawing tools — only shown in Draw mode */}
+ {mode === "draw" && (
+ <>
+
+
+ {/* Tool buttons */}
+
+ {/* Select */}
+
+
+
+
+ {/* Pen */}
+
+
+
+
+ {/* Highlighter */}
+
+
+
+
+ {/* Text */}
+
+
+
+
+
+ {/* Color picker */}
+
setPenColor(e.target.value)}
+ title="Color"
+ className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white"
+ />
+
+ {/* Stroke width slider */}
+
setStrokeWidth(Number(e.target.value))}
+ className="w-16 accent-blue-600"
+ title={`Stroke width: ${strokeWidth}`}
+ />
+
+
+
+ {/* Undo */}
+
+
+
+
+
+
+ {/* Clear page */}
+
+
+
+
+
+ >
+ )}
+
+ {/* Save button — far right */}
+
+ {saveNotice && (
+
+ ✓ Saved
+
+ )}
+
+ {saving ? "Saving…" : "Save"}
+
+
+
+
+
+ {/* ── Viewer area ─────────────────────────────────────────────────────── */}
+
+ {/* Loading spinner */}
+ {!isReady && !loadError && (
+
+ )}
+
+ {/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
+
+ {/* PDF.js renders here */}
+
+
+ {/*
+ Fabric.js is initialised on this element.
+ After init, Fabric wraps it in a div (wrapperEl) that our useEffect
+ repositions to position:absolute / top:0 / left:0 — sitting above the PDF.
+ */}
+
+
+
+ {/* Bottom page controls for mobile convenience */}
+ {isReady && (
+
+ goToPage(currentPage - 1)}
+ disabled={currentPage <= 1 || rendering}
+ className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
+ >
+ ← Prev
+
+
+ {currentPage} / {totalPages}
+
+ goToPage(currentPage + 1)}
+ disabled={currentPage >= totalPages || rendering}
+ className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
+ >
+ Next →
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 8a452e3..9af923b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -36,4 +36,13 @@ export const api = {
uploadPdfs: (formData: FormData) =>
request("/api/pdfs/upload", { method: "POST", body: formData }),
deletePdf: (id: number) => request(`/api/pdfs/${id}`, { method: "DELETE" }),
+
+ // Annotations
+ getAnnotation: (pdfId: number, page: number) =>
+ request(`/api/annotations/${pdfId}/${page}`),
+ upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
+ request(`/api/annotations/${pdfId}/${page}`, {
+ method: "PUT",
+ body: JSON.stringify(body),
+ }),
};
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 6babb8f..6ee2241 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -18,3 +18,12 @@ export interface PDFUploadResult {
pdf: PDFItem | null;
error: string | null;
}
+
+export interface AnnotationData {
+ id: number;
+ pdf_id: number;
+ page_number: number;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ canvas_data: Record;
+ updated_at: string;
+}