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 */}
+ setShowThumbnails(v => !v)}
+ title="Thumbnails"
+ className={`p-1.5 rounded-lg transition flex items-center gap-1 ${
+ showThumbnails
+ ? "bg-blue-50 text-blue-600"
+ : "text-gray-500 hover:bg-gray-100 hover:text-gray-800"
+ }`}
+ >
+
+
+
+ Thumb
+
+
+
+
+ {/* Fit mode buttons */}
+
+
setFitMode("width")}
+ title="Fit Width"
+ className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
+ fitMode === "width" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
+ }`}
+ >
+
+
+
+ Width
+
+
setFitMode("height")}
+ title="Fit Height"
+ className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
+ fitMode === "height" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
+ }`}
+ >
+
+
+
+ Height
+
+
+
+
+
+ {/* Scroll mode toggle */}
+
+
setScrollMode("single")}
+ title="Single page"
+ className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
+ scrollMode === "single" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
+ }`}
+ >
+
+
+
+ Single
+
+
setScrollMode("continuous")}
+ title="Continuous scroll"
+ className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
+ scrollMode === "continuous" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
+ }`}
+ >
+
+
+
+ Continuous
+
+
+
+
+
{/* 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}
+
+ ) : (
+ …
+ )}
goToPage(currentPage + 1)}
disabled={currentPage >= totalPages || rendering || !isReady}
@@ -610,6 +960,40 @@ export default function WorkbookViewer({ pdfId }: Props) {
+ {/* ── 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 && (
-
-
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 →
-
+ {/* 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