From 7da144286f9c6dabe36c9fa914b826dc60e06208 Mon Sep 17 00:00:00 2001 From: hienp Date: Tue, 31 Mar 2026 16:29:03 +0700 Subject: [PATCH 01/11] =?UTF-8?q?ho=C3=A0n=20th=C3=A0nh=20docker=20compose?= =?UTF-8?q?=20giai=20=C4=91o=E1=BA=A1n=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 ++ backend/app/routers/auth.py | 4 +- deploy.sh | 42 +++++++++++++++++ docker-compose.yml | 2 + frontend/Dockerfile | 1 + frontend/src/types/modules.d.ts | 13 +++++ push-ghcr.sh | 62 ++++++++++++++++++++++++ readme.md | 84 ++++++++++++++++++++++++++++++++- 8 files changed, 211 insertions(+), 2 deletions(-) create mode 100755 deploy.sh create mode 100644 frontend/src/types/modules.d.ts create mode 100755 push-ghcr.sh diff --git a/.env.example b/.env.example index 8c89096..db46c22 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60 # ── Ports ───────────────────────────────────────────────────────────────────── # Port exposed on the host for the Next.js frontend FRONTEND_PORT=3000 + +# ── Docker Hub images (optional — leave blank to build locally) ─────────────── +# Set these on the target machine so docker compose pull works without building +# BACKEND_IMAGE=your_dockerhub_username/lms-backend:latest +# FRONTEND_IMAGE=your_dockerhub_username/lms-frontend:latest diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index e660f2e..9493258 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session +import os from ..database import get_db from ..dependencies import get_current_user @@ -16,6 +17,7 @@ router = APIRouter(prefix="/auth", tags=["auth"]) _COOKIE_NAME = "access_token" _COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds +_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true" def _set_auth_cookie(response: Response, user_id: int) -> None: @@ -24,7 +26,7 @@ def _set_auth_cookie(response: Response, user_id: int) -> None: key=_COOKIE_NAME, value=token, httponly=True, - secure=True, # send only over HTTPS (Nginx handles TLS in prod) + secure=_COOKIE_SECURE, samesite="lax", max_age=_COOKIE_MAX_AGE, path="/", diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..45eab80 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Pull LMS images from GHCR and start the stack on a new machine. +# Usage: ./deploy.sh +# Requires: .env file with BACKEND_IMAGE, FRONTEND_IMAGE, POSTGRES_PASSWORD, JWT_SECRET_KEY + +set -e + +cd "$(dirname "$0")" + +if [ ! -f .env ]; then + echo "❌ .env not found. Copy .env.example → .env and fill in the values." + exit 1 +fi + +source .env + +if [ -z "$POSTGRES_PASSWORD" ] || [ "$POSTGRES_PASSWORD" = "change_me_strong_password" ]; then + echo "❌ Set POSTGRES_PASSWORD in .env" + exit 1 +fi + +if [ -z "$JWT_SECRET_KEY" ] || [ "$JWT_SECRET_KEY" = "change_me_generate_with_secrets_token_hex_32" ]; then + echo "❌ Set JWT_SECRET_KEY in .env" + echo " Generate: python3 -c \"import secrets; print(secrets.token_hex(32))\"" + exit 1 +fi + +# Login GHCR if token is provided +if [ -n "$GITHUB_TOKEN" ] && [ -n "$GITHUB_USER" ]; then + echo "🔐 Logging in to ghcr.io..." + echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin +fi + +echo "📦 Pulling images from GHCR..." +docker compose pull + +echo "🚀 Starting stack..." +docker compose up -d + +echo "" +echo "✅ Running! Open http://localhost:${FRONTEND_PORT:-3000}" +docker compose ps diff --git a/docker-compose.yml b/docker-compose.yml index 1112add..0fa17ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: # ── FastAPI backend ─────────────────────────────────────────────────────────── backend: + image: ${BACKEND_IMAGE:-lms-backend:latest} build: context: ./backend dockerfile: Dockerfile @@ -42,6 +43,7 @@ services: # ── Next.js frontend ────────────────────────────────────────────────────────── frontend: + image: ${FRONTEND_IMAGE:-lms-frontend:latest} build: context: ./frontend dockerfile: Dockerfile diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9e80154..0e1deff 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -27,6 +27,7 @@ ENV NODE_ENV=production COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/next.config.js ./ EXPOSE 3000 diff --git a/frontend/src/types/modules.d.ts b/frontend/src/types/modules.d.ts new file mode 100644 index 0000000..eb152f0 --- /dev/null +++ b/frontend/src/types/modules.d.ts @@ -0,0 +1,13 @@ +declare module "fabric" { + export const fabric: any; +} + +declare module "pdfjs-dist" { + export const GlobalWorkerOptions: { + workerSrc: string; + }; + export const version: string; + export function getDocument(params: any): { + promise: Promise; + }; +} diff --git a/push-ghcr.sh b/push-ghcr.sh new file mode 100755 index 0000000..03dd5ee --- /dev/null +++ b/push-ghcr.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Push LMS images to GitHub Container Registry (GHCR) +# Usage: ./push-ghcr.sh [tag] +# Reads GITHUB_USER and GITHUB_TOKEN from .env + +set -e + +cd "$(dirname "$0")" + +# Load .env +if [ -f .env ]; then + export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs) +fi + +TAG="${1:-latest}" + +GITHUB_USER="${GITHUB_USER:?GITHUB_USER not set in .env}" +GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN not set in .env}" + +BACKEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-backend:${TAG}" +FRONTEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-frontend:${TAG}" + +echo "========================================" +echo " LMS → GHCR Push" +echo " Backend : $BACKEND_IMAGE" +echo " Frontend: $FRONTEND_IMAGE" +echo "========================================" +echo "" + +# ── 1. Login to GHCR ────────────────────────────────────────────────────────── +echo "🔐 Logging in to ghcr.io ..." +echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin + +# ── 2. Build images ─────────────────────────────────────────────────────────── +echo "" +echo "🔨 Building images..." +cd "$(dirname "$0")" +docker compose build + +# ── 3. Tag for GHCR ─────────────────────────────────────────────────────────── +echo "" +echo "🏷 Tagging images..." +docker tag lms-backend:latest "$BACKEND_IMAGE" +docker tag lms-frontend:latest "$FRONTEND_IMAGE" + +# ── 4. Push ─────────────────────────────────────────────────────────────────── +echo "" +echo "⬆ Pushing to GHCR..." +docker push "$BACKEND_IMAGE" +docker push "$FRONTEND_IMAGE" + +echo "" +echo "✅ Done! Images pushed:" +echo " $BACKEND_IMAGE" +echo " $FRONTEND_IMAGE" +echo "" +echo "📋 Add these to .env on the target machine:" +echo " BACKEND_IMAGE=$BACKEND_IMAGE" +echo " FRONTEND_IMAGE=$FRONTEND_IMAGE" +echo "" +echo "🚀 On the target machine:" +echo " docker compose pull && docker compose up -d" diff --git a/readme.md b/readme.md index 10fadba..f3f2846 100644 --- a/readme.md +++ b/readme.md @@ -51,4 +51,86 @@ Please use the following technologies for this project: Let's build this step-by-step to avoid context limits. 1. First, analyze this spec and confirm you understand. 2. Provide the database schema models (SQLAlchemy or Prisma). -3. Wait for my confirmation before writing the Backend API routes. \ No newline at end of file +3. Wait for my confirmation before writing the Backend API routes. + +--- + +## 7. CI/CD — Push lên GitHub Container Registry (GHCR) + +### 7.1. Yêu cầu + +- **Docker** đã cài và đang chạy +- **GitHub Personal Access Token (PAT)** với quyền `write:packages` và `read:packages` + - Tạo tại: https://github.com/settings/tokens → *Generate new token (classic)* +- File `.env` đã được cấu hình (xem `.env.example`) + +### 7.2. Cấu hình `.env` + +Thêm các dòng sau vào file `.env` (file này đã được gitignore, **không commit**): + +```env +GITHUB_USER= +GITHUB_TOKEN= +BACKEND_IMAGE=ghcr.io//lms-backend:latest +FRONTEND_IMAGE=ghcr.io//lms-frontend:latest +``` + +### 7.3. Build & Push lên GHCR + +```bash +chmod +x push-ghcr.sh +./push-ghcr.sh +``` + +Script sẽ tự động: +1. Đọc `GITHUB_USER` và `GITHUB_TOKEN` từ `.env` +2. Đăng nhập vào `ghcr.io` +3. Build cả hai image (`lms-backend`, `lms-frontend`) bằng `docker compose build` +4. Tag và push lên GHCR + +Để push với tag cụ thể (ví dụ: version): +```bash +./push-ghcr.sh v1.0.0 +``` + +### 7.4. Deploy trên máy khác + +Trên máy đích (server, VPS, máy tính khác): + +```bash +# 1. Copy các file cần thiết +scp docker-compose.yml .env.example deploy.sh user@server:/opt/lms/ +ssh user@server + +# 2. Tạo .env từ example +cd /opt/lms +cp .env.example .env +# Điền các giá trị: POSTGRES_PASSWORD, JWT_SECRET_KEY, GITHUB_USER, GITHUB_TOKEN, +# BACKEND_IMAGE, FRONTEND_IMAGE + +# 3. Chạy deploy +chmod +x deploy.sh +./deploy.sh +``` + +Script `deploy.sh` sẽ: +1. Kiểm tra `.env` hợp lệ +2. Đăng nhập GHCR (nếu có token) +3. Pull image từ GHCR +4. Khởi động toàn bộ stack: `db`, `backend`, `frontend` + +### 7.5. Kiểm tra packages trên GitHub + +Sau khi push, image sẽ xuất hiện tại: +``` +https://github.com/?tab=packages +``` + +> **Lưu ý:** Mặc định packages ở chế độ **Private**. Để máy khác pull mà không cần token, vào +> GitHub → Packages → tên package → *Package settings* → đổi visibility sang **Public**. + +### 7.6. Sinh JWT Secret Key + +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` \ No newline at end of file From 79eea344e99ee7bd883e856194e90b448dabb47c Mon Sep 17 00:00:00 2001 From: hienp Date: Wed, 1 Apr 2026 08:46:58 +0700 Subject: [PATCH 02/11] =?UTF-8?q?c=C3=A1c=20ch=E1=BB=A9c=20n=C4=83ng=20thu?= =?UTF-8?q?mbnail,=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 From 89db1c7b5c2a5dfb725ac233bacd4ec0cdad9fdc Mon Sep 17 00:00:00 2001 From: hienp Date: Wed, 1 Apr 2026 09:25:31 +0700 Subject: [PATCH 03/11] =?UTF-8?q?b=E1=BB=95=20sung=20ch=E1=BB=A9c=20n?= =?UTF-8?q?=C4=83ng=20eraser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/WorkbookViewer.tsx | 454 +++++++++++---------- 1 file changed, 231 insertions(+), 223 deletions(-) diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx index 656f4f0..e55b6d0 100644 --- a/frontend/src/components/WorkbookViewer.tsx +++ b/frontend/src/components/WorkbookViewer.tsx @@ -8,7 +8,7 @@ import { api } from "@/lib/api"; // Types // ───────────────────────────────────────────────────────────────────────────── -type Tool = "select" | "pen" | "highlighter" | "text"; +type Tool = "select" | "pen" | "highlighter" | "text" | "eraser"; type ViewMode = "pan" | "draw"; type FitMode = "width" | "height"; type ScrollMode = "single" | "continuous"; @@ -565,6 +565,18 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.renderAll(); }); break; + + case "eraser": + fc.isDrawingMode = false; + fc.selection = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.on("mouse:down", (options: any) => { + if (!options.target) return; + fc.remove(options.target); + addedObjects.current = addedObjects.current.filter(o => o !== options.target); + fc.renderAll(); + }); + break; } }, [isReady, mode, tool, penColor, strokeWidth]); @@ -602,6 +614,12 @@ export default function WorkbookViewer({ pdfId }: Props) { 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; + case "eraser": + fc.isDrawingMode = false; fc.selection = false; + fc.on("mouse:down", (options: any) => { + if (!options.target) return; + fc.remove(options.target); fc.renderAll(); + }); break; } }); }, [isReady, scrollMode, mode, tool, penColor, strokeWidth]); @@ -700,254 +718,244 @@ export default function WorkbookViewer({ pdfId }: Props) { {/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
-
+
- {/* Back button + title */} - - - {pdfTitle} - + {/* Scrollable tools strip */} +
-
- - {/* Thumbnail panel toggle */} - - -
- - {/* Fit mode buttons */} -
+ {/* Back button + title */} - -
- -
- - {/* Scroll mode toggle */} -
- - -
- -
- - {/* Page navigation */} -
- - {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} - - ) : ( - - )} + + {pdfTitle} + + +
+ + {/* Thumbnail toggle */} -
-
+
- {/* Pan / Draw mode toggle */} -
- - -
- - {/* Drawing tools — only shown in Draw mode */} - {mode === "draw" && ( - <> -
- - {/* Tool buttons */} -
- {/* Select */} - - - - - {/* Pen */} - - - - - {/* Highlighter */} - - - - - {/* Text */} - - - -
- - {/* Color picker */} - setPenColor(e.target.value)} - title="Color" - className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white" - /> - - {/* Stroke width slider */} - setStrokeWidth(Number(e.target.value))} - className="w-16 accent-blue-600" - title={`Stroke width: ${strokeWidth}`} - /> - -
- - {/* Undo */} + {/* Fit mode */} +
+ +
+ +
+ + {/* Scroll mode */} +
+ + +
+ +
+ + {/* Page navigation */} +
+ - - {/* Clear page */} + {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} + + ) : ( + + )} - - )} +
- {/* Save button — far right */} -
+
+ + {/* Pan / Draw mode toggle */} +
+ + +
+ + {/* Drawing tools — inline, visible only in draw mode */} + {mode === "draw" && ( + <> +
+ +
+ + + + + + + + + + + + + + + +
+ + setPenColor(e.target.value)} + title="Color" + className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white flex-shrink-0" + /> + + setStrokeWidth(Number(e.target.value))} + className="w-20 accent-blue-600 flex-shrink-0" + title={`Stroke width: ${strokeWidth}`} + /> + +
+ + + + + + )} +
+ + {/* Save — fixed right, never scrolls away */} +
{saveNotice && ( - - ✓ Saved - + ✓ Saved )} + {/* Delete button — only for owner, visible on hover */} + {isOwner && ( + + )}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx index e55b6d0..b522aaa 100644 --- a/frontend/src/components/WorkbookViewer.tsx +++ b/frontend/src/components/WorkbookViewer.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; +import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration"; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -113,6 +114,15 @@ export default function WorkbookViewer({ pdfId }: Props) { const [fitMode, setFitMode] = useState("width"); const [scrollMode, setScrollMode] = useState("single"); + // Collaboration + const [collabToken, setCollabToken] = useState(null); + const currentPageRef = useRef(1); // mutable copy for collab callbacks + const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events + const currentUserIdRef = useRef(null); // populated from api.me(); used to identify own vs remote objects + // Buffer for remote events that arrive while renderPageWithAnnotations is in progress + const renderingRef = useRef(false); + const pendingRemoteEvents = useRef([]); + // Ref for auto-scrolling thumbnail panel const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]); const fitModeRef = useRef("width"); @@ -125,6 +135,13 @@ export default function WorkbookViewer({ pdfId }: Props) { // Generation counter — incremented on each renderAllPages call to abort stale runs const renderAllPagesGenRef = useRef(0); + // Collab: stable ref to send functions (set after hook initializes) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any } | null>(null); + + // Keep currentPageRef in sync + useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]); + // Keep mutable refs in sync useEffect(() => { currentToolRef.current = tool; }, [tool]); useEffect(() => { penColorRef.current = penColor; }, [penColor]); @@ -139,6 +156,121 @@ export default function WorkbookViewer({ pdfId }: Props) { } }, [currentPage, showThumbnails]); + // Fetch JWT token for WebSocket auth (cookie not sent on WS upgrade) + useEffect(() => { + fetch("/api/auth/token", { credentials: "include" }) + .then(r => r.ok ? r.json() : null) + .then((d: { access_token?: string } | null) => { if (d?.access_token) setCollabToken(d.access_token); }) + .catch(() => {}); + }, []); + + // Fetch current user id — used to distinguish own from remote objects when merging /all + useEffect(() => { + api.me().then((u) => { currentUserIdRef.current = u.id; }).catch(() => {}); + }, []); + + // Handle incoming remote annotation events + const onRemoteEvent = useCallback((event: RemoteEvent) => { + const fabric = fabricNSRef.current; + if (!fabric) return; + + // Resolve the right fabric canvas for the event's page + const getCanvas = (page: number) => { + if (scrollModeRef.current === "continuous") { + return pageFabricRefs.current[page - 1] ?? null; + } + return currentPageRef.current === page ? fabricRef.current : null; + }; + + if (event.type === "clear") { + const fc = getCanvas(event.page ?? currentPageRef.current); + if (!fc) return; + skipRemoteRef.current = true; + skipObjectTracking.current = true; + fc.clear(); fc.renderAll(); + skipRemoteRef.current = false; + skipObjectTracking.current = false; + return; + } + + if (event.type === "object_remove") { + const fc = getCanvas(event.page ?? currentPageRef.current); + if (!fc) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const collab_id = (event.payload as any)?.obj_id; + if (!collab_id) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const target = fc.getObjects().find((o: any) => o.collab_id === collab_id); + if (target) { skipRemoteRef.current = true; fc.remove(target); fc.renderAll(); skipRemoteRef.current = false; } + return; + } + + if (event.type === "object_add") { + // If a page render is in progress, the canvas is about to be cleared + reloaded. + // Buffer this event and replay it after renderPageWithAnnotations finishes. + if (renderingRef.current && scrollModeRef.current === "single") { + pendingRemoteEvents.current.push(event); + return; + } + const page = event.page ?? currentPageRef.current; + const fc = getCanvas(page); + if (!fc) return; + const objJson = event.payload as Record; + skipRemoteRef.current = true; + skipObjectTracking.current = true; + fabric.util.enlivenObjects([objJson], (objects: any[]) => { + objects.forEach((obj: any) => { + obj.collab_id = objJson.collab_id; + obj._isRemote = true; // don't save other users' live strokes under our account + obj.selectable = false; + obj.evented = false; + fc.add(obj); + }); + fc.renderAll(); + skipRemoteRef.current = false; + skipObjectTracking.current = false; + }); + return; + } + }, []); + + // When a new peer joins the room, broadcast all our own (non-remote) canvas + // objects so they receive our pre-existing annotations immediately. + const handlePeerJoined = useCallback(() => { + const sendAdd = collabSendRef.current?.objectAdd; + if (!sendAdd) return; + + const broadcastCanvas = (fc: any, page: number) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.getObjects().forEach((obj: any) => { + // Skip objects from other users — they already have their own + if (obj._isRemote) return; + if (!obj.collab_id) obj.collab_id = crypto.randomUUID(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (obj as any).toJSON(["collab_id"]); + sendAdd(json, page); + }); + }; + + if (scrollModeRef.current === "continuous") { + pageFabricRefs.current.forEach((fc, idx) => { + if (fc) broadcastCanvas(fc, idx + 1); + }); + } else { + const fc = fabricRef.current; + if (fc) broadcastCanvas(fc, currentPageRef.current); + } + }, []); + + // Collaboration hook + const { users: collabUsers, connected: collabConnected, sendObjectAdd, sendObjectRemove, sendClear } = + useCollaboration({ pdfId, token: collabToken, onEvent: onRemoteEvent, onPeerJoined: handlePeerJoined }); + + // Keep send functions accessible in stable refs (used in Fabric event handlers) + useEffect(() => { + collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear }; + }, [sendObjectAdd, sendObjectRemove, sendClear]); + // Re-render current page when fit mode changes — moved below renderAllPages definition const generateThumbnails = useCallback(async () => { @@ -173,6 +305,8 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return; setRendering(true); + renderingRef.current = true; + pendingRemoteEvents.current = []; try { // Cancel any in-progress render on the same canvas renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } }); @@ -215,7 +349,7 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!annotationData) { try { - const ann = await api.getAnnotation(pdfId, pageNum); + const ann = await api.getAllAnnotations(pdfId, pageNum); if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) { annotationData = ann.canvas_data; localAnnotations.current[pageNum] = annotationData; @@ -231,6 +365,18 @@ export default function WorkbookViewer({ pdfId }: Props) { if (annotationData) { await new Promise((resolve) => { fc.loadFromJSON(annotationData, () => { + // Mark objects from other users as remote — prevents saving them under current user + const uid = currentUserIdRef.current; + if (uid !== null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.getObjects().forEach((obj: any) => { + if (obj._owner_id != null && obj._owner_id !== uid) { + obj._isRemote = true; + obj.selectable = false; + obj.evented = false; + } + }); + } fc.renderAll(); skipObjectTracking.current = false; resolve(); @@ -240,7 +386,34 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.renderAll(); skipObjectTracking.current = false; } + + // Replay any remote events that arrived while the canvas was being loaded + renderingRef.current = false; + const queued = pendingRemoteEvents.current.splice(0); + const fabric = fabricNSRef.current; + if (fabric && queued.length > 0) { + for (const evt of queued) { + if (evt.type !== "object_add") continue; + if ((evt.page ?? currentPageRef.current) !== pageNum) continue; + const objJson = evt.payload as Record; + skipRemoteRef.current = true; + skipObjectTracking.current = true; + fabric.util.enlivenObjects([objJson], (objects: any[]) => { + objects.forEach((obj: any) => { + obj.collab_id = objJson.collab_id; + obj._isRemote = true; + obj.selectable = false; + obj.evented = false; + fc.add(obj); + }); + fc.renderAll(); + skipRemoteRef.current = false; + skipObjectTracking.current = false; + }); + } + } } finally { + renderingRef.current = false; setRendering(false); } }, @@ -319,7 +492,7 @@ export default function WorkbookViewer({ pdfId }: Props) { let annotationData: object | null = cached ?? null; if (!annotationData) { try { - const ann = await api.getAnnotation(pdfId, i); + const ann = await api.getAllAnnotations(pdfId, i); if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) { annotationData = ann.canvas_data; localAnnotations.current[i] = annotationData; @@ -328,7 +501,22 @@ export default function WorkbookViewer({ pdfId }: Props) { } if (annotationData) { await new Promise((resolve) => { - fc.loadFromJSON(annotationData, () => { fc.renderAll(); resolve(); }); + fc.loadFromJSON(annotationData, () => { + // Mark objects from other users as remote + const uid = currentUserIdRef.current; + if (uid !== null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.getObjects().forEach((obj: any) => { + if (obj._owner_id != null && obj._owner_id !== uid) { + obj._isRemote = true; + obj.selectable = false; + obj.evented = false; + } + }); + } + fc.renderAll(); + resolve(); + }); }); } } @@ -386,11 +574,22 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.wrapperEl.style.pointerEvents = "none"; // start in pan mode } - // Track added objects for undo (skip objects loaded from JSON) + // Single object:added handler: undo tracking + collab broadcast for text // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.on("object:added", (e: any) => { - if (!skipObjectTracking.current) { - addedObjects.current.push(e.target); + if (skipObjectTracking.current) return; + addedObjects.current.push(e.target); + if (skipRemoteRef.current) return; + const obj = e.target; + // Text objects: broadcast final content when editing ends, not the placeholder + if (obj.type === "i-text" || obj.type === "text") { + obj.once("editing:exited", () => { + if (skipRemoteRef.current) return; + if (!obj.collab_id) obj.collab_id = crypto.randomUUID(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (obj as any).toJSON(["collab_id"]); + collabSendRef.current?.objectAdd(json, currentPageRef.current); + }); } }); @@ -401,6 +600,13 @@ export default function WorkbookViewer({ pdfId }: Props) { options.path.set({ opacity: 0.42 }); fc.renderAll(); } + // Broadcast stroke to collaborators + if (!skipRemoteRef.current) { + options.path.collab_id = crypto.randomUUID(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = (options.path as any).toJSON(["collab_id"]); + collabSendRef.current?.objectAdd(obj, currentPageRef.current); + } }); // ── Init PDF.js ─────────────────────────────────────────────────── @@ -572,9 +778,11 @@ export default function WorkbookViewer({ pdfId }: Props) { // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.on("mouse:down", (options: any) => { if (!options.target) return; + const collab_id = options.target.collab_id as string | undefined; fc.remove(options.target); addedObjects.current = addedObjects.current.filter(o => o !== options.target); fc.renderAll(); + if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); }); break; } @@ -618,7 +826,9 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.isDrawingMode = false; fc.selection = false; fc.on("mouse:down", (options: any) => { if (!options.target) return; + const collab_id = options.target.collab_id as string | undefined; fc.remove(options.target); fc.renderAll(); + if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); }); break; } }); @@ -641,7 +851,8 @@ export default function WorkbookViewer({ pdfId }: Props) { const fc = fabricRef.current; if (!fc) return; - localAnnotations.current[currentPage] = fc.toJSON(); + // Preserve _owner_id so remote-object detection works on cache hits + localAnnotations.current[currentPage] = fc.toJSON(["_owner_id"]); await renderPageWithAnnotations(newPage); setCurrentPage(newPage); }, @@ -659,11 +870,21 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!fc || !isReady || saving) return; setSaving(true); - const canvasData = fc.toJSON(); + // Include _owner_id in serialization so we can filter remote objects + const fullCanvasData = fc.toJSON(["_owner_id"]); + // Only save objects that belong to the current user (no _owner_id = own; _owner_id === uid = own) + const uid = currentUserIdRef.current; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ownObjects = (fullCanvasData.objects as any[] ?? []).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (o: any) => o._owner_id == null || o._owner_id === uid + ); + const canvasData = { ...fullCanvasData, objects: ownObjects }; try { await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData }); - localAnnotations.current[currentPage] = canvasData; + // Cache the full display state (including remote objects) so the page looks correct on revisit + localAnnotations.current[currentPage] = fullCanvasData; setSaveNotice(true); setTimeout(() => setSaveNotice(false), 2500); } catch (err: unknown) { @@ -691,6 +912,7 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.clear(); addedObjects.current = []; fc.renderAll(); + collabSendRef.current?.clear(currentPageRef.current); }, []); // ───────────────────────────────────────────────────────────────────────── @@ -954,6 +1176,31 @@ export default function WorkbookViewer({ pdfId }: Props) { {/* Save — fixed right, never scrolls away */}
+ + {/* Collaboration presence */} +
+ + {collabUsers.length > 0 && ( +
+ {collabUsers.slice(0, 5).map(u => ( + + {u.username[0]} + + ))} + {collabUsers.length > 5 && ( + + +{collabUsers.length - 5} + + )} +
+ )} +
+ {saveNotice && ( ✓ Saved )} diff --git a/frontend/src/hooks/useCollaboration.ts b/frontend/src/hooks/useCollaboration.ts new file mode 100644 index 0000000..ab54f19 --- /dev/null +++ b/frontend/src/hooks/useCollaboration.ts @@ -0,0 +1,178 @@ +/** + * useCollaboration + * + * Manages a WebSocket connection to the backend collaboration room for a PDF. + * The hook is intentionally "dumb" about canvas internals — callers supply + * callbacks that do the actual Fabric.js work. + * + * Protocol (all messages are JSON): + * + * → we send: + * { type: "object_add", payload: , page: number } + * { type: "object_remove", payload: { obj_id: string }, page: number } + * { type: "clear", page: number } + * { type: "cursor", payload: { x: number, y: number }, page: number } + * { type: "ping" } + * + * ← we receive (same shapes, plus user_id / username / color injected by server): + * { type: "presence", users: CollabUser[] } + * { type: "object_add", ..., user_id, username, color } + * { type: "object_remove", ..., user_id, username, color } + * { type: "clear", ..., user_id, username, color } + * { type: "cursor", ..., user_id, username, color } + * { type: "pong" } + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface CollabUser { + user_id: number; + username: string; + color: string; +} + +export interface RemoteEvent { + type: "object_add" | "object_remove" | "clear" | "cursor"; + payload?: unknown; + page?: number; + user_id: number; + username: string; + color: string; +} + +interface Options { + pdfId: number; + /** JWT access token — fetched from /api/auth/me or passed in */ + token: string | null; + onEvent: (event: RemoteEvent) => void; + /** + * Called whenever a new peer joins the room (presence list grows). + * The host should respond by re-broadcasting all their current canvas objects + * so late-joining users see pre-existing annotations. + */ + onPeerJoined?: () => void; +} + +const WS_BASE = + typeof window !== "undefined" + ? (window.location.protocol === "https:" ? "wss" : "ws") + + "://" + + // Replace the port (or add one) to reach the backend directly on 8000 + window.location.host.replace(/:\d+$/, "") + ":8000" + : "ws://localhost:8000"; + +const PING_INTERVAL = 25_000; // 25 s keepalive +const RECONNECT_DELAY = 3_000; // 3 s reconnect on unexpected close + +export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Options) { + const [users, setUsers] = useState([]); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(null); + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + + const onPeerJoinedRef = useRef(onPeerJoined); + onPeerJoinedRef.current = onPeerJoined; + + // Track previous user count to detect new peers joining + const prevUserCountRef = useRef(0); + + const pingTimerRef = useRef | null>(null); + const reconnectTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + const connect = useCallback(() => { + if (!token || !mountedRef.current) return; + + const url = `${WS_BASE}/ws/pdf/${pdfId}?token=${encodeURIComponent(token)}`; + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + if (!mountedRef.current) { ws.close(); return; } + setConnected(true); + pingTimerRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); + }, PING_INTERVAL); + }; + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data as string); + if (msg.type === "pong") return; + if (msg.type === "presence") { + const incoming = (msg.users ?? []) as CollabUser[]; + setUsers(incoming); + // If someone new joined (count increased) and we're already in the room, + // notify the caller so they can re-broadcast their current canvas objects. + if ( + prevUserCountRef.current > 0 && + incoming.length > prevUserCountRef.current + ) { + onPeerJoinedRef.current?.(); + } + prevUserCountRef.current = incoming.length; + return; + } + onEventRef.current(msg as RemoteEvent); + } catch { + // ignore malformed messages + } + }; + + ws.onclose = () => { + setConnected(false); + if (pingTimerRef.current) clearInterval(pingTimerRef.current); + if (mountedRef.current) { + reconnectTimerRef.current = setTimeout(connect, RECONNECT_DELAY); + } + }; + + ws.onerror = () => ws.close(); + }, [pdfId, token]); + + useEffect(() => { + mountedRef.current = true; + connect(); + return () => { + mountedRef.current = false; + if (pingTimerRef.current) clearInterval(pingTimerRef.current); + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + wsRef.current?.close(); + }; + }, [connect]); + + /** Send an annotation event to all other clients in the room. */ + const send = useCallback((msg: Record) => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } + }, []); + + const sendObjectAdd = useCallback( + (fabricObject: object, page: number) => + send({ type: "object_add", payload: fabricObject, page }), + [send] + ); + + const sendObjectRemove = useCallback( + (objId: string, page: number) => + send({ type: "object_remove", payload: { obj_id: objId }, page }), + [send] + ); + + const sendClear = useCallback( + (page: number) => send({ type: "clear", page }), + [send] + ); + + const sendCursor = useCallback( + (x: number, y: number, page: number) => + send({ type: "cursor", payload: { x, y }, page }), + [send] + ); + + return { users, connected, sendObjectAdd, sendObjectRemove, sendClear, sendCursor }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9af923b..aedae1d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -40,6 +40,8 @@ export const api = { // Annotations getAnnotation: (pdfId: number, page: number) => request(`/api/annotations/${pdfId}/${page}`), + getAllAnnotations: (pdfId: number, page: number) => + request(`/api/annotations/${pdfId}/${page}/all`), upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) => request(`/api/annotations/${pdfId}/${page}`, { method: "PUT", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ee2241..b0b7497 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -7,6 +7,8 @@ export interface User { export interface PDFItem { id: number; + user_id: number; + owner_username: string; title: string; total_pages: number | null; created_at: string; From 8546d41a2615eaa73dd09bbc9e7bac588c744ea0 Mon Sep 17 00:00:00 2001 From: hienp Date: Wed, 1 Apr 2026 18:29:56 +0700 Subject: [PATCH 05/11] =?UTF-8?q?colaborative=20t=C3=ADch=20h=E1=BB=A3p=20?= =?UTF-8?q?redis=20=C4=91=E1=BB=83=20=C4=91=E1=BB=93ng=20b=E1=BB=99=20to?= =?UTF-8?q?=C3=A0n=20b=E1=BB=99=20annotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/redis_client.py | 75 ++++++++ backend/app/routers/annotations.py | 85 +++++++-- backend/app/routers/ws.py | 2 +- backend/requirements.txt | 1 + docker-compose.yml | 16 ++ frontend/src/components/WorkbookViewer.tsx | 191 +++++++++++++++++++-- frontend/src/hooks/useCollaboration.ts | 22 ++- frontend/src/lib/api.ts | 5 + start-dev.sh | 1 + 9 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 backend/app/redis_client.py diff --git a/backend/app/redis_client.py b/backend/app/redis_client.py new file mode 100644 index 0000000..589db3e --- /dev/null +++ b/backend/app/redis_client.py @@ -0,0 +1,75 @@ +""" +Redis helper — returns a connected client or None when Redis is unavailable. + +Key namespace design (prevents cross-file / cross-user data leakage): + + ann:{pdf_id}:{page_number}:{user_id} + +Every segment is mandatory, so: + - User A opening file 1 never touches User A's data on file 2 + - User A opening file 1 never touches User B's data on file 1 + - Data for page 3 never affects page 7 + +TTL: 24 h — temp entry expires automatically if the user never returns. +On permanent save the entry is deleted immediately. +""" + +import logging +import os +from typing import Optional + +import redis as redis_lib + +logger = logging.getLogger(__name__) + +_client: Optional[redis_lib.Redis] = None +_warned = False # log the "unavailable" warning only once + + +def get_redis() -> Optional[redis_lib.Redis]: + """Return a live Redis client, or None if Redis is unreachable.""" + global _client, _warned + + if _client is not None: + try: + _client.ping() + return _client + except Exception: + _client = None # connection dropped — try to reconnect below + + url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + try: + r: redis_lib.Redis = redis_lib.from_url( + url, + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2, + ) + r.ping() + _client = r + _warned = False + logger.info("Redis connected: %s", url) + return _client + except Exception as exc: + if not _warned: + logger.warning( + "Redis unavailable (%s) — temp annotation cache disabled. " + "Set REDIS_URL or start a local Redis instance to enable it.", + exc, + ) + _warned = True + return None + + +# Key helpers — centralised so there's one place to change the format. +ANN_TTL = 86_400 # 24 hours + + +def ann_temp_key(pdf_id: int, page_number: int, user_id: int) -> str: + """Fully-namespaced Redis key for one user's unsaved canvas on one PDF page.""" + return f"ann:{pdf_id}:{page_number}:{user_id}" + + +def ann_temp_page_pattern(pdf_id: int, page_number: int) -> str: + """Glob pattern to list all users' temp entries for a given page.""" + return f"ann:{pdf_id}:{page_number}:*" diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py index ff010c7..fb17062 100644 --- a/backend/app/routers/annotations.py +++ b/backend/app/routers/annotations.py @@ -1,11 +1,13 @@ from datetime import datetime, timezone +import json -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status as http_status from sqlalchemy.orm import Session from ..database import get_db from ..dependencies import get_current_user from ..models import Annotation, PDF, User +from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis from ..schemas import AnnotationIn, AnnotationOut router = APIRouter(prefix="/annotations", tags=["annotations"]) @@ -82,7 +84,35 @@ def get_all_annotations( .all() ) - if not rows: + # Build user_id → canvas_data from DB (permanent saves) + # Key = pdf_id + page_number + user_id → completely isolated per file/page/user + user_data: dict[int, dict] = {} + for row in rows: + user_data[row.user_id] = dict(row.canvas_data) if row.canvas_data else {} + + # Overlay with Redis temp data — unsaved strokes written by path:created / clear. + # If a user has BOTH a DB record and a temp entry, Redis wins (more recent). + r = get_redis() + if r is not None: + try: + pattern = ann_temp_page_pattern(pdf_id, page_number) + temp_keys: list[str] = r.keys(pattern) + for key in temp_keys: + # key format: ann:{pdf_id}:{page_number}:{user_id} + try: + uid = int(key.split(":")[-1]) + except ValueError: + continue + raw = r.get(key) + if raw: + try: + user_data[uid] = json.loads(raw) + except Exception: + pass + except Exception: + pass # Redis hiccup — fall through with DB data only + + if not user_data: return AnnotationOut( id=0, pdf_id=pdf_id, @@ -91,28 +121,50 @@ def get_all_annotations( updated_at=datetime.now(timezone.utc), ) - # Merge all objects arrays; use canvas metadata (background etc.) from latest record - latest = max(rows, key=lambda r: r.updated_at) - base: dict = dict(latest.canvas_data) if latest.canvas_data else {} + # Canvas-level metadata (version, background…) comes from the latest DB record + latest = max(rows, key=lambda row: row.updated_at) if rows else None + base: dict = dict(latest.canvas_data) if latest and latest.canvas_data else {} + merged_objects: list = [] - for row in rows: - for obj in (row.canvas_data or {}).get("objects", []): - # Tag each object with its owner so the frontend can avoid re-saving - # other users' objects under the current user's record (prevents duplicates). + for uid, canvas in user_data.items(): + for obj in (canvas or {}).get("objects", []): obj_copy = dict(obj) - obj_copy["_owner_id"] = row.user_id + obj_copy["_owner_id"] = uid merged_objects.append(obj_copy) base["objects"] = merged_objects return AnnotationOut( - id=latest.id, + id=latest.id if latest else 0, pdf_id=pdf_id, page_number=page_number, canvas_data=base, - updated_at=latest.updated_at, + updated_at=latest.updated_at if latest else datetime.now(timezone.utc), ) +# ── PUT /api/annotations/{pdf_id}/{page_number}/temp ────────────────────────── +# Writes unsaved canvas state to Redis so every subsequent /all call includes it. +# Called automatically (debounced) by the frontend after each stroke. + +@router.put("/{pdf_id}/{page_number}/temp", status_code=http_status.HTTP_204_NO_CONTENT) +def upsert_temp_annotation( + pdf_id: int, + page_number: int, + body: AnnotationIn, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + _any_pdf_or_404(db, pdf_id) + r = get_redis() + if r is None: + return # Redis unavailable — silently skip + key = ann_temp_key(pdf_id, page_number, current_user.id) + try: + r.setex(key, ANN_TTL, json.dumps(body.canvas_data)) + except Exception: + pass # non-critical + + # ── PUT /api/annotations/{pdf_id}/{page_number} ─────────────────────────────── @router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut) @@ -149,4 +201,13 @@ def upsert_annotation( db.commit() db.refresh(ann) + + # Data is now in DB — remove the Redis temp entry so /all doesn't double-count + r = get_redis() + if r is not None: + try: + r.delete(ann_temp_key(pdf_id, page_number, current_user.id)) + except Exception: + pass + return ann diff --git a/backend/app/routers/ws.py b/backend/app/routers/ws.py index 3048865..79b5f42 100644 --- a/backend/app/routers/ws.py +++ b/backend/app/routers/ws.py @@ -161,7 +161,7 @@ async def pdf_collaboration(websocket: WebSocket, pdf_id: int): continue # Inject sender identity and forward to all other clients - if msg_type in {"object_add", "object_remove", "clear", "cursor"}: + if msg_type in {"object_add", "object_remove", "clear", "cursor", "color_sync"}: msg.update(user_info) await room.broadcast(msg, exclude=websocket) diff --git a/backend/requirements.txt b/backend/requirements.txt index 35f9bce..4ac8e78 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ python-jose[cryptography]==3.3.0 pydantic[email]==2.9.2 python-multipart==0.0.12 alembic==1.14.1 +redis>=5.0 diff --git a/docker-compose.yml b/docker-compose.yml index 0fa17ca..0f92a57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,19 @@ services: networks: - lms_net + # ── Redis (temp annotation cache) ──────────────────────────────────────────── + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --save "" --appendonly no # in-memory only, no disk writes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - lms_net + # ── FastAPI backend ─────────────────────────────────────────────────────────── backend: image: ${BACKEND_IMAGE:-lms-backend:latest} @@ -28,12 +41,15 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy environment: DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-lms_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lms_db} JWT_SECRET_KEY: ${JWT_SECRET_KEY} JWT_ALGORITHM: HS256 ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} UPLOAD_DIR: /uploads + REDIS_URL: redis://redis:6379/0 volumes: - pdf_uploads:/uploads expose: diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx index b522aaa..9cc351a 100644 --- a/frontend/src/components/WorkbookViewer.tsx +++ b/frontend/src/components/WorkbookViewer.tsx @@ -1,10 +1,33 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration"; +// ───────────────────────────────────────────────────────────────────────────── +// Preset color palette (30 hues, 3 brightness levels × 10 hue families) +// Each color is visually distinct to avoid confusion between collaborators. +// ───────────────────────────────────────────────────────────────────────────── + +const PRESET_COLORS = [ + "#e63946", "#9d0208", "#ff6b6b", // Red + "#f4572a", "#a23b17", "#ff9472", // Orange-red + "#f7b731", "#a07800", "#ffe066", // Yellow + "#80b918", "#4a6c0a", "#b5e853", // Yellow-green + "#2dc653", "#0a7a2e", "#6ee08a", // Green + "#00b4d8", "#005f73", "#48cae4", // Cyan + "#4361ee", "#1a237e", "#7b9cff", // Blue + "#7209b7", "#3a0068", "#b56aff", // Purple + "#e040fb", "#880e62", "#f48fff", // Magenta + "#ff6392", "#a3003f", "#ffadc5", // Rose +] as const; + +function userIdToPresetColor(id: number): string { + return PRESET_COLORS[id % PRESET_COLORS.length]; +} + // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── @@ -122,6 +145,14 @@ export default function WorkbookViewer({ pdfId }: Props) { // Buffer for remote events that arrive while renderPageWithAnnotations is in progress const renderingRef = useRef(false); const pendingRemoteEvents = useRef([]); + // True once api.me() has resolved and pen color has been set from user id + const [userColorReady, setUserColorReady] = useState(false); + + // Color picker + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [colorPickerPos, setColorPickerPos] = useState<{ top: number; left: number } | null>(null); + const colorBtnRef = useRef(null); + const colorPickerRef = useRef(null); // Ref for auto-scrolling thumbnail panel const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]); @@ -137,7 +168,12 @@ export default function WorkbookViewer({ pdfId }: Props) { // Collab: stable ref to send functions (set after hook initializes) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any } | null>(null); + const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any; colorSync: (c: string) => void } | null>(null); + + // Temp-cache sync: debounce unsaved canvas state to Redis (1.5 s after last stroke) + const tempSyncTimerRef = useRef | null>(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const syncTempCanvasRef = useRef<(page: number) => void>(() => {}); // Keep currentPageRef in sync useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]); @@ -149,6 +185,27 @@ export default function WorkbookViewer({ pdfId }: Props) { useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]); useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]); + // Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId + useEffect(() => { + syncTempCanvasRef.current = (page: number) => { + const fc = fabricRef.current; + if (!fc) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fullData: any = fc.toJSON(["_owner_id"]); + const uid = currentUserIdRef.current; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ownObjects = ((fullData.objects as any[]) ?? []).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (o: any) => o._owner_id == null || o._owner_id === uid + ); + const canvasData = { ...fullData, objects: ownObjects }; + if (tempSyncTimerRef.current) clearTimeout(tempSyncTimerRef.current); + tempSyncTimerRef.current = setTimeout(() => { + api.upsertTempAnnotation(pdfId, page, { canvas_data: canvasData }).catch(() => {}); + }, 1500); + }; + }, [pdfId]); + // Auto-scroll thumbnail sidebar to keep current page visible useEffect(() => { if (showThumbnails) { @@ -166,9 +223,32 @@ export default function WorkbookViewer({ pdfId }: Props) { // Fetch current user id — used to distinguish own from remote objects when merging /all useEffect(() => { - api.me().then((u) => { currentUserIdRef.current = u.id; }).catch(() => {}); + api.me().then((u) => { + currentUserIdRef.current = u.id; + // Assign deterministic color from preset palette based on user id + const assigned = userIdToPresetColor(u.id); + setPenColor(assigned); + penColorRef.current = assigned; + setUserColorReady(true); // unblocks the color-sync broadcast + }).catch(() => {}); }, []); + // Close color picker when clicking outside + useEffect(() => { + if (!colorPickerOpen) return; + const handler = (e: MouseEvent) => { + if ( + colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node) && + colorBtnRef.current && !colorBtnRef.current.contains(e.target as Node) + ) { + setColorPickerOpen(false); + setColorPickerPos(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [colorPickerOpen]); + // Handle incoming remote annotation events const onRemoteEvent = useCallback((event: RemoteEvent) => { const fabric = fabricNSRef.current; @@ -260,16 +340,49 @@ export default function WorkbookViewer({ pdfId }: Props) { const fc = fabricRef.current; if (fc) broadcastCanvas(fc, currentPageRef.current); } + // Also broadcast our current pen color so the new peer can disable our swatch + collabSendRef.current?.colorSync(penColorRef.current); }, []); // Collaboration hook - const { users: collabUsers, connected: collabConnected, sendObjectAdd, sendObjectRemove, sendClear } = + const { users: collabUsers, connected: collabConnected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendColorSync } = useCollaboration({ pdfId, token: collabToken, onEvent: onRemoteEvent, onPeerJoined: handlePeerJoined }); // Keep send functions accessible in stable refs (used in Fabric event handlers) useEffect(() => { - collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear }; - }, [sendObjectAdd, sendObjectRemove, sendClear]); + collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync }; + }, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]); + + // Broadcast pen color only when BOTH WS is connected AND user color has been fetched. + // This prevents both users broadcasting the same default "#e63946" before api.me() resolves. + // Also re-broadcasts whenever the user manually picks a new color. + useEffect(() => { + if (collabConnected && userColorReady) { + sendColorSync(penColor); + } + }, [collabConnected, userColorReady, penColor, sendColorSync]); + + // If our color collides with a peer's color, pick the first free preset color and rebroadcast. + useEffect(() => { + if (!collabConnected || !userColorReady) return; + const uid = currentUserIdRef.current; + if (uid == null) return; + + const taken = new Set( + Object.entries(peerColors) + .filter(([id]) => Number(id) !== uid) + .map(([, c]) => c.toLowerCase()) + ); + const current = penColor.toLowerCase(); + if (!taken.has(current)) return; + + const replacement = PRESET_COLORS.find(c => !taken.has(c.toLowerCase())) ?? penColor; + if (replacement.toLowerCase() === current) return; + + setPenColor(replacement); + penColorRef.current = replacement; + sendColorSync(replacement); + }, [collabConnected, userColorReady, peerColors, penColor, sendColorSync]); // Re-render current page when fit mode changes — moved below renderAllPages definition @@ -607,6 +720,14 @@ export default function WorkbookViewer({ pdfId }: Props) { const obj = (options.path as any).toJSON(["collab_id"]); collabSendRef.current?.objectAdd(obj, currentPageRef.current); } + // Sync unsaved canvas to Redis so late-joining users see this stroke + syncTempCanvasRef.current(currentPageRef.current); + }); + + // Object removed (eraser / undo) — sync temp cache + fc.on("object:removed", () => { + if (skipObjectTracking.current || skipRemoteRef.current) return; + syncTempCanvasRef.current(currentPageRef.current); }); // ── Init PDF.js ─────────────────────────────────────────────────── @@ -883,6 +1004,8 @@ export default function WorkbookViewer({ pdfId }: Props) { try { await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData }); + // Data is now in DB — cancel any pending debounce (backend deletes Redis key too) + if (tempSyncTimerRef.current) { clearTimeout(tempSyncTimerRef.current); tempSyncTimerRef.current = null; } // Cache the full display state (including remote objects) so the page looks correct on revisit localAnnotations.current[currentPage] = fullCanvasData; setSaveNotice(true); @@ -913,6 +1036,8 @@ export default function WorkbookViewer({ pdfId }: Props) { addedObjects.current = []; fc.renderAll(); collabSendRef.current?.clear(currentPageRef.current); + // Push empty canvas to Redis so other users see the clear immediately on next load + syncTempCanvasRef.current(currentPageRef.current); }, []); // ───────────────────────────────────────────────────────────────────────── @@ -1129,12 +1254,22 @@ export default function WorkbookViewer({ pdfId }: Props) {
- setPenColor(e.target.value)} - title="Color" - className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white flex-shrink-0" + {/* Color picker button */} +
+ + {/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */} + {colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal( +
+

Chọn màu bút

+
+ {PRESET_COLORS.map((c) => { + const takenByPeer = Object.entries(peerColors) + .filter(([uid]) => Number(uid) !== currentUserIdRef.current) + .some(([, pc]) => pc.toLowerCase() === c.toLowerCase()); + const isSelected = penColor.toLowerCase() === c.toLowerCase(); + return ( +
+
, + document.body + )}
); } diff --git a/frontend/src/hooks/useCollaboration.ts b/frontend/src/hooks/useCollaboration.ts index ab54f19..df9ab01 100644 --- a/frontend/src/hooks/useCollaboration.ts +++ b/frontend/src/hooks/useCollaboration.ts @@ -32,7 +32,7 @@ export interface CollabUser { } export interface RemoteEvent { - type: "object_add" | "object_remove" | "clear" | "cursor"; + type: "object_add" | "object_remove" | "clear" | "cursor" | "color_sync"; payload?: unknown; page?: number; user_id: number; @@ -67,6 +67,7 @@ const RECONNECT_DELAY = 3_000; // 3 s reconnect on unexpected close export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Options) { const [users, setUsers] = useState([]); const [connected, setConnected] = useState(false); + const [peerColors, setPeerColors] = useState>({}); const wsRef = useRef(null); const onEventRef = useRef(onEvent); @@ -101,9 +102,21 @@ export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Option try { const msg = JSON.parse(e.data as string); if (msg.type === "pong") return; + if (msg.type === "color_sync") { + const { user_id, color } = msg as { user_id: number; color: string }; + setPeerColors(prev => ({ ...prev, [user_id]: color })); + return; + } if (msg.type === "presence") { const incoming = (msg.users ?? []) as CollabUser[]; setUsers(incoming); + // Update peer color map from presence payload (includes our own entry). + setPeerColors( + incoming.reduce>((acc, u) => { + acc[u.user_id] = u.color; + return acc; + }, {}) + ); // If someone new joined (count increased) and we're already in the room, // notify the caller so they can re-broadcast their current canvas objects. if ( @@ -174,5 +187,10 @@ export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Option [send] ); - return { users, connected, sendObjectAdd, sendObjectRemove, sendClear, sendCursor }; + const sendColorSync = useCallback( + (color: string) => send({ type: "color_sync", color }), + [send] + ); + + return { users, connected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendCursor, sendColorSync }; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index aedae1d..31a99b7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,4 +47,9 @@ export const api = { method: "PUT", body: JSON.stringify(body), }), + upsertTempAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) => + request(`/api/annotations/${pdfId}/${page}/temp`, { + method: "PUT", + body: JSON.stringify(body), + }), }; diff --git a/start-dev.sh b/start-dev.sh index 611bf2f..4bd10cf 100755 --- a/start-dev.sh +++ b/start-dev.sh @@ -66,6 +66,7 @@ start_backend() { JWT_SECRET_KEY="$JWT_SECRET_KEY" \ ACCESS_TOKEN_EXPIRE_MINUTES="$ACCESS_TOKEN_EXPIRE_MINUTES" \ UPLOAD_DIR="$UPLOAD_DIR" \ + REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" \ ${VENV}uvicorn main:app \ --host 0.0.0.0 \ --port "$BACKEND_PORT" \ From 386871bd4ac0bc8dfba02bd2b5b09fc57852cb66 Mon Sep 17 00:00:00 2001 From: hienp Date: Wed, 1 Apr 2026 19:26:47 +0700 Subject: [PATCH 06/11] =?UTF-8?q?th=E1=BB=B1c=20hi=E1=BB=87n=20ph=C3=A2n?= =?UTF-8?q?=20quy=E1=BB=81n=20admin=20gi=C3=A1o=20vi=C3=AAn=20v=C3=A0=20h?= =?UTF-8?q?=E1=BB=8Dc=20sinh=20xong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alembic/versions/0002_add_role_status.py | 49 +++ backend/app/dependencies.py | 33 +- backend/app/main.py | 3 +- backend/app/models.py | 17 + backend/app/routers/admin.py | 83 +++++ backend/app/routers/annotations.py | 95 ++++- backend/app/routers/auth.py | 26 +- backend/app/schemas.py | 13 + frontend/src/app/admin/page.tsx | 338 ++++++++++++++++++ frontend/src/app/dashboard/page.tsx | 8 + frontend/src/components/WorkbookViewer.tsx | 134 +++++-- frontend/src/lib/api.ts | 20 ++ frontend/src/types/index.ts | 5 + readme.md | 9 +- 14 files changed, 796 insertions(+), 37 deletions(-) create mode 100644 backend/alembic/versions/0002_add_role_status.py create mode 100644 backend/app/routers/admin.py create mode 100644 frontend/src/app/admin/page.tsx diff --git a/backend/alembic/versions/0002_add_role_status.py b/backend/alembic/versions/0002_add_role_status.py new file mode 100644 index 0000000..b53c933 --- /dev/null +++ b/backend/alembic/versions/0002_add_role_status.py @@ -0,0 +1,49 @@ +"""add role and status to users + +Revision ID: 0002_add_role_status +Revises: 0001_initial_schema +Create Date: 2026-04-01 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "0002_add_role_status" +down_revision = "0001" +branch_labels = None +depends_on = None + +user_role = postgresql.ENUM("admin", "teacher", "student", name="user_role") +user_status = postgresql.ENUM("pending", "approved", "rejected", name="user_status") + + +def upgrade() -> None: + user_role.create(op.get_bind(), checkfirst=True) + user_status.create(op.get_bind(), checkfirst=True) + + op.add_column( + "users", + sa.Column( + "role", + sa.Enum("admin", "teacher", "student", name="user_role"), + nullable=False, + server_default="student", + ), + ) + op.add_column( + "users", + sa.Column( + "status", + sa.Enum("pending", "approved", "rejected", name="user_status"), + nullable=False, + server_default="pending", + ), + ) + + +def downgrade() -> None: + op.drop_column("users", "status") + op.drop_column("users", "role") + user_role.drop(op.get_bind(), checkfirst=True) + user_status.drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 431de48..e17a225 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -3,7 +3,7 @@ from jose import JWTError from sqlalchemy.orm import Session from .database import get_db -from .models import User +from .models import User, UserRole, UserStatus from .security import decode_access_token @@ -33,4 +33,35 @@ def get_current_user( if user is None: raise credentials_exception + # Guard: approved users only (pending/rejected cannot use the API) + if user.status != UserStatus.approved: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Your account is pending admin approval.", + ) + return user + + +def require_role(*roles: UserRole): + """ + Returns a FastAPI dependency that asserts the current user has one of the + given roles. Usage: Depends(require_role(UserRole.admin, UserRole.teacher)) + """ + def _check(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action.", + ) + return current_user + return _check + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != UserRole.admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required.", + ) + return current_user diff --git a/backend/app/main.py b/backend/app/main.py index c9fd14a..3090492 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routers import annotations, auth, pdfs, ws +from .routers import admin, annotations, auth, pdfs, ws @asynccontextmanager @@ -24,6 +24,7 @@ app.add_middleware( ) app.include_router(auth.router, prefix="/api") +app.include_router(admin.router, prefix="/api") app.include_router(pdfs.router, prefix="/api") app.include_router(annotations.router, prefix="/api") app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path) diff --git a/backend/app/models.py b/backend/app/models.py index ba274ce..5a174ad 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,9 @@ from datetime import datetime, timezone +import enum + from sqlalchemy import ( Column, + Enum, Integer, String, Text, @@ -13,6 +16,18 @@ from sqlalchemy.orm import relationship from .database import Base +class UserRole(str, enum.Enum): + admin = "admin" + teacher = "teacher" + student = "student" + + +class UserStatus(str, enum.Enum): + pending = "pending" + approved = "approved" + rejected = "rejected" + + class User(Base): __tablename__ = "users" @@ -20,6 +35,8 @@ class User(Base): username = Column(String(64), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) + role = Column(Enum(UserRole, name="user_role"), nullable=False, default=UserRole.student) + status = Column(Enum(UserStatus, name="user_status"), nullable=False, default=UserStatus.pending) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) # Relationships diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..d12748b --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..dependencies import require_admin +from ..models import User, UserStatus +from ..schemas import UserApprove, UserRoleUpdate, UserOut + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +# ── GET /api/admin/users ────────────────────────────────────────────────────── + +@router.get("/users", response_model=list[UserOut]) +def list_users( + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Return all users (any role / status). Admin only.""" + return db.query(User).order_by(User.created_at.desc()).all() + + +# ── PATCH /api/admin/users/{user_id}/status ─────────────────────────────────── + +@router.patch("/users/{user_id}/status", response_model=UserOut) +def update_user_status( + user_id: int, + body: UserApprove, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Approve or reject a user account. Admin only.""" + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot change your own status.") + + user.status = body.status + db.commit() + db.refresh(user) + return user + + +# ── PATCH /api/admin/users/{user_id}/role ───────────────────────────────────── + +@router.patch("/users/{user_id}/role", response_model=UserOut) +def update_user_role( + user_id: int, + body: UserRoleUpdate, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Change a user's role. Admin only.""" + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot change your own role.") + + user.role = body.role + db.commit() + db.refresh(user) + return user + + +# ── DELETE /api/admin/users/{user_id} ───────────────────────────────────────── + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Permanently delete a user and all their data. Admin only.""" + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account.") + + db.delete(user) + db.commit() diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py index fb17062..05d3336 100644 --- a/backend/app/routers/annotations.py +++ b/backend/app/routers/annotations.py @@ -6,10 +6,50 @@ from sqlalchemy.orm import Session from ..database import get_db from ..dependencies import get_current_user -from ..models import Annotation, PDF, User +from ..models import Annotation, PDF, User, UserRole from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis from ..schemas import AnnotationIn, AnnotationOut + +def _do_delete_annotation(ann: Annotation, current_user: User, db: Session) -> None: + """Shared RBAC + delete logic, used by both delete endpoints.""" + from ..models import UserRole # avoid circular at module level + + ann_owner = db.get(User, ann.user_id) + + if current_user.role == UserRole.admin: + pass # full access + elif current_user.role == UserRole.teacher: + if ann.user_id == current_user.id: + pass # own annotation + elif ann_owner and ann_owner.role == UserRole.admin: + raise HTTPException( + status_code=http_status.HTTP_403_FORBIDDEN, + detail="Teachers cannot delete annotations belonging to an admin.", + ) + else: + pass # can delete student annotations (or annotations of deleted users) + else: # student + if ann.user_id != current_user.id: + raise HTTPException( + status_code=http_status.HTTP_403_FORBIDDEN, + detail="You can only delete your own annotations.", + ) + + pdf_id = ann.pdf_id + page_number = ann.page_number + user_id = ann.user_id + db.delete(ann) + db.commit() + + # Remove Redis temp entry so /all doesn't serve stale data + r = get_redis() + if r is not None: + try: + r.delete(ann_temp_key(pdf_id, page_number, user_id)) + except Exception: + pass + router = APIRouter(prefix="/annotations", tags=["annotations"]) @@ -211,3 +251,56 @@ def upsert_annotation( pass return ann + + +# ── DELETE /api/annotations/{annotation_id} ─────────────────────────────────── + +@router.delete("/{annotation_id}", status_code=http_status.HTTP_204_NO_CONTENT) +def delete_annotation( + annotation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Delete annotation by primary key (RBAC enforced).""" + ann = db.get(Annotation, annotation_id) + if not ann: + raise HTTPException(status_code=404, detail="Annotation not found.") + _do_delete_annotation(ann, current_user, db) + + +# ── DELETE /api/annotations/{pdf_id}/{page_number}/user/{target_user_id} ────── + +@router.delete("/{pdf_id}/{page_number}/user/{target_user_id}", status_code=http_status.HTTP_204_NO_CONTENT) +def delete_user_page_annotation( + pdf_id: int, + page_number: int, + target_user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Delete a specific user's annotation on a given page (RBAC enforced). + Useful for teachers clearing a student's page without knowing the annotation id. + Returns 204 even when no annotation exists (idempotent). + """ + _any_pdf_or_404(db, pdf_id) + ann = ( + db.query(Annotation) + .filter( + Annotation.pdf_id == pdf_id, + Annotation.page_number == page_number, + Annotation.user_id == target_user_id, + ) + .first() + ) + if ann is None: + # Nothing in DB — still clean up any Redis temp entry + r = get_redis() + if r is not None: + try: + r.delete(ann_temp_key(pdf_id, page_number, target_user_id)) + except Exception: + pass + return + _do_delete_annotation(ann, current_user, db) + diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 3f63346..5f1d3be 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -4,8 +4,8 @@ import os from ..database import get_db from ..dependencies import get_current_user -from ..models import User -from ..schemas import UserLogin, UserOut, UserRegister +from ..models import User, UserRole, UserStatus +from ..schemas import UserLogin, UserOut, UserRegister, UserApprove from ..security import ( ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, @@ -42,16 +42,25 @@ def register(body: UserRegister, response: Response, db: Session = Depends(get_d if db.query(User).filter(User.email == body.email).first(): raise HTTPException(status_code=400, detail="Email already registered.") + # Admin registers as approved immediately; others start as pending + initial_status = ( + UserStatus.approved if body.role == UserRole.admin else UserStatus.pending + ) + user = User( username=body.username, email=body.email, password_hash=hash_password(body.password), + role=body.role, + status=initial_status, ) db.add(user) db.commit() db.refresh(user) - _set_auth_cookie(response, user.id) + # Only set auth cookie if immediately approved (admin self-registration) + if user.status == UserStatus.approved: + _set_auth_cookie(response, user.id) return user @@ -68,6 +77,17 @@ def login(body: UserLogin, response: Response, db: Session = Depends(get_db)): if not user or not password_ok: raise HTTPException(status_code=401, detail="Invalid username or password.") + if user.status == UserStatus.pending: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Your account is pending admin approval.", + ) + if user.status == UserStatus.rejected: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Your account has been rejected.", + ) + _set_auth_cookie(response, user.id) return user diff --git a/backend/app/schemas.py b/backend/app/schemas.py index effa71a..347b4f5 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,6 +2,8 @@ from datetime import datetime from pydantic import BaseModel, EmailStr, field_validator import re +from .models import UserRole, UserStatus + # ── Auth ───────────────────────────────────────────────────────────────────── @@ -9,6 +11,7 @@ class UserRegister(BaseModel): username: str email: EmailStr password: str + role: UserRole = UserRole.student @field_validator("username") @classmethod @@ -37,11 +40,21 @@ class UserOut(BaseModel): id: int username: str email: str + role: UserRole + status: UserStatus created_at: datetime model_config = {"from_attributes": True} +class UserApprove(BaseModel): + status: UserStatus + + +class UserRoleUpdate(BaseModel): + role: UserRole + + class TokenPayload(BaseModel): sub: int # user id exp: int diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..d3f54af --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { api } from "@/lib/api"; +import type { User, UserRole, UserStatus } from "@/types"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const STATUS_LABELS: Record = { + pending: "Chờ duyệt", + approved: "Đã duyệt", + rejected: "Từ chối", +}; + +const ROLE_LABELS: Record = { + admin: "Admin", + teacher: "Giáo viên", + student: "Học viên", +}; + +const STATUS_COLORS: Record = { + pending: "bg-yellow-100 text-yellow-800", + approved: "bg-green-100 text-green-800", + rejected: "bg-red-100 text-red-800", +}; + +const ROLE_COLORS: Record = { + admin: "bg-purple-100 text-purple-800", + teacher: "bg-blue-100 text-blue-800", + student: "bg-gray-100 text-gray-700", +}; + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function AdminPage() { + const router = useRouter(); + const [me, setMe] = useState(null); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(null); // userId being mutated + const [filterStatus, setFilterStatus] = useState("all"); + const [filterRole, setFilterRole] = useState("all"); + const [search, setSearch] = useState(""); + + // ── Auth guard: admin only ───────────────────────────────────────────────── + useEffect(() => { + async function init() { + try { + const me = await api.me(); + if (me.role !== "admin") { router.replace("/dashboard"); return; } + setMe(me); + const list = await api.adminListUsers(); + setUsers(list); + } catch { + router.replace("/login"); + } finally { + setLoading(false); + } + } + init(); + }, [router]); + + // ── Mutate helpers ───────────────────────────────────────────────────────── + const updateUser = useCallback((updated: User) => { + setUsers(prev => prev.map(u => u.id === updated.id ? updated : u)); + }, []); + + const handleStatusChange = useCallback(async (userId: number, status: UserStatus) => { + setBusy(userId); + try { + const updated = await api.adminUpdateStatus(userId, status); + updateUser(updated); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to update status."); + } finally { + setBusy(null); + } + }, [updateUser]); + + const handleRoleChange = useCallback(async (userId: number, role: UserRole) => { + setBusy(userId); + try { + const updated = await api.adminUpdateRole(userId, role); + updateUser(updated); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to update role."); + } finally { + setBusy(null); + } + }, [updateUser]); + + const handleDelete = useCallback(async (userId: number, username: string) => { + if (!confirm(`Xoá tài khoản "${username}" và toàn bộ dữ liệu? Không thể hoàn tác.`)) return; + setBusy(userId); + try { + await api.adminDeleteUser(userId); + setUsers(prev => prev.filter(u => u.id !== userId)); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to delete user."); + } finally { + setBusy(null); + } + }, []); + + // ── Filtered list ────────────────────────────────────────────────────────── + const filtered = users.filter(u => { + if (filterStatus !== "all" && u.status !== filterStatus) return false; + if (filterRole !== "all" && u.role !== filterRole) return false; + if (search) { + const q = search.toLowerCase(); + if (!u.username.toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false; + } + return true; + }); + + const pendingCount = users.filter(u => u.status === "pending").length; + + // ── Loading ──────────────────────────────────────────────────────────────── + if (loading) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ {/* ── Navbar ──────────────────────────────────────────────────────────── */} +
+
+
+ + PDF LMS + + Admin + +
+
+ {me?.username} + +
+
+
+ +
+ {/* ── Page title + stats ─────────────────────────────────────────────── */} +
+
+

Quản lý người dùng

+

{users.length} tài khoản tổng cộng

+
+ {pendingCount > 0 && ( +
setFilterStatus("pending")} + > + + + + {pendingCount} tài khoản chờ duyệt +
+ )} +
+ + {/* ── Error banner ──────────────────────────────────────────────────── */} + {error && ( +
+ {error} + +
+ )} + + {/* ── Filters ───────────────────────────────────────────────────────── */} +
+ setSearch(e.target.value)} + className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500" + /> + + + {(filterStatus !== "all" || filterRole !== "all" || search) && ( + + )} +
+ + {/* ── Table ────────────────────────────────────────────────────────── */} +
+ {filtered.length === 0 ? ( +
Không tìm thấy tài khoản nào.
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map(u => { + const isSelf = u.id === me?.id; + const isLoading = busy === u.id; + return ( + + {/* ID */} + + + {/* User info */} + + + {/* Role selector */} + + + {/* Status selector */} + + + {/* Created at */} + + + {/* Actions */} + + + ); + })} + +
IDNgười dùngVai tròTrạng tháiNgày đăng kýHành động
{u.id} +
{u.username}
+
{u.email}
+
+ {isSelf ? ( + + {ROLE_LABELS[u.role]} + + ) : ( + + )} + + {isSelf ? ( + + {STATUS_LABELS[u.status]} + + ) : ( + + )} + + {new Date(u.created_at).toLocaleDateString("vi-VN")} + + {isLoading ? ( + + + + + ) : isSelf ? ( + + ) : ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index a52382e..988668d 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -71,6 +71,14 @@ export default function DashboardPage() { PDF LMS
{user?.username} + {user?.role === "admin" && ( + + )} + )} +
+ ); + })} {collabUsers.length > 5 && ( +{collabUsers.length - 5} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 31a99b7..8427164 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,4 +52,24 @@ export const api = { method: "PUT", body: JSON.stringify(body), }), + deleteAnnotation: (annotationId: number) => + request(`/api/annotations/${annotationId}`, { method: "DELETE" }), + clearUserAnnotation: (pdfId: number, page: number, userId: number) => + request(`/api/annotations/${pdfId}/${page}/user/${userId}`, { method: "DELETE" }), + + // Admin + adminListUsers: () => + request("/api/admin/users"), + adminUpdateStatus: (userId: number, status: import("@/types").UserStatus) => + request(`/api/admin/users/${userId}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }), + adminUpdateRole: (userId: number, role: import("@/types").UserRole) => + request(`/api/admin/users/${userId}/role`, { + method: "PATCH", + body: JSON.stringify({ role }), + }), + adminDeleteUser: (userId: number) => + request(`/api/admin/users/${userId}`, { method: "DELETE" }), }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b0b7497..6ec0905 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,7 +1,12 @@ +export type UserRole = "admin" | "teacher" | "student"; +export type UserStatus = "pending" | "approved" | "rejected"; + export interface User { id: number; username: string; email: string; + role: UserRole; + status: UserStatus; created_at: string; } diff --git a/readme.md b/readme.md index f3f2846..60d7c79 100644 --- a/readme.md +++ b/readme.md @@ -133,4 +133,11 @@ https://github.com/?tab=packages ```bash python3 -c "import secrets; print(secrets.token_hex(32))" -``` \ No newline at end of file +``` + + +Field Value +username admin +password Admin@12345 +role admin +status approved From 217e2f97122cc8aed9a02b2dd95e85ee59198e59 Mon Sep 17 00:00:00 2001 From: hienp Date: Wed, 1 Apr 2026 20:13:48 +0700 Subject: [PATCH 07/11] =?UTF-8?q?ho=C3=A0n=20th=C3=A0nh=20ch=E1=BB=A9c=20n?= =?UTF-8?q?=C4=83ng=20hi=E1=BB=87n=20username=20annotation=20c=E1=BB=A7a?= =?UTF-8?q?=20t=E1=BB=ABng=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/auth.py | 16 +- backend/app/schemas.py | 12 ++ frontend/src/app/admin/page.tsx | 9 + frontend/src/app/change-password/page.tsx | 200 +++++++++++++++++++++ frontend/src/app/dashboard/page.tsx | 9 + frontend/src/components/WorkbookViewer.tsx | 138 +++++++++++++- frontend/src/lib/api.ts | 2 + 7 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 frontend/src/app/change-password/page.tsx diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5f1d3be..317812f 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,7 +5,7 @@ import os from ..database import get_db from ..dependencies import get_current_user from ..models import User, UserRole, UserStatus -from ..schemas import UserLogin, UserOut, UserRegister, UserApprove +from ..schemas import UserLogin, UserOut, UserRegister, UserApprove, ChangePassword from ..security import ( ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, @@ -117,3 +117,17 @@ def get_token(access_token: str | None = Cookie(default=None)): if not access_token: raise HTTPException(status_code=401, detail="Not authenticated.") return {"access_token": access_token} + + +# ── POST /auth/change-password ──────────────────────────────────────────────── + +@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT) +def change_password( + body: ChangePassword, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if not verify_password(body.current_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không đúng.") + current_user.password_hash = hash_password(body.new_password) + db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 347b4f5..b5e77cf 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -55,6 +55,18 @@ class UserRoleUpdate(BaseModel): role: UserRole +class ChangePassword(BaseModel): + current_password: str + new_password: str + + @field_validator("new_password") + @classmethod + def password_strength(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters.") + return v + + class TokenPayload(BaseModel): sub: int # user id exp: int diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index d3f54af..2b66ba1 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -151,6 +151,15 @@ export default function AdminPage() {
{me?.username} + +

Đổi mật khẩu

+
+ + {/* Success */} + {success && ( +
+ + + + Đổi mật khẩu thành công! +
+ )} + + {/* Error */} + {error && ( +
+ {error} + +
+ )} + +
+ {/* Current password */} +
+ +
+ setForm(f => ({ ...f, current_password: e.target.value }))} + required + className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + placeholder="Nhập mật khẩu hiện tại" + /> + +
+
+ + {/* New password */} +
+ +
+ setForm(f => ({ ...f, new_password: e.target.value }))} + required + minLength={8} + className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + placeholder="Ít nhất 8 ký tự" + /> + +
+ {/* Strength indicator */} + {form.new_password && ( +
+ {[1,2,3,4].map(i => { + const len = form.new_password.length; + const hasUpper = /[A-Z]/.test(form.new_password); + const hasSpecial = /[^A-Za-z0-9]/.test(form.new_password); + const score = (len >= 8 ? 1 : 0) + (len >= 12 ? 1 : 0) + (hasUpper ? 1 : 0) + (hasSpecial ? 1 : 0); + const active = i <= score; + const color = score <= 1 ? "bg-red-400" : score === 2 ? "bg-yellow-400" : score === 3 ? "bg-blue-400" : "bg-green-500"; + return
; + })} +
+ )} +
+ + {/* Confirm password */} +
+ +
+ setForm(f => ({ ...f, confirm_password: e.target.value }))} + required + className={`w-full border rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:ring-1 ${ + form.confirm_password && form.confirm_password !== form.new_password + ? "border-red-400 focus:border-red-400 focus:ring-red-300" + : "border-gray-300 focus:border-blue-500 focus:ring-blue-500" + }`} + placeholder="Nhập lại mật khẩu mới" + /> + +
+ {form.confirm_password && form.confirm_password !== form.new_password && ( +

Mật khẩu không khớp.

+ )} +
+ + + +
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 988668d..87bd68c 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -79,6 +79,15 @@ export default function DashboardPage() { Quản trị )} + +
+ )} + +
+ {/* Download backup */} + + + {/* Restore */} + +
+

