mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
Merge pull request 'dev01' (#1) from dev01 into main
Reviewed-on: basketballcantho/ten-project#1
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
/uploads/
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
@@ -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"]
|
||||||
Vendored
+5
@@ -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.
|
||||||
@@ -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;
|
||||||
Generated
+3415
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user