diff --git a/backend/app/main.py b/backend/app/main.py index 31dd37a..b69a375 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .database import Base, engine -from .routers import auth +from .routers import auth, pdfs @asynccontextmanager @@ -25,3 +25,4 @@ app.add_middleware( ) app.include_router(auth.router, prefix="/api") +app.include_router(pdfs.router, prefix="/api") diff --git a/backend/app/routers/pdfs.py b/backend/app/routers/pdfs.py new file mode 100644 index 0000000..82fa67d --- /dev/null +++ b/backend/app/routers/pdfs.py @@ -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", + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 55dea29..7fa4647 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -45,3 +45,21 @@ class UserOut(BaseModel): class TokenPayload(BaseModel): sub: int # user id exp: int + + +# ── PDFs ────────────────────────────────────────────────────────────────────── + +class PDFOut(BaseModel): + id: int + title: str + total_pages: int | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class PDFUploadResult(BaseModel): + filename: str + success: bool + pdf: PDFOut | None = None + error: str | None = None diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..2d867d9 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/api/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..32d1746 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10", + "postcss": "^8", + "tailwindcss": "^3", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..7dc668f --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -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(null); + const [pdfs, setPdfs] = useState([]); + 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 ( +
+ + + + +
+ ); + } + + return ( +
+ {/* ── Nav ────────────────────────────────────────────────────────── */} +
+
+ PDF LMS +
+ {user?.username} + +
+
+
+ + {/* ── Main content ───────────────────────────────────────────────── */} +
+

My PDFs

+ + + + {pdfs.length === 0 ? ( +

+ No PDFs yet. Upload one above to get started. +

+ ) : ( +
+ {pdfs.map((pdf) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..3a61991 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..06dc4e2 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -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 ( +
+
+

Sign in

+ + {error && ( +

+ {error} +

+ )} + +
+
+ + setForm({ ...form, username: e.target.value })} + required + autoComplete="username" + /> +
+
+ + setForm({ ...form, password: e.target.value })} + required + autoComplete="current-password" + /> +
+ +
+ +

+ No account?{" "} + + Register + +

+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..a74cb27 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/dashboard"); +} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx new file mode 100644 index 0000000..50f2349 --- /dev/null +++ b/frontend/src/app/register/page.tsx @@ -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 ( +
+
+

Create account

+ + {error && ( +

+ {error} +

+ )} + +
+
+ + setForm({ ...form, username: e.target.value })} + required + autoComplete="username" + /> +
+
+ + setForm({ ...form, email: e.target.value })} + required + autoComplete="email" + /> +
+
+ + setForm({ ...form, password: e.target.value })} + required + autoComplete="new-password" + minLength={8} + /> +
+ +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/frontend/src/components/PDFCard.tsx b/frontend/src/components/PDFCard.tsx new file mode 100644 index 0000000..282f418 --- /dev/null +++ b/frontend/src/components/PDFCard.tsx @@ -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 ( +
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 */} +
+ + + +
+ + {/* Meta */} +
+

+ {pdf.title} +

+

{formatDate(pdf.created_at)}

+ {pdf.total_pages != null && ( +

{pdf.total_pages} pages

+ )} +
+ + {/* Delete button — visible on hover */} + +
+ ); +} diff --git a/frontend/src/components/UploadZone.tsx b/frontend/src/components/UploadZone.tsx new file mode 100644 index 0000000..4b5ed45 --- /dev/null +++ b/frontend/src/components/UploadZone.tsx @@ -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([]); + const inputRef = useRef(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) { + if (e.target.files) uploadFiles(e.target.files); + } + + return ( +
+
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"}`} + > + + + {uploading ? ( +
+ + + + +

Uploading…

+
+ ) : ( + <> + + + +

+ Drag & drop PDFs here, or{" "} + click to browse +

+

Multiple files supported · Max 50 MB each

+ + )} +
+ + {errors.length > 0 && ( +
    + {errors.map((e, i) => ( +
  • + {e} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..8a452e3 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,39 @@ +/** + * Thin fetch wrapper — always sends cookies (HttpOnly JWT). + * All paths are relative so Next.js rewrites proxy them to the backend. + */ + +async function request(path: string, init: RequestInit = {}): Promise { + 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("/api/auth/me"), + login: (body: { username: string; password: string }) => + request("/api/auth/login", { method: "POST", body: JSON.stringify(body) }), + register: (body: { username: string; email: string; password: string }) => + request("/api/auth/register", { method: "POST", body: JSON.stringify(body) }), + logout: () => request("/api/auth/logout", { method: "POST" }), + + // PDFs + listPdfs: () => request("/api/pdfs"), + uploadPdfs: (formData: FormData) => + request("/api/pdfs/upload", { method: "POST", body: formData }), + deletePdf: (id: number) => request(`/api/pdfs/${id}`, { method: "DELETE" }), +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..6babb8f --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,20 @@ +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; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..b269d72 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./src/**/*.{ts,tsx}"], + theme: { extend: {} }, + plugins: [], +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..fba2bf3 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +}