From 79eea344e99ee7bd883e856194e90b448dabb47c Mon Sep 17 00:00:00 2001 From: hienp Date: Wed, 1 Apr 2026 08:46:58 +0700 Subject: [PATCH] =?UTF-8?q?c=C3=A1c=20ch=E1=BB=A9c=20n=C4=83ng=20thumbnail?= =?UTF-8?q?,=20scroll=20continuos=20=C4=91=C3=A3=20xong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/pdfs.py | 4 +- frontend/src/components/UploadZone.tsx | 2 +- frontend/src/components/WorkbookViewer.tsx | 465 +++++++++++++++++++-- start-dev.sh | 133 ++++++ 4 files changed, 555 insertions(+), 49 deletions(-) create mode 100755 start-dev.sh diff --git a/backend/app/routers/pdfs.py b/backend/app/routers/pdfs.py index 82fa67d..10d0801 100644 --- a/backend/app/routers/pdfs.py +++ b/backend/app/routers/pdfs.py @@ -14,7 +14,7 @@ from ..schemas import PDFOut, PDFUploadResult router = APIRouter(prefix="/pdfs", tags=["pdfs"]) UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads")) -MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB +MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB def _user_dir(user_id: int) -> Path: @@ -71,7 +71,7 @@ async def upload_pdfs( # ── Size guard ──────────────────────────────────────────────────────── body = header + await file.read() if len(body) > MAX_FILE_SIZE: - results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File exceeds 50 MB limit.")) + results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File exceeds 500 MB limit.")) continue # ── Save to disk with UUID filename (prevents path traversal) ───────── diff --git a/frontend/src/components/UploadZone.tsx b/frontend/src/components/UploadZone.tsx index 4b5ed45..0e73d37 100644 --- a/frontend/src/components/UploadZone.tsx +++ b/frontend/src/components/UploadZone.tsx @@ -102,7 +102,7 @@ export default function UploadZone({ onUploaded }: Props) { Drag & drop PDFs here, or{" "} click to browse

-

Multiple files supported · Max 50 MB each

+

