các chức năng thumbnail, scroll continuos đã xong

This commit is contained in:
2026-04-01 08:46:58 +07:00
parent 7da144286f
commit 79eea344e9
4 changed files with 555 additions and 49 deletions
+2 -2
View File
@@ -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) ─────────
+1 -1
View File
@@ -102,7 +102,7 @@ export default function UploadZone({ onUploaded }: Props) {
Drag & drop PDFs here, or{" "}
<span className="text-blue-600">click to browse</span>
</p>
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 50 MB each</p>
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 500 MB each</p>
</>
)}
</div>
+418 -45
View File
@@ -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<string[]>([]);
const [fitMode, setFitMode] = useState<FitMode>("width");
const [scrollMode, setScrollMode] = useState<ScrollMode>("single");
// Ref for auto-scrolling thumbnail panel
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
const fitModeRef = useRef<FitMode>("width");
const scrollModeRef = useRef<ScrollMode>("single");
// Per-page canvas refs for continuous mode
const pageCanvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
const pageFabricRefs = useRef<any[]>([]);
// Active PDF.js render tasks — cancel before re-rendering
const renderTasksRef = useRef<any[]>([]);
// 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<void>((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 (
<div className="min-h-screen flex flex-col bg-gray-100">
<div className="h-screen flex flex-col overflow-hidden bg-gray-100">
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-20">
@@ -463,6 +718,85 @@ export default function WorkbookViewer({ pdfId }: Props) {
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Thumbnail panel toggle */}
<button
onClick={() => 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"
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
<span className="text-xs font-medium hidden sm:inline">Thumb</span>
</button>
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Fit mode buttons */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => 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"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h8M8 12h8M4 17h16" />
</svg>
<span className="hidden sm:inline">Width</span>
</button>
<button
onClick={() => 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"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8V4m0 0H3m4 0l-4 4M17 8V4m0 0h4m-4 0l4 4M7 16v4m0 0H3m4 0l-4-4M17 16v4m0 0h4m-4 0l4-4" />
</svg>
<span className="hidden sm:inline">Height</span>
</button>
</div>
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Scroll mode toggle */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => 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"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6M9 8h6M9 16h6M5 4h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1z" />
</svg>
<span className="hidden sm:inline">Single</span>
</button>
<button
onClick={() => 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"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span className="hidden sm:inline">Continuous</span>
</button>
</div>
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Page navigation */}
<div className="flex items-center gap-0.5">
<button
@@ -475,9 +809,25 @@ export default function WorkbookViewer({ pdfId }: Props) {
<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}` : "…"}
{isReady ? (
<span className="flex items-center gap-1 text-xs text-gray-600 tabular-nums">
<input
type="number"
min={1}
max={totalPages}
value={currentPage}
onChange={(e) => {
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"
/>
<span>/ {totalPages}</span>
</span>
) : (
<span className="text-xs text-gray-600 px-1"></span>
)}
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages || rendering || !isReady}
@@ -610,6 +960,40 @@ export default function WorkbookViewer({ pdfId }: Props) {
</div>
</header>
{/* ── Body: thumbnail sidebar + viewer ─────────────────────────────────── */}
<div className="flex flex-1 overflow-hidden">
{/* Thumbnail sidebar */}
{showThumbnails && (
<aside className="w-36 bg-white border-r border-gray-200 overflow-y-auto flex-shrink-0">
<div className="flex flex-col gap-2 py-3 px-2">
{Array.from({ length: totalPages }).map((_, idx) => (
<button
key={idx}
ref={(el) => { thumbRefs.current[idx] = el; }}
onClick={() => goToPage(idx + 1)}
className={`flex flex-col items-center gap-1 rounded-lg p-1 w-full transition ${
currentPage === idx + 1
? "ring-2 ring-blue-500 bg-blue-50"
: "hover:bg-gray-100"
}`}
>
{thumbnails[idx] ? (
<img
src={thumbnails[idx]}
alt={`Page ${idx + 1}`}
className="w-full rounded shadow-sm border border-gray-200"
/>
) : (
<div className="w-full aspect-[3/4] bg-gray-100 rounded animate-pulse" />
)}
<span className="text-[10px] text-gray-500 tabular-nums font-medium">{idx + 1}</span>
</button>
))}
</div>
</aside>
)}
{/* ── Viewer area ─────────────────────────────────────────────────────── */}
<main
ref={scrollContainerRef}
@@ -630,42 +1014,31 @@ export default function WorkbookViewer({ pdfId }: Props) {
{/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
<div
className="relative mx-auto"
style={{ display: isReady ? "block" : "none", width: "fit-content" }}
style={{ display: isReady && scrollMode === "single" ? "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>
{/* Continuous scroll: one wrapper per page */}
{isReady && scrollMode === "continuous" && (
<div className="flex flex-col gap-6 items-center">
{Array.from({ length: totalPages }).map((_, idx) => (
<div key={idx} className="relative" style={{ width: "fit-content" }}>
<canvas
ref={(el) => { pageCanvasRefs.current[idx] = el; }}
data-page={idx + 1}
className="block shadow-lg rounded"
/>
<canvas id={`fabric-continuous-${idx + 1}`} />
</div>
))}
</div>
)}
</main>
</div>
</div>
);
}
Executable
+133
View File
@@ -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