mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:03:11 +07:00
Hoàn thành bước 3.3. Interactive Workbook (The Core Viewer)
This commit is contained in:
+2
-1
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function WorkbookPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const pdfId = parseInt(params.id, 10);
|
||||
return <WorkbookViewer pdfId={pdfId} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{children}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WorkbookViewer({ pdfId }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
// DOM refs
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricElRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Library instances (dynamic imports to avoid SSR)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fabricRef = useRef<any>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fabricNSRef = useRef<any>(null); // fabric namespace object
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pdfDocRef = useRef<any>(null);
|
||||
|
||||
// Per-page annotation cache (local, not yet flushed to server)
|
||||
const localAnnotations = useRef<Record<number, object>>({});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const addedObjects = useRef<any[]>([]);
|
||||
const skipObjectTracking = useRef(false);
|
||||
|
||||
// Mutable refs kept in sync with state (used inside closures/callbacks)
|
||||
const currentToolRef = useRef<Tool>("pen");
|
||||
const penColorRef = useRef<string>("#e63946");
|
||||
const strokeWidthRef = useRef<number>(3);
|
||||
|
||||
// UI state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [mode, setViewMode] = useState<ViewMode>("pan");
|
||||
const [tool, setTool] = useState<Tool>("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<void>((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 (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 px-4">
|
||||
<p className="text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg px-4 py-3">
|
||||
{loadError}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-100">
|
||||
|
||||
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
|
||||
<header className="bg-white border-b shadow-sm sticky top-0 z-20">
|
||||
<div className="max-w-5xl mx-auto px-3 py-2 flex flex-wrap items-center gap-2">
|
||||
|
||||
{/* Back button + title */}
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
title="Back"
|
||||
className="p-1 text-gray-500 hover:text-blue-600 transition rounded"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-gray-800 truncate max-w-[130px] sm:max-w-xs">
|
||||
{pdfTitle}
|
||||
</span>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
|
||||
|
||||
{/* Page navigation */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1 || rendering || !isReady}
|
||||
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
|
||||
title="Previous page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-xs text-gray-600 px-1 whitespace-nowrap tabular-nums">
|
||||
{isReady ? `${currentPage} / ${totalPages}` : "…"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || rendering || !isReady}
|
||||
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
|
||||
title="Next page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
|
||||
|
||||
{/* Pan / Draw mode toggle */}
|
||||
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawing tools — only shown in Draw mode */}
|
||||
{mode === "draw" && (
|
||||
<>
|
||||
<div className="h-5 w-px bg-gray-200" />
|
||||
|
||||
{/* Tool buttons */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Select */}
|
||||
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</ToolBtn>
|
||||
|
||||
{/* Pen */}
|
||||
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</ToolBtn>
|
||||
|
||||
{/* Highlighter */}
|
||||
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</ToolBtn>
|
||||
|
||||
{/* Text */}
|
||||
<ToolBtn id="text" current={tool} onClick={setTool} title="Text">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12h6m-3-3v6M7 20h10a2 2 0 002-2V6a2 2 0 00-2-2H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</ToolBtn>
|
||||
</div>
|
||||
|
||||
{/* Color picker */}
|
||||
<input
|
||||
type="color"
|
||||
value={penColor}
|
||||
onChange={(e) => 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 */}
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={12}
|
||||
value={strokeWidth}
|
||||
onChange={(e) => setStrokeWidth(Number(e.target.value))}
|
||||
className="w-16 accent-blue-600"
|
||||
title={`Stroke width: ${strokeWidth}`}
|
||||
/>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200" />
|
||||
|
||||
{/* Undo */}
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
title="Undo last stroke"
|
||||
className="p-1.5 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Clear page */}
|
||||
<button
|
||||
onClick={handleClear}
|
||||
title="Clear page annotations"
|
||||
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M4 7h16M10 3h4a1 1 0 011 1v1H9V4a1 1 0 011-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save button — far right */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{saveNotice && (
|
||||
<span className="text-xs text-green-600 font-medium animate-pulse">
|
||||
✓ Saved
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isReady}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white text-xs font-semibold px-3 py-1.5 rounded-lg transition"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Viewer area ─────────────────────────────────────────────────────── */}
|
||||
<main
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-auto py-6 px-4"
|
||||
// In Draw mode: lock touch-action so all touch events go to Fabric
|
||||
style={{ touchAction: mode === "draw" ? "none" : "auto" }}
|
||||
>
|
||||
{/* Loading spinner */}
|
||||
{!isReady && !loadError && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{ display: isReady ? "block" : "none", width: "fit-content" }}
|
||||
>
|
||||
{/* PDF.js renders here */}
|
||||
<canvas ref={pdfCanvasRef} className="block shadow-lg rounded" />
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<canvas ref={fabricElRef} />
|
||||
</div>
|
||||
|
||||
{/* Bottom page controls for mobile convenience */}
|
||||
{isReady && (
|
||||
<div className="flex justify-center mt-6 gap-4">
|
||||
<button
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1 || rendering}
|
||||
className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-sm text-gray-500 tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || rendering}
|
||||
className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,4 +36,13 @@ export const api = {
|
||||
uploadPdfs: (formData: FormData) =>
|
||||
request<import("@/types").PDFUploadResult[]>("/api/pdfs/upload", { method: "POST", body: formData }),
|
||||
deletePdf: (id: number) => request<void>(`/api/pdfs/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Annotations
|
||||
getAnnotation: (pdfId: number, page: number) =>
|
||||
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`),
|
||||
upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
|
||||
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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<string, any>;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user