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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine from .routers import annotations, auth, pdfs
from .routers import auth
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Create all tables on startup (use Alembic for migrations in production) # Tables are managed by Alembic migrations.
Base.metadata.create_all(bind=engine) # Run: alembic upgrade head (done by docker-compose entrypoint)
yield yield
@@ -25,3 +24,5 @@ app.add_middleware(
) )
app.include_router(auth.router, prefix="/api") 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): class TokenPayload(BaseModel):
sub: int # user id sub: int # user id
exp: int 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 from datetime import datetime, timedelta, timezone
import os import os
import bcrypt
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext
# ── Config (override via environment variables) ─────────────────────────────── # ── Config (override via environment variables) ───────────────────────────────
SECRET_KEY: str = os.environ["JWT_SECRET_KEY"] # must be set — no default 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") os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")
) )
# ── Password hashing ────────────────────────────────────────────────────────── # ── Password hashing (use bcrypt directly — passlib has compat issues) ────────
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str: 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: def verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed) return bcrypt.checkpw(plain.encode(), hashed.encode())
# ── JWT ─────────────────────────────────────────────────────────────────────── # ── 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 uvicorn[standard]==0.30.6
sqlalchemy==2.0.36 sqlalchemy==2.0.36
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
passlib[bcrypt]==1.7.4 bcrypt==4.0.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
pydantic[email]==2.9.2 pydantic[email]==2.9.2
python-multipart==0.0.12 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"]
}