Merge pull request 'dev01' (#1) from dev01 into main

Reviewed-on: basketballcantho/ten-project#1
This commit is contained in:
2026-03-31 08:47:12 +00:00
38 changed files with 5439 additions and 11 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
*.log
+19
View File
@@ -0,0 +1,19 @@
# Copy this file to .env and fill in the values before running docker-compose.
# NEVER commit .env to version control.
# ── PostgreSQL ────────────────────────────────────────────────────────────────
POSTGRES_DB=lms_db
POSTGRES_USER=lms_user
# REQUIRED — choose a strong password
POSTGRES_PASSWORD=change_me_strong_password
# ── JWT ───────────────────────────────────────────────────────────────────────
# REQUIRED — generate with: python -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET_KEY=change_me_generate_with_secrets_token_hex_32
# Token lifetime in minutes (default: 60)
ACCESS_TOKEN_EXPIRE_MINUTES=60
# ── Ports ─────────────────────────────────────────────────────────────────────
# Port exposed on the host for the Next.js frontend
FRONTEND_PORT=3000
+21
View File
@@ -0,0 +1,21 @@
# Environment
.env
.env.local
.env.*.local
# Python
__pycache__/
*.pyc
.venv/
venv/
# Node
node_modules/
.next/
# Uploads (runtime data)
/backend/uploads/
# OS
.DS_Store
Thumbs.db
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
.env
.env.*
!.env.example
.venv/
venv/
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
/uploads/
+28
View File
@@ -0,0 +1,28 @@
# ── Stage 1: deps ─────────────────────────────────────────────────────────────
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Stage 2: runtime ──────────────────────────────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# Copy installed packages from deps stage
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=deps /usr/local/bin /usr/local/bin
# Copy application code
COPY . .
# Ensure entrypoint is executable
RUN chmod +x entrypoint.sh
# Upload directory (will be mounted as a Docker volume)
RUN mkdir -p /uploads
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
+46
View File
@@ -0,0 +1,46 @@
# Alembic configuration — values prefixed with % are interpolated from env vars
# at runtime via env.py.
[alembic]
script_location = alembic
prepend_sys_path = .
# sqlalchemy.url is set dynamically in env.py from the DATABASE_URL env var.
# Do NOT put credentials here.
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+58
View File
@@ -0,0 +1,58 @@
"""Alembic environment — wires SQLAlchemy models into the migration engine."""
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Load app models so Alembic can inspect metadata
from app.database import Base # noqa: F401 — registers Base.metadata
import app.models # noqa: F401 — registers all ORM classes
# ── Alembic Config object ─────────────────────────────────────────────────────
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Inject DATABASE_URL from environment so it is never hard-coded
database_url = os.environ["DATABASE_URL"]
config.set_main_option("sqlalchemy.url", database_url)
target_metadata = Base.metadata
# ── Offline migrations (no live DB connection) ────────────────────────────────
def run_migrations_offline() -> None:
context.configure(
url=database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
# ── Online migrations (live DB connection) ────────────────────────────────────
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+17
View File
@@ -0,0 +1,17 @@
"""Alembic migration script template."""
# revision identifiers, used by Alembic.
revision = None
down_revision = None
branch_labels = None
depends_on = None
from alembic import op # noqa
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,102 @@
"""initial schema: users, pdfs, annotations
Revision ID: 0001
Revises:
Create Date: 2026-03-31
"""
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade() -> None:
# ── users ─────────────────────────────────────────────────────────────────
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(length=64), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_users_id", "users", ["id"])
op.create_index("ix_users_username", "users", ["username"], unique=True)
op.create_index("ix_users_email", "users", ["email"], unique=True)
# ── pdfs ──────────────────────────────────────────────────────────────────
op.create_table(
"pdfs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("file_path", sa.Text(), nullable=False),
sa.Column("total_pages", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_pdfs_id", "pdfs", ["id"])
op.create_index("ix_pdfs_user_id", "pdfs", ["user_id"])
# ── annotations ───────────────────────────────────────────────────────────
op.create_table(
"annotations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("pdf_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("page_number", sa.Integer(), nullable=False),
sa.Column(
"canvas_data",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["pdf_id"], ["pdfs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"pdf_id", "user_id", "page_number", name="uq_annotation_pdf_user_page"
),
)
op.create_index("ix_annotations_id", "annotations", ["id"])
op.create_index("ix_annotations_pdf_id", "annotations", ["pdf_id"])
op.create_index("ix_annotations_user_id", "annotations", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_annotations_user_id", table_name="annotations")
op.drop_index("ix_annotations_pdf_id", table_name="annotations")
op.drop_index("ix_annotations_id", table_name="annotations")
op.drop_table("annotations")
op.drop_index("ix_pdfs_user_id", table_name="pdfs")
op.drop_index("ix_pdfs_id", table_name="pdfs")
op.drop_table("pdfs")
op.drop_index("ix_users_email", table_name="users")
op.drop_index("ix_users_username", table_name="users")
op.drop_index("ix_users_id", table_name="users")
op.drop_table("users")
+5 -4
View File
@@ -3,14 +3,13 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine
from .routers import auth
from .routers import annotations, auth, pdfs
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create all tables on startup (use Alembic for migrations in production)
Base.metadata.create_all(bind=engine)
# Tables are managed by Alembic migrations.
# Run: alembic upgrade head (done by docker-compose entrypoint)
yield
@@ -25,3 +24,5 @@ app.add_middleware(
)
app.include_router(auth.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api")
app.include_router(annotations.router, prefix="/api")
+91
View File
@@ -0,0 +1,91 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import get_current_user
from ..models import Annotation, PDF, User
from ..schemas import AnnotationIn, AnnotationOut
router = APIRouter(prefix="/annotations", tags=["annotations"])
def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF:
pdf = db.get(PDF, pdf_id)
if not pdf or pdf.user_id != user_id:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
# ── GET /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut)
def get_annotation(
pdf_id: int,
page_number: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_verify_pdf_ownership(db, pdf_id, current_user.id)
ann = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.user_id == current_user.id,
Annotation.page_number == page_number,
)
.first()
)
# Return empty canvas if no annotation exists yet — not an error
if ann is None:
return AnnotationOut(
id=0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data={},
updated_at=datetime.now(timezone.utc),
)
return ann
# ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
def upsert_annotation(
pdf_id: int,
page_number: int,
body: AnnotationIn,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_verify_pdf_ownership(db, pdf_id, current_user.id)
ann = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.user_id == current_user.id,
Annotation.page_number == page_number,
)
.first()
)
if ann is None:
ann = Annotation(
pdf_id=pdf_id,
user_id=current_user.id,
page_number=page_number,
canvas_data=body.canvas_data,
)
db.add(ann)
else:
ann.canvas_data = body.canvas_data
ann.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(ann)
return ann
+135
View File
@@ -0,0 +1,135 @@
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import get_current_user
from ..models import PDF, User
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
def _user_dir(user_id: int) -> Path:
d = UPLOAD_DIR / str(user_id)
d.mkdir(parents=True, exist_ok=True)
return d
def _own_or_404(db: Session, pdf_id: int, user_id: int) -> PDF:
pdf = db.get(PDF, pdf_id)
if not pdf or pdf.user_id != user_id:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
# ── GET /api/pdfs ─────────────────────────────────────────────────────────────
@router.get("", response_model=list[PDFOut])
def list_pdfs(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return (
db.query(PDF)
.filter(PDF.user_id == current_user.id)
.order_by(PDF.created_at.desc())
.all()
)
# ── POST /api/pdfs/upload ─────────────────────────────────────────────────────
@router.post("/upload", response_model=list[PDFUploadResult], status_code=status.HTTP_201_CREATED)
async def upload_pdfs(
files: list[UploadFile],
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Accept one or more PDF files in a single multipart request."""
results: list[PDFUploadResult] = []
for file in files:
# ── Validate MIME type ────────────────────────────────────────────────
if file.content_type not in ("application/pdf", "application/octet-stream"):
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="Not a PDF file."))
continue
# ── Read & check magic bytes (PDF header: %PDF) ───────────────────────
header = await file.read(5)
if not header.startswith(b"%PDF-"):
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File is not a valid PDF."))
continue
# ── 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."))
continue
# ── Save to disk with UUID filename (prevents path traversal) ─────────
safe_name = f"{uuid.uuid4()}.pdf"
dest = _user_dir(current_user.id) / safe_name
dest.write_bytes(body)
# ── Persist metadata ──────────────────────────────────────────────────
original_title = Path(file.filename or "Untitled").stem or "Untitled"
pdf = PDF(
user_id=current_user.id,
title=original_title,
file_path=str(dest),
)
db.add(pdf)
db.commit()
db.refresh(pdf)
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=PDFOut.model_validate(pdf)))
return results
# ── DELETE /api/pdfs/{pdf_id} ─────────────────────────────────────────────────
@router.delete("/{pdf_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_pdf(
pdf_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
pdf = _own_or_404(db, pdf_id, current_user.id)
file_path = Path(pdf.file_path)
db.delete(pdf)
db.commit()
if file_path.exists():
file_path.unlink()
# ── GET /api/pdfs/{pdf_id}/file ───────────────────────────────────────────────
@router.get("/{pdf_id}/file")
def serve_pdf(
pdf_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Serve the raw PDF bytes — only to the owning user."""
pdf = _own_or_404(db, pdf_id, current_user.id)
file_path = Path(pdf.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk.")
return FileResponse(
path=str(file_path),
media_type="application/pdf",
filename=f"{pdf.title}.pdf",
)
+34
View File
@@ -45,3 +45,37 @@ class UserOut(BaseModel):
class TokenPayload(BaseModel):
sub: int # user id
exp: int
# ── PDFs ──────────────────────────────────────────────────────────────────────
class PDFOut(BaseModel):
id: int
title: str
total_pages: int | None
created_at: datetime
model_config = {"from_attributes": True}
class PDFUploadResult(BaseModel):
filename: str
success: bool
pdf: PDFOut | None = None
error: str | None = None
# ── Annotations ───────────────────────────────────────────────────────────────
class AnnotationIn(BaseModel):
canvas_data: dict
class AnnotationOut(BaseModel):
id: int
pdf_id: int
page_number: int
canvas_data: dict
updated_at: datetime
model_config = {"from_attributes": True}
+4 -6
View File
@@ -1,8 +1,8 @@
from datetime import datetime, timedelta, timezone
import os
import bcrypt
from jose import JWTError, jwt
from passlib.context import CryptContext
# ── Config (override via environment variables) ───────────────────────────────
SECRET_KEY: str = os.environ["JWT_SECRET_KEY"] # must be set — no default
@@ -11,16 +11,14 @@ ACCESS_TOKEN_EXPIRE_MINUTES: int = int(
os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")
)
# ── Password hashing ──────────────────────────────────────────────────────────
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# ── Password hashing (use bcrypt directly — passlib has compat issues) ────────
def hash_password(plain: str) -> str:
return _pwd_context.hash(plain)
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
return bcrypt.checkpw(plain.encode(), hashed.encode())
# ── JWT ───────────────────────────────────────────────────────────────────────
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Docker entrypoint: wait for Postgres, run migrations, then start the app.
set -e
echo "⏳ Running Alembic migrations..."
alembic upgrade head
echo "🚀 Starting FastAPI..."
exec uvicorn main:app --host 0.0.0.0 --port 8000
+2 -1
View File
@@ -2,7 +2,8 @@ fastapi==0.115.6
uvicorn[standard]==0.30.6
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-jose[cryptography]==3.3.0
pydantic[email]==2.9.2
python-multipart==0.0.12
alembic==1.14.1
+69
View File
@@ -0,0 +1,69 @@
services:
# ── PostgreSQL ────────────────────────────────────────────────────────────────
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-lms_db}
POSTGRES_USER: ${POSTGRES_USER:-lms_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms_user} -d ${POSTGRES_DB:-lms_db}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- lms_net
# ── FastAPI backend ───────────────────────────────────────────────────────────
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
db:
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
volumes:
- pdf_uploads:/uploads
expose:
- "8000"
networks:
- lms_net
# ── Next.js frontend ──────────────────────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://backend:8000
restart: unless-stopped
depends_on:
- backend
environment:
NEXT_PUBLIC_API_URL: http://backend:8000
NODE_ENV: production
ports:
- "${FRONTEND_PORT:-3000}:3000"
networks:
- lms_net
# ── Volumes ───────────────────────────────────────────────────────────────────
volumes:
postgres_data:
pdf_uploads:
# ── Networks ──────────────────────────────────────────────────────────────────
networks:
lms_net:
driver: bridge
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.next/
.env
.env.*
!.env.example
*.log
+33
View File
@@ -0,0 +1,33 @@
# ── Stage 1: deps ─────────────────────────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ── Stage 2: build ────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_API_URL is baked in at build time via docker-compose build args
ARG NEXT_PUBLIC_API_URL=http://backend:8000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# ── Stage 3: runtime ──────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+14
View File
@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/api/:path*`,
},
];
},
};
module.exports = nextConfig;
+3415
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "lms-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "14.2.18",
"react": "^18",
"react-dom": "^18",
"fabric": "^5.3.0",
"pdfjs-dist": "^4.9.124"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10",
"postcss": "^8",
"tailwindcss": "^3",
"typescript": "^5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { PDFItem, User } from "@/types";
import UploadZone from "@/components/UploadZone";
import PDFCard from "@/components/PDFCard";
export default function DashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [pdfs, setPdfs] = useState<PDFItem[]>([]);
const [loading, setLoading] = useState(true);
// ── Auth check + initial data load ────────────────────────────────────────
useEffect(() => {
async function init() {
try {
const [me, list] = await Promise.all([api.me(), api.listPdfs()]);
setUser(me);
setPdfs(list);
} catch {
router.replace("/login");
} finally {
setLoading(false);
}
}
init();
}, [router]);
// ── Called after successful upload ────────────────────────────────────────
const handleUploaded = useCallback((newPdfs: PDFItem[]) => {
setPdfs((prev) => [...newPdfs, ...prev]);
}, []);
// ── Delete ─────────────────────────────────────────────────────────────────
const handleDelete = useCallback(async (id: number) => {
if (!confirm("Delete this PDF? This cannot be undone.")) return;
try {
await api.deletePdf(id);
setPdfs((prev) => prev.filter((p) => p.id !== id));
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Delete failed.");
}
}, []);
// ── Logout ─────────────────────────────────────────────────────────────────
async function handleLogout() {
await api.logout();
router.replace("/login");
}
// ── Loading skeleton ───────────────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
);
}
return (
<div className="min-h-screen">
{/* ── Nav ────────────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{user?.username}</span>
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-red-600 transition"
>
Sign out
</button>
</div>
</div>
</header>
{/* ── Main content ───────────────────────────────────────────────── */}
<main className="max-w-6xl mx-auto px-4 py-8">
<h2 className="text-xl font-bold mb-6 text-gray-800">My PDFs</h2>
<UploadZone onUploaded={handleUploaded} />
{pdfs.length === 0 ? (
<p className="text-center text-gray-400 text-sm mt-12">
No PDFs yet. Upload one above to get started.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{pdfs.map((pdf) => (
<PDFCard key={pdf.id} pdf={pdf} onDelete={handleDelete} />
))}
</div>
)}
</main>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "PDF LMS",
description: "Personal PDF Learning Management System",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
</html>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
const [form, setForm] = useState({ username: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await api.login(form);
router.push("/dashboard");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Login failed.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-8">
<h1 className="text-2xl font-bold mb-6 text-center">Sign in</h1>
{error && (
<p className="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error}
</p>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold rounded-lg py-2 text-sm transition"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
No account?{" "}
<Link href="/register" className="text-blue-600 hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
export default function RegisterPage() {
const router = useRouter();
const [form, setForm] = useState({ username: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await api.register(form);
router.push("/dashboard");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Registration failed.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-8">
<h1 className="text-2xl font-bold mb-6 text-center">Create account</h1>
{error && (
<p className="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error}
</p>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
autoComplete="new-password"
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold rounded-lg py-2 text-sm transition"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
Already have an account?{" "}
<Link href="/login" className="text-blue-600 hover:underline">
Sign in
</Link>
</p>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import dynamic from "next/dynamic";
// Disable SSR — WorkbookViewer uses pdfjs-dist and fabric which require browser APIs
const WorkbookViewer = dynamic(() => import("@/components/WorkbookViewer"), {
ssr: false,
loading: () => (
<div className="min-h-screen flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
),
});
export default function WorkbookPage({
params,
}: {
params: { id: string };
}) {
const pdfId = parseInt(params.id, 10);
return <WorkbookViewer pdfId={pdfId} />;
}
+60
View File
@@ -0,0 +1,60 @@
import { useRouter } from "next/navigation";
import type { PDFItem } from "@/types";
interface Props {
pdf: PDFItem;
onDelete: (id: number) => void;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function PDFCard({ pdf, onDelete }: Props) {
const router = useRouter();
return (
<div
onClick={() => router.push(`/workbook/${pdf.id}`)}
className="group relative bg-white rounded-2xl shadow-sm border border-gray-200 hover:shadow-md hover:border-blue-300 transition cursor-pointer overflow-hidden"
>
{/* Thumbnail placeholder */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center h-40">
<svg className="w-14 h-14 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
{/* Meta */}
<div className="p-4">
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug" title={pdf.title}>
{pdf.title}
</p>
<p className="mt-1 text-xs text-gray-400">{formatDate(pdf.created_at)}</p>
{pdf.total_pages != null && (
<p className="text-xs text-gray-400">{pdf.total_pages} pages</p>
)}
</div>
{/* Delete button — visible on hover */}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(pdf.id);
}}
title="Delete"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api";
import type { PDFItem } from "@/types";
interface Props {
onUploaded: (newPdfs: PDFItem[]) => void;
}
export default function UploadZone({ onUploaded }: Props) {
const [isDragging, setIsDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const uploadFiles = useCallback(
async (files: FileList | File[]) => {
const pdfFiles = Array.from(files).filter((f) => f.type === "application/pdf");
if (pdfFiles.length === 0) {
setErrors(["Please select PDF files only."]);
return;
}
setUploading(true);
setErrors([]);
const formData = new FormData();
pdfFiles.forEach((f) => formData.append("files", f));
try {
const results = await api.uploadPdfs(formData);
const succeeded = results.filter((r) => r.success && r.pdf).map((r) => r.pdf!);
const failed = results.filter((r) => !r.success);
if (failed.length > 0) {
setErrors(failed.map((r) => `${r.filename}: ${r.error}`));
}
if (succeeded.length > 0) {
onUploaded(succeeded);
}
} catch (err: unknown) {
setErrors([err instanceof Error ? err.message : "Upload failed."]);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
},
[onUploaded]
);
function onDragOver(e: React.DragEvent) {
e.preventDefault();
setIsDragging(true);
}
function onDragLeave() {
setIsDragging(false);
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragging(false);
uploadFiles(e.dataTransfer.files);
}
function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files) uploadFiles(e.target.files);
}
return (
<div className="mb-8">
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={`cursor-pointer border-2 border-dashed rounded-2xl px-6 py-12 text-center transition
${isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-blue-400 hover:bg-gray-50"}`}
>
<input
ref={inputRef}
type="file"
accept=".pdf,application/pdf"
multiple
className="hidden"
onChange={onInputChange}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2 text-blue-600">
<svg className="animate-spin h-8 w-8" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<p className="text-sm font-medium">Uploading</p>
</div>
) : (
<>
<svg className="mx-auto h-10 w-10 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 16V4m0 0L8 8m4-4l4 4M4 20h16" />
</svg>
<p className="text-sm font-semibold text-gray-700">
Drag &amp; drop PDFs here, or{" "}
<span className="text-blue-600">click to browse</span>
</p>
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 50 MB each</p>
</>
)}
</div>
{errors.length > 0 && (
<ul className="mt-3 space-y-1">
{errors.map((e, i) => (
<li key={i} className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-1.5">
{e}
</li>
))}
</ul>
)}
</div>
);
}
+671
View File
@@ -0,0 +1,671 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
type Tool = "select" | "pen" | "highlighter" | "text";
type ViewMode = "pan" | "draw";
interface Props {
pdfId: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function hexToRgba(hex: string, alpha: number): string {
let h = hex.replace("#", "");
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Toolbar button
// ─────────────────────────────────────────────────────────────────────────────
function ToolBtn({
id,
current,
onClick,
title,
children,
}: {
id: Tool;
current: Tool;
onClick: (t: Tool) => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
onClick={() => onClick(id)}
title={title}
className={`p-1.5 rounded-lg transition ${
current === id
? "bg-blue-600 text-white shadow"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800"
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{children}
</svg>
</button>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main component
// ─────────────────────────────────────────────────────────────────────────────
export default function WorkbookViewer({ pdfId }: Props) {
const router = useRouter();
// DOM refs
const scrollContainerRef = useRef<HTMLDivElement>(null);
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
const fabricElRef = useRef<HTMLCanvasElement>(null);
// Library instances (dynamic imports to avoid SSR)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fabricRef = useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fabricNSRef = useRef<any>(null); // fabric namespace object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pdfDocRef = useRef<any>(null);
// Per-page annotation cache (local, not yet flushed to server)
const localAnnotations = useRef<Record<number, object>>({});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addedObjects = useRef<any[]>([]);
const skipObjectTracking = useRef(false);
// Mutable refs kept in sync with state (used inside closures/callbacks)
const currentToolRef = useRef<Tool>("pen");
const penColorRef = useRef<string>("#e63946");
const strokeWidthRef = useRef<number>(3);
// UI state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [mode, setViewMode] = useState<ViewMode>("pan");
const [tool, setTool] = useState<Tool>("pen");
const [penColor, setPenColor] = useState("#e63946");
const [strokeWidth, setStrokeWidth] = useState(3);
const [saving, setSaving] = useState(false);
const [saveNotice, setSaveNotice] = useState(false);
const [isReady, setIsReady] = useState(false);
const [pdfTitle, setPdfTitle] = useState("PDF");
const [loadError, setLoadError] = useState("");
const [rendering, setRendering] = useState(false);
// Keep mutable refs in sync
useEffect(() => { currentToolRef.current = tool; }, [tool]);
useEffect(() => { penColorRef.current = penColor; }, [penColor]);
useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
// ─────────────────────────────────────────────────────────────────────────
// renderPageWithAnnotations
// ─────────────────────────────────────────────────────────────────────────
const renderPageWithAnnotations = useCallback(
async (pageNum: number) => {
const fc = fabricRef.current;
const doc = pdfDocRef.current;
if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return;
setRendering(true);
try {
// ── 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;
const viewport = page.getViewport({ scale });
const pdfCvs = pdfCanvasRef.current;
pdfCvs.width = Math.floor(viewport.width);
pdfCvs.height = Math.floor(viewport.height);
await page.render({
canvasContext: pdfCvs.getContext("2d")!,
viewport,
}).promise;
// ── 2. Resize Fabric canvas to match ──────────────────────────────
fc.setWidth(pdfCvs.width);
fc.setHeight(pdfCvs.height);
if (fc.wrapperEl) {
fc.wrapperEl.style.width = `${pdfCvs.width}px`;
fc.wrapperEl.style.height = `${pdfCvs.height}px`;
}
// ── 3. Load annotation data ───────────────────────────────────────
const cached = localAnnotations.current[pageNum];
let annotationData: object | null = cached ?? null;
if (!annotationData) {
try {
const ann = await api.getAnnotation(pdfId, pageNum);
if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) {
annotationData = ann.canvas_data;
localAnnotations.current[pageNum] = annotationData;
}
} catch { /* no annotation yet */ }
}
// Clear and load
skipObjectTracking.current = true;
fc.clear();
addedObjects.current = [];
if (annotationData) {
await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => {
fc.renderAll();
skipObjectTracking.current = false;
resolve();
});
});
} else {
fc.renderAll();
skipObjectTracking.current = false;
}
} finally {
setRendering(false);
}
},
[pdfId]
);
// ─────────────────────────────────────────────────────────────────────────
// Initialization (mount)
// ─────────────────────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
async function init() {
try {
// Fetch PDF title
try {
const pdfs = await api.listPdfs();
const found = pdfs.find((p) => p.id === pdfId);
if (found) setPdfTitle(found.title);
} catch { /* ignore */ }
if (cancelled || !fabricElRef.current) return;
// ── Init Fabric.js ────────────────────────────────────────────────
const fabricModule = await import("fabric");
const fabric = fabricModule.fabric;
fabricNSRef.current = fabric;
if (cancelled) return;
const fc = new fabric.Canvas(fabricElRef.current, {
isDrawingMode: false,
selection: false,
enableRetinaScaling: false,
});
fabricRef.current = fc;
// Position Fabric wrapper absolutely over PDF canvas
if (fc.wrapperEl) {
fc.wrapperEl.style.position = "absolute";
fc.wrapperEl.style.top = "0";
fc.wrapperEl.style.left = "0";
fc.wrapperEl.style.pointerEvents = "none"; // start in pan mode
}
// Track added objects for undo (skip objects loaded from JSON)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.on("object:added", (e: any) => {
if (!skipObjectTracking.current) {
addedObjects.current.push(e.target);
}
});
// Highlighter: reduce opacity of drawn path after creation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.on("path:created", (options: any) => {
if (currentToolRef.current === "highlighter") {
options.path.set({ opacity: 0.42 });
fc.renderAll();
}
});
// ── Init PDF.js ───────────────────────────────────────────────────
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
if (cancelled) return;
const doc = await pdfjs
.getDocument({ url: `/api/pdfs/${pdfId}/file`, withCredentials: true })
.promise;
if (cancelled) { doc.destroy(); return; }
pdfDocRef.current = doc;
setTotalPages(doc.numPages);
setIsReady(true);
// ── Render first page ─────────────────────────────────────────────
await renderPageWithAnnotations(1);
setCurrentPage(1);
} catch (err: unknown) {
if (!cancelled) {
setLoadError(
err instanceof Error ? err.message : "Failed to load PDF."
);
}
}
}
init();
return () => {
cancelled = true;
fabricRef.current?.dispose();
fabricRef.current = null;
pdfDocRef.current?.destroy();
pdfDocRef.current = null;
};
}, [pdfId, renderPageWithAnnotations]);
// ─────────────────────────────────────────────────────────────────────────
// Apply tool / mode to Fabric canvas
// ─────────────────────────────────────────────────────────────────────────
useEffect(() => {
const fc = fabricRef.current;
const fabric = fabricNSRef.current;
if (!isReady || !fc || !fabric) return;
// Clean up previous mouse:down handler (added for text tool)
fc.off("mouse:down");
if (mode === "pan") {
fc.isDrawingMode = false;
fc.selection = false;
if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "none";
return;
}
// Draw mode — enable pointer events
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 brush = new fabric.PencilBrush(fc);
brush.color = penColor;
brush.width = strokeWidth;
fc.freeDrawingBrush = brush;
break;
}
case "highlighter": {
fc.isDrawingMode = true;
fc.selection = false;
const hBrush = new fabric.PencilBrush(fc);
// Full-opacity color during drawing; path:created handler applies opacity:0.42
hBrush.color = hexToRgba(penColor, 0.99);
hBrush.width = strokeWidth * 7;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(hBrush as any).strokeLineCap = "square";
fc.freeDrawingBrush = hBrush;
break;
}
case "text":
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; // clicked existing object → let Fabric handle it
const pointer = fc.getPointer(options.e);
const textObj = new fabric.IText("Text", {
left: pointer.x,
top: pointer.y,
fontSize: 20,
fill: penColorRef.current,
fontFamily: "Arial, sans-serif",
padding: 4,
});
fc.add(textObj);
fc.setActiveObject(textObj);
textObj.enterEditing();
textObj.selectAll();
fc.renderAll();
});
break;
}
}, [isReady, mode, tool, penColor, strokeWidth]);
// ─────────────────────────────────────────────────────────────────────────
// Page navigation
// ─────────────────────────────────────────────────────────────────────────
const goToPage = useCallback(
async (newPage: number) => {
const fc = fabricRef.current;
if (!fc || newPage < 1 || newPage > totalPages || rendering) return;
// Cache current page before leaving
localAnnotations.current[currentPage] = fc.toJSON();
await renderPageWithAnnotations(newPage);
setCurrentPage(newPage);
},
[currentPage, totalPages, rendering, renderPageWithAnnotations]
);
// ─────────────────────────────────────────────────────────────────────────
// Save
// ─────────────────────────────────────────────────────────────────────────
const handleSave = useCallback(async () => {
const fc = fabricRef.current;
if (!fc || !isReady || saving) return;
setSaving(true);
const canvasData = fc.toJSON();
try {
await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData });
localAnnotations.current[currentPage] = canvasData;
setSaveNotice(true);
setTimeout(() => setSaveNotice(false), 2500);
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Save failed.");
} finally {
setSaving(false);
}
}, [pdfId, currentPage, isReady, saving]);
// ─────────────────────────────────────────────────────────────────────────
// Undo / Clear
// ─────────────────────────────────────────────────────────────────────────
const handleUndo = useCallback(() => {
const fc = fabricRef.current;
if (!fc || addedObjects.current.length === 0) return;
const last = addedObjects.current.pop();
if (last) { fc.remove(last); fc.renderAll(); }
}, []);
const handleClear = useCallback(() => {
const fc = fabricRef.current;
if (!fc) return;
if (!confirm("Clear all annotations on this page?")) return;
fc.clear();
addedObjects.current = [];
fc.renderAll();
}, []);
// ─────────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────────
if (loadError) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 px-4">
<p className="text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg px-4 py-3">
{loadError}
</p>
<button
onClick={() => router.push("/dashboard")}
className="text-blue-600 hover:underline text-sm"
>
Back to Dashboard
</button>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-gray-100">
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-20">
<div className="max-w-5xl mx-auto px-3 py-2 flex flex-wrap items-center gap-2">
{/* Back button + title */}
<button
onClick={() => router.push("/dashboard")}
title="Back"
className="p-1 text-gray-500 hover:text-blue-600 transition rounded"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-semibold text-gray-800 truncate max-w-[130px] sm:max-w-xs">
{pdfTitle}
</span>
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Page navigation */}
<div className="flex items-center gap-0.5">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1 || rendering || !isReady}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
title="Previous page"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-xs text-gray-600 px-1 whitespace-nowrap tabular-nums">
{isReady ? `${currentPage} / ${totalPages}` : "…"}
</span>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages || rendering || !isReady}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
title="Next page"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Pan / Draw mode toggle */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => setViewMode("pan")}
className={`text-xs px-2.5 py-1 rounded-md font-medium transition ${
mode === "pan" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
}`}
>
Pan
</button>
<button
onClick={() => setViewMode("draw")}
className={`text-xs px-2.5 py-1 rounded-md font-medium transition ${
mode === "draw" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
}`}
>
Draw
</button>
</div>
{/* Drawing tools — only shown in Draw mode */}
{mode === "draw" && (
<>
<div className="h-5 w-px bg-gray-200" />
{/* Tool buttons */}
<div className="flex items-center gap-0.5">
{/* Select */}
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</ToolBtn>
{/* Pen */}
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</ToolBtn>
{/* Highlighter */}
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</ToolBtn>
{/* Text */}
<ToolBtn id="text" current={tool} onClick={setTool} title="Text">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-3-3v6M7 20h10a2 2 0 002-2V6a2 2 0 00-2-2H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
</ToolBtn>
</div>
{/* Color picker */}
<input
type="color"
value={penColor}
onChange={(e) => 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 */}
<input
type="range"
min={1}
max={12}
value={strokeWidth}
onChange={(e) => setStrokeWidth(Number(e.target.value))}
className="w-16 accent-blue-600"
title={`Stroke width: ${strokeWidth}`}
/>
<div className="h-5 w-px bg-gray-200" />
{/* Undo */}
<button
onClick={handleUndo}
title="Undo last stroke"
className="p-1.5 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
{/* Clear page */}
<button
onClick={handleClear}
title="Clear page annotations"
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M4 7h16M10 3h4a1 1 0 011 1v1H9V4a1 1 0 011-1z" />
</svg>
</button>
</>
)}
{/* Save button — far right */}
<div className="ml-auto flex items-center gap-2">
{saveNotice && (
<span className="text-xs text-green-600 font-medium animate-pulse">
Saved
</span>
)}
<button
onClick={handleSave}
disabled={saving || !isReady}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white text-xs font-semibold px-3 py-1.5 rounded-lg transition"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</header>
{/* ── Viewer area ─────────────────────────────────────────────────────── */}
<main
ref={scrollContainerRef}
className="flex-1 overflow-auto py-6 px-4"
// In Draw mode: lock touch-action so all touch events go to Fabric
style={{ touchAction: mode === "draw" ? "none" : "auto" }}
>
{/* Loading spinner */}
{!isReady && !loadError && (
<div className="flex items-center justify-center h-64">
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
)}
{/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
<div
className="relative mx-auto"
style={{ display: isReady ? "block" : "none", width: "fit-content" }}
>
{/* PDF.js renders here */}
<canvas ref={pdfCanvasRef} className="block shadow-lg rounded" />
{/*
Fabric.js is initialised on this element.
After init, Fabric wraps it in a div (wrapperEl) that our useEffect
repositions to position:absolute / top:0 / left:0 — sitting above the PDF.
*/}
<canvas ref={fabricElRef} />
</div>
{/* Bottom page controls for mobile convenience */}
{isReady && (
<div className="flex justify-center mt-6 gap-4">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1 || rendering}
className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
>
Prev
</button>
<span className="text-sm text-gray-500 tabular-nums">
{currentPage} / {totalPages}
</span>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages || rendering}
className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
>
Next
</button>
</div>
)}
</main>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Thin fetch wrapper — always sends cookies (HttpOnly JWT).
* All paths are relative so Next.js rewrites proxy them to the backend.
*/
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...init,
credentials: "include",
headers: {
...(init.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...init.headers,
},
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? "Request failed");
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
// Auth
me: () => request<import("@/types").User>("/api/auth/me"),
login: (body: { username: string; password: string }) =>
request<import("@/types").User>("/api/auth/login", { method: "POST", body: JSON.stringify(body) }),
register: (body: { username: string; email: string; password: string }) =>
request<import("@/types").User>("/api/auth/register", { method: "POST", body: JSON.stringify(body) }),
logout: () => request<void>("/api/auth/logout", { method: "POST" }),
// PDFs
listPdfs: () => request<import("@/types").PDFItem[]>("/api/pdfs"),
uploadPdfs: (formData: FormData) =>
request<import("@/types").PDFUploadResult[]>("/api/pdfs/upload", { method: "POST", body: formData }),
deletePdf: (id: number) => request<void>(`/api/pdfs/${id}`, { method: "DELETE" }),
// Annotations
getAnnotation: (pdfId: number, page: number) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`),
upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`, {
method: "PUT",
body: JSON.stringify(body),
}),
};
+29
View File
@@ -0,0 +1,29 @@
export interface User {
id: number;
username: string;
email: string;
created_at: string;
}
export interface PDFItem {
id: number;
title: string;
total_pages: number | null;
created_at: string;
}
export interface PDFUploadResult {
filename: string;
success: boolean;
pdf: PDFItem | null;
error: string | null;
}
export interface AnnotationData {
id: number;
pdf_id: number;
page_number: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvas_data: Record<string, any>;
updated_at: string;
}
+9
View File
@@ -0,0 +1,9 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}