+ ⚠️ Khôi phục sẽ ghi đè toàn bộ dữ liệu hiện tại (người dùng, PDF, annotation). Hãy tải backup trước khi khôi phục. +

+
+ {/* ── Table ────────────────────────────────────────────────────────── */}
{filtered.length === 0 ? ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 21cbd3c..aa6fce1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -74,4 +74,31 @@ export const api = { }), adminDeleteUser: (userId: number) => request(`/api/admin/users/${userId}`, { method: "DELETE" }), + + // Backup / Restore + /** Triggers pg_dump + file pack; returns a Blob for the browser to download. */ + adminDownloadBackup: async (): Promise => { + const res = await fetch("/api/admin/backup/download", { credentials: "include" }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail?.detail ?? "Backup failed"); + } + return res.blob(); + }, + + /** Upload a backup ZIP to restore the system. */ + adminRestoreBackup: async (file: File): Promise<{ message: string; backup_created_at: string }> => { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/admin/backup/restore", { + method: "POST", + credentials: "include", + body: form, + }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail?.detail ?? "Restore failed"); + } + return res.json(); + }, }; diff --git a/push-ghcr.sh b/push-ghcr.sh index 03dd5ee..dfebc77 100755 --- a/push-ghcr.sh +++ b/push-ghcr.sh @@ -1,62 +1,145 @@ #!/bin/bash # Push LMS images to GitHub Container Registry (GHCR) -# Usage: ./push-ghcr.sh [tag] +# Auto-bumps the patch version in VERSION file on every successful push. +# +# Usage: +# ./push-ghcr.sh # auto-bump patch: 1.2.3 → 1.2.4 +# ./push-ghcr.sh minor # bump minor: 1.2.3 → 1.3.0 +# ./push-ghcr.sh major # bump major: 1.2.3 → 2.0.0 +# ./push-ghcr.sh 2.5.1 # use exact version (no auto-bump) +# ./push-ghcr.sh --no-cache # force full Docker rebuild +# # Reads GITHUB_USER and GITHUB_TOKEN from .env +# Version is stored in ./VERSION -set -e +set -euo pipefail cd "$(dirname "$0")" -# Load .env -if [ -f .env ]; then - export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs) -fi +# ── Colour helpers ──────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +ok() { echo -e "${GREEN}✔${RESET} $*"; } +info() { echo -e "${CYAN}▶${RESET} $*"; } +warn() { echo -e "${YELLOW}⚠${RESET} $*"; } +fail() { echo -e "\033[0;31m✘${RESET} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; } -TAG="${1:-latest}" +# ── Parse arguments ─────────────────────────────────────────────────────────── +BUMP_TYPE="patch" +EXPLICIT_TAG="" +NO_CACHE="" + +for arg in "$@"; do + case $arg in + major|minor|patch) BUMP_TYPE=$arg ;; + --no-cache) NO_CACHE="--no-cache" ;; + [0-9]*.[0-9]*.[0-9]*) EXPLICIT_TAG=$arg ;; # exact semver passed + *) fail "Unknown argument: $arg" ;; + esac +done + +# ── Load .env ───────────────────────────────────────────────────────────────── +[ -f .env ] || fail ".env not found. Copy .env.example → .env first." +# shellcheck disable=SC2046 +export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs) GITHUB_USER="${GITHUB_USER:?GITHUB_USER not set in .env}" GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN not set in .env}" -BACKEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-backend:${TAG}" -FRONTEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-frontend:${TAG}" +# ── Read current version ────────────────────────────────────────────────────── +VERSION_FILE="VERSION" +[ -f "$VERSION_FILE" ] || echo "1.0.0" > "$VERSION_FILE" -echo "========================================" -echo " LMS → GHCR Push" -echo " Backend : $BACKEND_IMAGE" -echo " Frontend: $FRONTEND_IMAGE" -echo "========================================" -echo "" +CURRENT=$(cat "$VERSION_FILE" | tr -d '[:space:]') +if ! [[ $CURRENT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "VERSION file contains invalid semver: '$CURRENT'. Expected format: X.Y.Z" +fi -# ── 1. Login to GHCR ────────────────────────────────────────────────────────── -echo "🔐 Logging in to ghcr.io ..." +IFS='.' read -r V_MAJOR V_MINOR V_PATCH <<< "$CURRENT" + +# ── Compute new version ─────────────────────────────────────────────────────── +if [ -n "$EXPLICIT_TAG" ]; then + NEW_VERSION="$EXPLICIT_TAG" + info "Using explicit version: ${BOLD}$NEW_VERSION${RESET}" +else + case $BUMP_TYPE in + major) NEW_VERSION="$((V_MAJOR+1)).0.0" ;; + minor) NEW_VERSION="${V_MAJOR}.$((V_MINOR+1)).0" ;; + patch) NEW_VERSION="${V_MAJOR}.${V_MINOR}.$((V_PATCH+1))" ;; + esac + info "Bumping ${BUMP_TYPE}: ${BOLD}${CURRENT}${RESET} → ${BOLD}${NEW_VERSION}${RESET}" +fi + +# ── Image names ─────────────────────────────────────────────────────────────── +REGISTRY="ghcr.io/${GITHUB_USER}" +BACKEND_VERSIONED="${REGISTRY}/lms-backend:${NEW_VERSION}" +FRONTEND_VERSIONED="${REGISTRY}/lms-frontend:${NEW_VERSION}" +BACKEND_LATEST="${REGISTRY}/lms-backend:latest" +FRONTEND_LATEST="${REGISTRY}/lms-frontend:latest" + +step "LMS → GHCR Push" +echo -e " Version : ${BOLD}${NEW_VERSION}${RESET}" +echo -e " Backend : ${BACKEND_VERSIONED}" +echo -e " Frontend : ${FRONTEND_VERSIONED}" + +# ── 1. Login to GHCR ───────────────────────────────────────────────────────── +step "Login to ghcr.io" echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin +ok "Logged in" # ── 2. Build images ─────────────────────────────────────────────────────────── -echo "" -echo "🔨 Building images..." -cd "$(dirname "$0")" -docker compose build +step "Building images" +[ -n "$NO_CACHE" ] && warn "Building without layer cache" +# shellcheck disable=SC2086 +docker compose build $NO_CACHE +ok "Build complete" -# ── 3. Tag for GHCR ─────────────────────────────────────────────────────────── -echo "" -echo "🏷 Tagging images..." -docker tag lms-backend:latest "$BACKEND_IMAGE" -docker tag lms-frontend:latest "$FRONTEND_IMAGE" +# ── 3. Tag versioned + latest ───────────────────────────────────────────────── +step "Tagging images" +docker tag lms-backend:latest "$BACKEND_VERSIONED" +docker tag lms-frontend:latest "$FRONTEND_VERSIONED" +docker tag lms-backend:latest "$BACKEND_LATEST" +docker tag lms-frontend:latest "$FRONTEND_LATEST" +ok "Tagged ${NEW_VERSION} + latest" -# ── 4. Push ─────────────────────────────────────────────────────────────────── -echo "" -echo "⬆ Pushing to GHCR..." -docker push "$BACKEND_IMAGE" -docker push "$FRONTEND_IMAGE" +# ── 4. Push versioned + latest ──────────────────────────────────────────────── +step "Pushing to GHCR" +docker push "$BACKEND_VERSIONED" +docker push "$FRONTEND_VERSIONED" +docker push "$BACKEND_LATEST" +docker push "$FRONTEND_LATEST" +ok "Push complete" +# ── 5. Save new version (only after successful push) ───────────────────────── +if [ -z "$EXPLICIT_TAG" ]; then + echo "$NEW_VERSION" > "$VERSION_FILE" + + # Commit VERSION bump if inside a git repo + if git rev-parse --git-dir &>/dev/null; then + git add "$VERSION_FILE" + git commit -m "chore: bump version ${CURRENT} → ${NEW_VERSION}" --no-verify 2>/dev/null \ + && ok "Committed VERSION bump to git" \ + || warn "VERSION updated locally but git commit skipped (nothing to commit or no git user configured)" + else + ok "VERSION file updated to ${NEW_VERSION}" + fi +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── echo "" -echo "✅ Done! Images pushed:" -echo " $BACKEND_IMAGE" -echo " $FRONTEND_IMAGE" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD}${GREEN} ✅ Images pushed successfully — v${NEW_VERSION}${RESET}" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}" +echo -e " ${BOLD}Versioned tags:${RESET}" +echo " $BACKEND_VERSIONED" +echo " $FRONTEND_VERSIONED" +echo -e " ${BOLD}Latest tags also updated:${RESET}" +echo " $BACKEND_LATEST" +echo " $FRONTEND_LATEST" echo "" -echo "📋 Add these to .env on the target machine:" -echo " BACKEND_IMAGE=$BACKEND_IMAGE" -echo " FRONTEND_IMAGE=$FRONTEND_IMAGE" +echo -e " ${BOLD}📋 Add to .env on target machine:${RESET}" +echo " BACKEND_IMAGE=${BACKEND_VERSIONED}" +echo " FRONTEND_IMAGE=${FRONTEND_VERSIONED}" echo "" -echo "🚀 On the target machine:" -echo " docker compose pull && docker compose up -d" +echo -e " ${BOLD}🚀 Deploy on target machine:${RESET}" +echo " docker compose pull && docker compose up -d" diff --git a/readme.md b/readme.md index 60d7c79..61036b0 100644 --- a/readme.md +++ b/readme.md @@ -141,3 +141,12 @@ username admin password Admin@12345 role admin status approved + + +Lệnh Kết quả tag +./push-ghcr.sh 1.0.0 → 1.0.1 (tự động tăng patch) +./push-ghcr.sh minor 1.0.0 → 1.1.0 +./push-ghcr.sh major 1.0.0 → 2.0.0 +./push-ghcr.sh 1.5.0 đúng 1.5.0, không auto-bump +./push-ghcr.sh --no-cache rebuild + auto-bump patch +./push-ghcr.sh minor --no-cache bump minor + rebuild từ đầu \ No newline at end of file