mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
các chức năng thumbnail, scroll continuos đã xong
This commit is contained in:
@@ -14,7 +14,7 @@ from ..schemas import PDFOut, PDFUploadResult
|
|||||||
router = APIRouter(prefix="/pdfs", tags=["pdfs"])
|
router = APIRouter(prefix="/pdfs", tags=["pdfs"])
|
||||||
|
|
||||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
|
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:
|
def _user_dir(user_id: int) -> Path:
|
||||||
@@ -71,7 +71,7 @@ async def upload_pdfs(
|
|||||||
# ── Size guard ────────────────────────────────────────────────────────
|
# ── Size guard ────────────────────────────────────────────────────────
|
||||||
body = header + await file.read()
|
body = header + await file.read()
|
||||||
if len(body) > MAX_FILE_SIZE:
|
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
|
continue
|
||||||
|
|
||||||
# ── Save to disk with UUID filename (prevents path traversal) ─────────
|
# ── Save to disk with UUID filename (prevents path traversal) ─────────
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function UploadZone({ onUploaded }: Props) {
|
|||||||
Drag & drop PDFs here, or{" "}
|
Drag & drop PDFs here, or{" "}
|
||||||
<span className="text-blue-600">click to browse</span>
|
<span className="text-blue-600">click to browse</span>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { api } from "@/lib/api";
|
|||||||
|
|
||||||
type Tool = "select" | "pen" | "highlighter" | "text";
|
type Tool = "select" | "pen" | "highlighter" | "text";
|
||||||
type ViewMode = "pan" | "draw";
|
type ViewMode = "pan" | "draw";
|
||||||
|
type FitMode = "width" | "height";
|
||||||
|
type ScrollMode = "single" | "continuous";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pdfId: number;
|
pdfId: number;
|
||||||
@@ -106,11 +108,59 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
const [pdfTitle, setPdfTitle] = useState("PDF");
|
const [pdfTitle, setPdfTitle] = useState("PDF");
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
const [rendering, setRendering] = useState(false);
|
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
|
// Keep mutable refs in sync
|
||||||
useEffect(() => { currentToolRef.current = tool; }, [tool]);
|
useEffect(() => { currentToolRef.current = tool; }, [tool]);
|
||||||
useEffect(() => { penColorRef.current = penColor; }, [penColor]);
|
useEffect(() => { penColorRef.current = penColor; }, [penColor]);
|
||||||
useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
|
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
|
// renderPageWithAnnotations
|
||||||
@@ -124,23 +174,34 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
|
|
||||||
setRendering(true);
|
setRendering(true);
|
||||||
try {
|
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 ─────────────────────────────────────────────
|
// ── 1. Render PDF page ─────────────────────────────────────────────
|
||||||
const page = await doc.getPage(pageNum);
|
const page = await doc.getPage(pageNum);
|
||||||
const containerWidth = Math.max(scrollContainerRef.current.clientWidth - 32, 300);
|
|
||||||
const viewport1 = page.getViewport({ scale: 1 });
|
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 viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
const pdfCvs = pdfCanvasRef.current;
|
const pdfCvs = pdfCanvasRef.current;
|
||||||
pdfCvs.width = Math.floor(viewport.width);
|
pdfCvs.width = Math.floor(viewport.width);
|
||||||
pdfCvs.height = Math.floor(viewport.height);
|
pdfCvs.height = Math.floor(viewport.height);
|
||||||
|
|
||||||
await page.render({
|
const singleRenderTask = page.render({
|
||||||
canvasContext: pdfCvs.getContext("2d")!,
|
canvasContext: pdfCvs.getContext("2d")!,
|
||||||
viewport,
|
viewport,
|
||||||
}).promise;
|
});
|
||||||
|
renderTasksRef.current.push(singleRenderTask);
|
||||||
// ── 2. Resize Fabric canvas to match ──────────────────────────────
|
await singleRenderTask.promise;
|
||||||
|
// ── 2. Resize Fabric canvas to match
|
||||||
fc.setWidth(pdfCvs.width);
|
fc.setWidth(pdfCvs.width);
|
||||||
fc.setHeight(pdfCvs.height);
|
fc.setHeight(pdfCvs.height);
|
||||||
if (fc.wrapperEl) {
|
if (fc.wrapperEl) {
|
||||||
@@ -186,6 +247,105 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
[pdfId]
|
[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)
|
// Initialization (mount)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -263,6 +423,10 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
await renderPageWithAnnotations(1);
|
await renderPageWithAnnotations(1);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
|
||||||
|
// Generate thumbnails in background (non-blocking)
|
||||||
|
setThumbnails([]);
|
||||||
|
generateThumbnails();
|
||||||
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLoadError(
|
setLoadError(
|
||||||
@@ -278,10 +442,55 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
fabricRef.current?.dispose();
|
fabricRef.current?.dispose();
|
||||||
fabricRef.current = null;
|
fabricRef.current = null;
|
||||||
|
pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } });
|
||||||
|
pageFabricRefs.current = [];
|
||||||
pdfDocRef.current?.destroy();
|
pdfDocRef.current?.destroy();
|
||||||
pdfDocRef.current = null;
|
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
|
// Apply tool / mode to Fabric canvas
|
||||||
@@ -359,22 +568,66 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
}
|
}
|
||||||
}, [isReady, mode, tool, penColor, strokeWidth]);
|
}, [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(
|
const goToPage = useCallback(
|
||||||
async (newPage: number) => {
|
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;
|
const fc = fabricRef.current;
|
||||||
if (!fc || newPage < 1 || newPage > totalPages || rendering) return;
|
if (!fc) return;
|
||||||
|
|
||||||
// Cache current page before leaving
|
|
||||||
localAnnotations.current[currentPage] = fc.toJSON();
|
localAnnotations.current[currentPage] = fc.toJSON();
|
||||||
|
|
||||||
await renderPageWithAnnotations(newPage);
|
await renderPageWithAnnotations(newPage);
|
||||||
setCurrentPage(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 handleSave = useCallback(async () => {
|
||||||
const fc = fabricRef.current;
|
const fc = scrollMode === "continuous"
|
||||||
|
? pageFabricRefs.current[currentPage - 1]
|
||||||
|
: fabricRef.current;
|
||||||
if (!fc || !isReady || saving) return;
|
if (!fc || !isReady || saving) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -441,7 +696,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 ──────────────────────────────────────────────────── */}
|
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
|
||||||
<header className="bg-white border-b shadow-sm sticky top-0 z-20">
|
<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" />
|
<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 */}
|
{/* Page navigation */}
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-gray-600 px-1 whitespace-nowrap tabular-nums">
|
{isReady ? (
|
||||||
{isReady ? `${currentPage} / ${totalPages}` : "…"}
|
<span className="flex items-center gap-1 text-xs text-gray-600 tabular-nums">
|
||||||
</span>
|
<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
|
<button
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
disabled={currentPage >= totalPages || rendering || !isReady}
|
disabled={currentPage >= totalPages || rendering || !isReady}
|
||||||
@@ -610,6 +960,40 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 ─────────────────────────────────────────────────────── */}
|
{/* ── Viewer area ─────────────────────────────────────────────────────── */}
|
||||||
<main
|
<main
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
@@ -630,42 +1014,31 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
{/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
|
{/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto"
|
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 */}
|
{/* PDF.js renders here */}
|
||||||
<canvas ref={pdfCanvasRef} className="block shadow-lg rounded" />
|
<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} />
|
<canvas ref={fabricElRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom page controls for mobile convenience */}
|
{/* Continuous scroll: one wrapper per page */}
|
||||||
{isReady && (
|
{isReady && scrollMode === "continuous" && (
|
||||||
<div className="flex justify-center mt-6 gap-4">
|
<div className="flex flex-col gap-6 items-center">
|
||||||
<button
|
{Array.from({ length: totalPages }).map((_, idx) => (
|
||||||
onClick={() => goToPage(currentPage - 1)}
|
<div key={idx} className="relative" style={{ width: "fit-content" }}>
|
||||||
disabled={currentPage <= 1 || rendering}
|
<canvas
|
||||||
className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
|
ref={(el) => { pageCanvasRefs.current[idx] = el; }}
|
||||||
>
|
data-page={idx + 1}
|
||||||
← Prev
|
className="block shadow-lg rounded"
|
||||||
</button>
|
/>
|
||||||
<span className="text-sm text-gray-500 tabular-nums">
|
<canvas id={`fabric-continuous-${idx + 1}`} />
|
||||||
{currentPage} / {totalPages}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+133
@@ -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
|
||||||
Reference in New Issue
Block a user