Multiple files supported · Max 500 MB each

)} diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx index 7a6cbb1..656f4f0 100644 --- a/frontend/src/components/WorkbookViewer.tsx +++ b/frontend/src/components/WorkbookViewer.tsx @@ -10,6 +10,8 @@ import { api } from "@/lib/api"; type Tool = "select" | "pen" | "highlighter" | "text"; type ViewMode = "pan" | "draw"; +type FitMode = "width" | "height"; +type ScrollMode = "single" | "continuous"; interface Props { pdfId: number; @@ -106,11 +108,59 @@ export default function WorkbookViewer({ pdfId }: Props) { const [pdfTitle, setPdfTitle] = useState("PDF"); const [loadError, setLoadError] = useState(""); const [rendering, setRendering] = useState(false); + const [showThumbnails, setShowThumbnails] = useState(true); + const [thumbnails, setThumbnails] = useState([]); + const [fitMode, setFitMode] = useState("width"); + const [scrollMode, setScrollMode] = useState("single"); + + // Ref for auto-scrolling thumbnail panel + const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]); + const fitModeRef = useRef("width"); + const scrollModeRef = useRef("single"); + // Per-page canvas refs for continuous mode + const pageCanvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); + const pageFabricRefs = useRef([]); + // Active PDF.js render tasks — cancel before re-rendering + const renderTasksRef = useRef([]); + // Generation counter — incremented on each renderAllPages call to abort stale runs + const renderAllPagesGenRef = useRef(0); // Keep mutable refs in sync useEffect(() => { currentToolRef.current = tool; }, [tool]); useEffect(() => { penColorRef.current = penColor; }, [penColor]); useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]); + useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]); + useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]); + + // Auto-scroll thumbnail sidebar to keep current page visible + useEffect(() => { + if (showThumbnails) { + thumbRefs.current[currentPage - 1]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [currentPage, showThumbnails]); + + // Re-render current page when fit mode changes — moved below renderAllPages definition + + const generateThumbnails = useCallback(async () => { + const doc = pdfDocRef.current; + if (!doc) return; + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const vp1 = page.getViewport({ scale: 1 }); + const scale = 120 / vp1.width; + const vp = page.getViewport({ scale }); + const cvs = document.createElement("canvas"); + cvs.width = Math.floor(vp.width); + cvs.height = Math.floor(vp.height); + await page.render({ canvasContext: cvs.getContext("2d")!, viewport: vp }).promise; + const dataUrl = cvs.toDataURL("image/jpeg", 0.7); + setThumbnails(prev => { + const next = [...prev]; + next[i - 1] = dataUrl; + return next; + }); + } + }, []); // ───────────────────────────────────────────────────────────────────────── // renderPageWithAnnotations @@ -124,23 +174,34 @@ export default function WorkbookViewer({ pdfId }: Props) { setRendering(true); try { + // Cancel any in-progress render on the same canvas + renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } }); + renderTasksRef.current = []; + // ── 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; + let scale: number; + if (fitModeRef.current === "height") { + const containerHeight = Math.max(scrollContainerRef.current.clientHeight - 48, 400); + scale = containerHeight / viewport1.height; + } else { + const containerWidth = Math.max(scrollContainerRef.current.clientWidth - 32, 300); + 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({ + const singleRenderTask = page.render({ canvasContext: pdfCvs.getContext("2d")!, viewport, - }).promise; - - // ── 2. Resize Fabric canvas to match ────────────────────────────── + }); + renderTasksRef.current.push(singleRenderTask); + await singleRenderTask.promise; + // ── 2. Resize Fabric canvas to match fc.setWidth(pdfCvs.width); fc.setHeight(pdfCvs.height); if (fc.wrapperEl) { @@ -186,6 +247,105 @@ export default function WorkbookViewer({ pdfId }: Props) { [pdfId] ); + // ───────────────────────────────────────────────────────────────────────── + // renderAllPages (continuous scroll mode) + // ───────────────────────────────────────────────────────────────────────── + + const renderAllPages = useCallback(async () => { + const gen = ++renderAllPagesGenRef.current; + const doc = pdfDocRef.current; + const container = scrollContainerRef.current; + if (!doc || !container) return; + + // Dispose old per-page fabric instances + pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } }); + pageFabricRefs.current = []; + + // Cancel any in-progress render tasks + renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } }); + renderTasksRef.current = []; + + const fabricModule = await import("fabric"); + if (renderAllPagesGenRef.current !== gen) return; + const fabric = fabricModule.fabric; + const containerWidth = Math.max(container.clientWidth - 32, 300); + + for (let i = 1; i <= doc.numPages; i++) { + if (renderAllPagesGenRef.current !== gen) return; + const pdfCvs = pageCanvasRefs.current[i - 1]; + const fabricEl = document.getElementById(`fabric-continuous-${i}`) as HTMLCanvasElement | null; + if (!pdfCvs || !fabricEl) continue; + + const page = await doc.getPage(i); + const vp1 = page.getViewport({ scale: 1 }); + let scale: number; + if (fitModeRef.current === "height") { + const containerHeight = Math.max(container.clientHeight - 48, 400); + scale = containerHeight / vp1.height; + } else { + scale = containerWidth / vp1.width; + } + const vp = page.getViewport({ scale }); + pdfCvs.width = Math.floor(vp.width); + pdfCvs.height = Math.floor(vp.height); + const contRenderTask = page.render({ canvasContext: pdfCvs.getContext("2d")!, viewport: vp }); + renderTasksRef.current.push(contRenderTask); + try { + await contRenderTask.promise; + } catch (e: any) { + if (e?.name === "RenderingCancelledException") return; + throw e; + } + if (renderAllPagesGenRef.current !== gen) return; + + // Create Fabric canvas overlay + const fc = new fabric.Canvas(fabricEl, { + isDrawingMode: false, + selection: false, + enableRetinaScaling: false, + }); + fc.setWidth(pdfCvs.width); + fc.setHeight(pdfCvs.height); + if (fc.wrapperEl) { + fc.wrapperEl.style.position = "absolute"; + fc.wrapperEl.style.top = "0"; + fc.wrapperEl.style.left = "0"; + fc.wrapperEl.style.pointerEvents = "none"; + } + pageFabricRefs.current[i - 1] = fc; + + // Load saved annotations + const cached = localAnnotations.current[i]; + let annotationData: object | null = cached ?? null; + if (!annotationData) { + try { + const ann = await api.getAnnotation(pdfId, i); + if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) { + annotationData = ann.canvas_data; + localAnnotations.current[i] = annotationData; + } + } catch { /* no annotation */ } + } + if (annotationData) { + await new Promise((resolve) => { + fc.loadFromJSON(annotationData, () => { fc.renderAll(); resolve(); }); + }); + } + } + + }, [pdfId]); + + // Re-render when fit mode changes + useEffect(() => { + if (!isReady) return; + if (scrollMode === "continuous") { + renderAllPages(); + } else { + renderPageWithAnnotations(currentPage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitMode]); + // ───────────────────────────────────────────────────────────────────────── // Initialization (mount) // ───────────────────────────────────────────────────────────────────────── @@ -263,6 +423,10 @@ export default function WorkbookViewer({ pdfId }: Props) { await renderPageWithAnnotations(1); setCurrentPage(1); + // Generate thumbnails in background (non-blocking) + setThumbnails([]); + generateThumbnails(); + } catch (err: unknown) { if (!cancelled) { setLoadError( @@ -278,10 +442,55 @@ export default function WorkbookViewer({ pdfId }: Props) { cancelled = true; fabricRef.current?.dispose(); fabricRef.current = null; + pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } }); + pageFabricRefs.current = []; pdfDocRef.current?.destroy(); pdfDocRef.current = null; }; - }, [pdfId, renderPageWithAnnotations]); + }, [pdfId, renderPageWithAnnotations, generateThumbnails, renderAllPages]); + + // Re-render when switching scroll modes + useEffect(() => { + if (!isReady) return; + if (scrollMode === "continuous") { + // small delay so DOM mounts the continuous page elements first + setTimeout(() => renderAllPages(), 50); + } else { + // Dispose continuous fabric instances + pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } }); + pageFabricRefs.current = []; + renderPageWithAnnotations(currentPage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollMode]); + + // Scroll-based page tracking for continuous mode + useEffect(() => { + if (!isReady || scrollMode !== "continuous") return; + const container = scrollContainerRef.current; + if (!container) return; + + const updateCurrentPage = () => { + const containerRect = container.getBoundingClientRect(); + let bestPage = 1; + let bestOverlap = -1; + pageCanvasRefs.current.forEach((cvs, idx) => { + if (!cvs || cvs.height === 0) return; + const rect = cvs.getBoundingClientRect(); + const top = Math.max(rect.top, containerRect.top); + const bottom = Math.min(rect.bottom, containerRect.bottom); + const overlap = Math.max(0, bottom - top); + if (overlap > bestOverlap) { + bestOverlap = overlap; + bestPage = idx + 1; + } + }); + if (bestOverlap > 0) setCurrentPage(bestPage); + }; + + container.addEventListener("scroll", updateCurrentPage, { passive: true }); + return () => container.removeEventListener("scroll", updateCurrentPage); + }, [isReady, scrollMode]); // ───────────────────────────────────────────────────────────────────────── // Apply tool / mode to Fabric canvas @@ -359,22 +568,66 @@ export default function WorkbookViewer({ pdfId }: Props) { } }, [isReady, mode, tool, penColor, strokeWidth]); + // Apply tool/mode to continuous-mode per-page fabric canvases too + useEffect(() => { + if (!isReady || scrollMode !== "continuous") return; + const fabric = fabricNSRef.current; + if (!fabric) return; + pageFabricRefs.current.forEach((fc) => { + if (!fc) return; + fc.off("mouse:down"); + if (mode === "pan") { + fc.isDrawingMode = false; + fc.selection = false; + if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "none"; + return; + } + 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 b = new fabric.PencilBrush(fc); b.color = penColor; b.width = strokeWidth; fc.freeDrawingBrush = b; break; + } + case "highlighter": { + fc.isDrawingMode = true; fc.selection = false; + const hb = new fabric.PencilBrush(fc); hb.color = hexToRgba(penColor, 0.99); hb.width = strokeWidth * 7; + (hb as any).strokeLineCap = "square"; fc.freeDrawingBrush = hb; break; + } + case "text": + fc.isDrawingMode = false; fc.selection = false; + fc.on("mouse:down", (options: any) => { + if (options.target) return; + const pointer = fc.getPointer(options.e); + const t = new fabric.IText("Text", { left: pointer.x, top: pointer.y, fontSize: 20, fill: penColorRef.current, fontFamily: "Arial, sans-serif", padding: 4 }); + fc.add(t); fc.setActiveObject(t); t.enterEditing(); t.selectAll(); fc.renderAll(); + }); break; + } + }); + }, [isReady, scrollMode, mode, tool, penColor, strokeWidth]); + // ───────────────────────────────────────────────────────────────────────── - // Page navigation - // ───────────────────────────────────────────────────────────────────────── + // goToPage — in continuous mode, scroll to the page element const goToPage = useCallback( async (newPage: number) => { + if (newPage < 1 || newPage > totalPages || rendering) return; + + if (scrollMode === "continuous") { + // Scroll the page wrapper into view + const el = pageCanvasRefs.current[newPage - 1]?.parentElement; + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + setCurrentPage(newPage); + return; + } + const fc = fabricRef.current; - if (!fc || newPage < 1 || newPage > totalPages || rendering) return; - - // Cache current page before leaving + if (!fc) return; localAnnotations.current[currentPage] = fc.toJSON(); - await renderPageWithAnnotations(newPage); setCurrentPage(newPage); }, - [currentPage, totalPages, rendering, renderPageWithAnnotations] + [currentPage, totalPages, rendering, scrollMode, renderPageWithAnnotations] ); // ───────────────────────────────────────────────────────────────────────── @@ -382,7 +635,9 @@ export default function WorkbookViewer({ pdfId }: Props) { // ───────────────────────────────────────────────────────────────────────── const handleSave = useCallback(async () => { - const fc = fabricRef.current; + const fc = scrollMode === "continuous" + ? pageFabricRefs.current[currentPage - 1] + : fabricRef.current; if (!fc || !isReady || saving) return; setSaving(true); @@ -441,7 +696,7 @@ export default function WorkbookViewer({ pdfId }: Props) { } return ( -
+
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
@@ -463,6 +718,85 @@ export default function WorkbookViewer({ pdfId }: Props) {
+ {/* Thumbnail panel toggle */} + + +
+ + {/* Fit mode buttons */} +
+ + +
+ +
+ + {/* Scroll mode toggle */} +
+ + +
+ +
+ {/* Page navigation */}
- - {isReady ? `${currentPage} / ${totalPages}` : "…"} - + {isReady ? ( + + { + const v = Number(e.target.value); + if (v >= 1 && v <= totalPages) goToPage(v); + }} + onFocus={(e) => e.target.select()} + className="w-10 text-center text-xs border border-gray-300 rounded px-1 py-0.5 focus:outline-none focus:border-blue-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + / {totalPages} + + ) : ( + + )}
+ {/* ── Body: thumbnail sidebar + viewer ─────────────────────────────────── */} +
+ + {/* Thumbnail sidebar */} + {showThumbnails && ( + + )} + {/* ── Viewer area ─────────────────────────────────────────────────────── */}
{/* 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 && ( -
- - - {currentPage} / {totalPages} - - + {/* Continuous scroll: one wrapper per page */} + {isReady && scrollMode === "continuous" && ( +
+ {Array.from({ length: totalPages }).map((_, idx) => ( +
+ { pageCanvasRefs.current[idx] = el; }} + data-page={idx + 1} + className="block shadow-lg rounded" + /> + +
+ ))}
)} + +
); } diff --git a/start-dev.sh b/start-dev.sh new file mode 100755 index 0000000..611bf2f --- /dev/null +++ b/start-dev.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────────────── +# start-dev.sh — Start LMS in local development mode +# Runs Backend (uvicorn) + Frontend (next dev) in parallel +# Usage: +# ./start-dev.sh # start both +# ./start-dev.sh backend # start backend only +# ./start-dev.sh frontend # start frontend only +# ───────────────────────────────────────────────────────────────────────────── +set -e +cd "$(dirname "$0")" + +# ── Load .env ───────────────────────────────────────────────────────────────── +if [ ! -f .env ]; then + echo "❌ .env not found. Copy .env.example → .env and fill in values." + exit 1 +fi + +# Export only the variables we need (skip GHCR secrets) +export $(grep -v '^#' .env | grep -E 'POSTGRES_(DB|USER|PASSWORD)|JWT_SECRET_KEY|ACCESS_TOKEN_EXPIRE_MINUTES|FRONTEND_PORT' | xargs) + +# Defaults +POSTGRES_DB="${POSTGRES_DB:-lms_db}" +POSTGRES_USER="${POSTGRES_USER:-lms_user}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-lms_test_password_123}" +JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}" +FRONTEND_PORT="${FRONTEND_PORT:-3000}" +BACKEND_PORT="${BACKEND_PORT:-8000}" + +DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}" +UPLOAD_DIR="./backend/uploads" + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +TARGET="${1:-both}" + +# ───────────────────────────────────────────────────────────────────────────── +start_backend() { + echo -e "${CYAN}${BOLD}▶ Starting Backend${NC} (port ${BACKEND_PORT})" + + # Check Python venv + VENV="" + if [ -d backend/.venv ]; then + VENV="backend/.venv/bin/" + elif [ -d backend/venv ]; then + VENV="backend/venv/bin/" + fi + + # Run Alembic migrations first + echo -e "${YELLOW} Running Alembic migrations...${NC}" + ( + cd backend + DATABASE_URL="$DATABASE_URL" \ + ${VENV}alembic upgrade head 2>&1 | sed 's/^/ [alembic] /' + ) + + mkdir -p "$UPLOAD_DIR" + + echo -e "${GREEN} Backend ready → http://localhost:${BACKEND_PORT}${NC}" + echo -e "${GREEN} API docs → http://localhost:${BACKEND_PORT}/docs${NC}" + echo "" + + DATABASE_URL="$DATABASE_URL" \ + JWT_SECRET_KEY="$JWT_SECRET_KEY" \ + ACCESS_TOKEN_EXPIRE_MINUTES="$ACCESS_TOKEN_EXPIRE_MINUTES" \ + UPLOAD_DIR="$UPLOAD_DIR" \ + ${VENV}uvicorn main:app \ + --host 0.0.0.0 \ + --port "$BACKEND_PORT" \ + --reload \ + --app-dir backend +} + +# ───────────────────────────────────────────────────────────────────────────── +start_frontend() { + echo -e "${CYAN}${BOLD}▶ Starting Frontend${NC} (port ${FRONTEND_PORT})" + + if [ ! -d frontend/node_modules ]; then + echo -e "${YELLOW} node_modules not found — running npm install...${NC}" + (cd frontend && npm install) + fi + + echo -e "${GREEN} Frontend ready → http://localhost:${FRONTEND_PORT}${NC}" + echo "" + + NEXT_PUBLIC_API_URL="http://localhost:${BACKEND_PORT}" \ + PORT="$FRONTEND_PORT" \ + npm --prefix frontend run dev +} + +# ───────────────────────────────────────────────────────────────────────────── +# Entrypoint +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}╔══════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ LMS — Dev Server ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════╝${NC}" +echo "" + +case "$TARGET" in + backend) + start_backend + ;; + frontend) + start_frontend + ;; + both) + # Run backend in background, frontend in foreground + # Trap Ctrl+C to kill both + trap 'echo -e "\n${RED}Stopping...${NC}"; kill 0' INT TERM + + start_backend & + BACKEND_PID=$! + + # Small delay so backend logs don't jumble with frontend boot + sleep 2 + + start_frontend & + FRONTEND_PID=$! + + echo "" + echo -e "${BOLD}Running. Press Ctrl+C to stop both servers.${NC}" + echo "" + + wait $BACKEND_PID $FRONTEND_PID + ;; + *) + echo "Usage: $0 [both|backend|frontend]" + exit 1 + ;; +esac