hoàn thành bước3.2. PDF Gallery (Dashboard)

This commit is contained in:
2026-03-31 14:26:48 +07:00
parent ebfa869a26
commit 9450cd3ed4
18 changed files with 766 additions and 1 deletions
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine from .database import Base, engine
from .routers import auth from .routers import auth, pdfs
@asynccontextmanager @asynccontextmanager
@@ -25,3 +25,4 @@ app.add_middleware(
) )
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api")
+135
View File
@@ -0,0 +1,135 @@
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import get_current_user
from ..models import PDF, User
from ..schemas import PDFOut, PDFUploadResult
router = APIRouter(prefix="/pdfs", tags=["pdfs"])
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
def _user_dir(user_id: int) -> Path:
d = UPLOAD_DIR / str(user_id)
d.mkdir(parents=True, exist_ok=True)
return d
def _own_or_404(db: Session, pdf_id: int, user_id: int) -> PDF:
pdf = db.get(PDF, pdf_id)
if not pdf or pdf.user_id != user_id:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
# ── GET /api/pdfs ─────────────────────────────────────────────────────────────
@router.get("", response_model=list[PDFOut])
def list_pdfs(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return (
db.query(PDF)
.filter(PDF.user_id == current_user.id)
.order_by(PDF.created_at.desc())
.all()
)
# ── POST /api/pdfs/upload ─────────────────────────────────────────────────────
@router.post("/upload", response_model=list[PDFUploadResult], status_code=status.HTTP_201_CREATED)
async def upload_pdfs(
files: list[UploadFile],
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Accept one or more PDF files in a single multipart request."""
results: list[PDFUploadResult] = []
for file in files:
# ── Validate MIME type ────────────────────────────────────────────────
if file.content_type not in ("application/pdf", "application/octet-stream"):
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="Not a PDF file."))
continue
# ── Read & check magic bytes (PDF header: %PDF) ───────────────────────
header = await file.read(5)
if not header.startswith(b"%PDF-"):
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File is not a valid PDF."))
continue
# ── Size guard ────────────────────────────────────────────────────────
body = header + await file.read()
if len(body) > MAX_FILE_SIZE:
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File exceeds 50 MB limit."))
continue
# ── Save to disk with UUID filename (prevents path traversal) ─────────
safe_name = f"{uuid.uuid4()}.pdf"
dest = _user_dir(current_user.id) / safe_name
dest.write_bytes(body)
# ── Persist metadata ──────────────────────────────────────────────────
original_title = Path(file.filename or "Untitled").stem or "Untitled"
pdf = PDF(
user_id=current_user.id,
title=original_title,
file_path=str(dest),
)
db.add(pdf)
db.commit()
db.refresh(pdf)
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=PDFOut.model_validate(pdf)))
return results
# ── DELETE /api/pdfs/{pdf_id} ─────────────────────────────────────────────────
@router.delete("/{pdf_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_pdf(
pdf_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
pdf = _own_or_404(db, pdf_id, current_user.id)
file_path = Path(pdf.file_path)
db.delete(pdf)
db.commit()
if file_path.exists():
file_path.unlink()
# ── GET /api/pdfs/{pdf_id}/file ───────────────────────────────────────────────
@router.get("/{pdf_id}/file")
def serve_pdf(
pdf_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Serve the raw PDF bytes — only to the owning user."""
pdf = _own_or_404(db, pdf_id, current_user.id)
file_path = Path(pdf.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk.")
return FileResponse(
path=str(file_path),
media_type="application/pdf",
filename=f"{pdf.title}.pdf",
)
+18
View File
@@ -45,3 +45,21 @@ 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
+14
View File
@@ -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;
+24
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { PDFItem, User } from "@/types";
import UploadZone from "@/components/UploadZone";
import PDFCard from "@/components/PDFCard";
export default function DashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [pdfs, setPdfs] = useState<PDFItem[]>([]);
const [loading, setLoading] = useState(true);
// ── Auth check + initial data load ────────────────────────────────────────
useEffect(() => {
async function init() {
try {
const [me, list] = await Promise.all([api.me(), api.listPdfs()]);
setUser(me);
setPdfs(list);
} catch {
router.replace("/login");
} finally {
setLoading(false);
}
}
init();
}, [router]);
// ── Called after successful upload ────────────────────────────────────────
const handleUploaded = useCallback((newPdfs: PDFItem[]) => {
setPdfs((prev) => [...newPdfs, ...prev]);
}, []);
// ── Delete ─────────────────────────────────────────────────────────────────
const handleDelete = useCallback(async (id: number) => {
if (!confirm("Delete this PDF? This cannot be undone.")) return;
try {
await api.deletePdf(id);
setPdfs((prev) => prev.filter((p) => p.id !== id));
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Delete failed.");
}
}, []);
// ── Logout ─────────────────────────────────────────────────────────────────
async function handleLogout() {
await api.logout();
router.replace("/login");
}
// ── Loading skeleton ───────────────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
);
}
return (
<div className="min-h-screen">
{/* ── Nav ────────────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{user?.username}</span>
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-red-600 transition"
>
Sign out
</button>
</div>
</div>
</header>
{/* ── Main content ───────────────────────────────────────────────── */}
<main className="max-w-6xl mx-auto px-4 py-8">
<h2 className="text-xl font-bold mb-6 text-gray-800">My PDFs</h2>
<UploadZone onUploaded={handleUploaded} />
{pdfs.length === 0 ? (
<p className="text-center text-gray-400 text-sm mt-12">
No PDFs yet. Upload one above to get started.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{pdfs.map((pdf) => (
<PDFCard key={pdf.id} pdf={pdf} onDelete={handleDelete} />
))}
</div>
)}
</main>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "PDF LMS",
description: "Personal PDF Learning Management System",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
</html>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
const [form, setForm] = useState({ username: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await api.login(form);
router.push("/dashboard");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Login failed.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-8">
<h1 className="text-2xl font-bold mb-6 text-center">Sign in</h1>
{error && (
<p className="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error}
</p>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold rounded-lg py-2 text-sm transition"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
No account?{" "}
<Link href="/register" className="text-blue-600 hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
export default function RegisterPage() {
const router = useRouter();
const [form, setForm] = useState({ username: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await api.register(form);
router.push("/dashboard");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Registration failed.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-8">
<h1 className="text-2xl font-bold mb-6 text-center">Create account</h1>
{error && (
<p className="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error}
</p>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
autoComplete="new-password"
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold rounded-lg py-2 text-sm transition"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
Already have an account?{" "}
<Link href="/login" className="text-blue-600 hover:underline">
Sign in
</Link>
</p>
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useRouter } from "next/navigation";
import type { PDFItem } from "@/types";
interface Props {
pdf: PDFItem;
onDelete: (id: number) => void;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function PDFCard({ pdf, onDelete }: Props) {
const router = useRouter();
return (
<div
onClick={() => router.push(`/workbook/${pdf.id}`)}
className="group relative bg-white rounded-2xl shadow-sm border border-gray-200 hover:shadow-md hover:border-blue-300 transition cursor-pointer overflow-hidden"
>
{/* Thumbnail placeholder */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center h-40">
<svg className="w-14 h-14 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
{/* Meta */}
<div className="p-4">
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug" title={pdf.title}>
{pdf.title}
</p>
<p className="mt-1 text-xs text-gray-400">{formatDate(pdf.created_at)}</p>
{pdf.total_pages != null && (
<p className="text-xs text-gray-400">{pdf.total_pages} pages</p>
)}
</div>
{/* Delete button — visible on hover */}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(pdf.id);
}}
title="Delete"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api";
import type { PDFItem } from "@/types";
interface Props {
onUploaded: (newPdfs: PDFItem[]) => void;
}
export default function UploadZone({ onUploaded }: Props) {
const [isDragging, setIsDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const uploadFiles = useCallback(
async (files: FileList | File[]) => {
const pdfFiles = Array.from(files).filter((f) => f.type === "application/pdf");
if (pdfFiles.length === 0) {
setErrors(["Please select PDF files only."]);
return;
}
setUploading(true);
setErrors([]);
const formData = new FormData();
pdfFiles.forEach((f) => formData.append("files", f));
try {
const results = await api.uploadPdfs(formData);
const succeeded = results.filter((r) => r.success && r.pdf).map((r) => r.pdf!);
const failed = results.filter((r) => !r.success);
if (failed.length > 0) {
setErrors(failed.map((r) => `${r.filename}: ${r.error}`));
}
if (succeeded.length > 0) {
onUploaded(succeeded);
}
} catch (err: unknown) {
setErrors([err instanceof Error ? err.message : "Upload failed."]);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
},
[onUploaded]
);
function onDragOver(e: React.DragEvent) {
e.preventDefault();
setIsDragging(true);
}
function onDragLeave() {
setIsDragging(false);
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragging(false);
uploadFiles(e.dataTransfer.files);
}
function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files) uploadFiles(e.target.files);
}
return (
<div className="mb-8">
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={`cursor-pointer border-2 border-dashed rounded-2xl px-6 py-12 text-center transition
${isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-blue-400 hover:bg-gray-50"}`}
>
<input
ref={inputRef}
type="file"
accept=".pdf,application/pdf"
multiple
className="hidden"
onChange={onInputChange}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2 text-blue-600">
<svg className="animate-spin h-8 w-8" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<p className="text-sm font-medium">Uploading</p>
</div>
) : (
<>
<svg className="mx-auto h-10 w-10 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 16V4m0 0L8 8m4-4l4 4M4 20h16" />
</svg>
<p className="text-sm font-semibold text-gray-700">
Drag &amp; drop PDFs here, or{" "}
<span className="text-blue-600">click to browse</span>
</p>
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 50 MB each</p>
</>
)}
</div>
{errors.length > 0 && (
<ul className="mt-3 space-y-1">
{errors.map((e, i) => (
<li key={i} className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-1.5">
{e}
</li>
))}
</ul>
)}
</div>
);
}
+39
View File
@@ -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<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" }),
};
+20
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}