Add XRayPDF client app with OCR routes

Introduce a new Next.js `client` app for XRayPDF: adds frontend pages (dashboard, upload, layout, Navbar, globals) and API routes for documents (upload, OCR, pixel processing, delete, list). Include Tesseract traineddata (eng.traineddata), update next.config.ts to allow native server packages and remote images, update package.json to add dependencies (canvas, sharp, tesseract.js, pdfjs-dist, multer, uuid), and update Prisma schema. Also add .gitignore entries for user uploads/results and a README describing the project and architecture.
This commit is contained in:
Aryan Singh committed 2026-02-25 22:39:42 -06:00
1 parent 50bc2eea74
commit 41186abc57
20 files changed
+2586 -81

No files matched your search

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
if (!doc) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user || doc.userId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await prisma.pdfDocument.delete({ where: { id } });
return NextResponse.json({ success: true });
}
@@ -0,0 +1,200 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import { readFile, writeFile, mkdir } from "fs/promises";
import path from "path";
/**
* Tesseract OCR: runs OCR on EVERY page of the PDF.
* Streams progress back to the client via newline-delimited JSON.
* Only allowed ONCE per document. Must be explicitly triggered.
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
if (!doc) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
if (doc.ocrRan) {
// Return existing results
if (doc.ocrResultDir && doc.ocrPageCount > 0) {
const pages = Array.from({ length: doc.ocrPageCount }, (_, i) => ({
page: i + 1,
path: `${doc.ocrResultDir}/page_${i + 1}.txt`,
}));
return NextResponse.json({
pages,
pageCount: doc.ocrPageCount,
alreadyRan: true,
});
}
return NextResponse.json(
{ error: "OCR has already been run", alreadyRan: true },
{ status: 409 }
);
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user || doc.userId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const pdfPath = path.join(process.cwd(), "public", doc.filePath);
const pdfBuffer = await readFile(pdfPath);
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
const loadingTask = pdfjsLib.getDocument({
data: new Uint8Array(pdfBuffer),
});
const pdfDoc = await loadingTask.promise;
const numPages = pdfDoc.numPages;
const resultsDir = path.join(
process.cwd(),
"public",
"results",
user.id,
id,
"ocr"
);
await mkdir(resultsDir, { recursive: true });
const publicDir = `/results/${user.id}/${id}/ocr`;
// Stream progress to the client via newline-delimited JSON
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const { createCanvas } = await import("canvas");
const Tesseract = await import("tesseract.js");
const allPages: { page: number; path: string }[] = [];
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
// Send progress event
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "progress",
page: pageNum,
totalPages: numPages,
}) + "\n"
)
);
const page = await pdfDoc.getPage(pageNum);
const scale = 2.0;
const viewport = page.getViewport({ scale });
const width = Math.floor(viewport.width);
const height = Math.floor(viewport.height);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
await page.render({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasContext: ctx as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvas: canvas as any,
viewport,
}).promise;
const imageBuffer = canvas.toBuffer("image/png");
const tempImgPath = path.join(
resultsDir,
`page_${pageNum}_temp.png`
);
await writeFile(tempImgPath, imageBuffer);
const { data } = await Tesseract.recognize(tempImgPath, "eng");
// Save per-page OCR text
const ocrFileName = `page_${pageNum}.txt`;
const ocrPath = path.join(resultsDir, ocrFileName);
await writeFile(ocrPath, data.text, "utf-8");
const publicPath = `${publicDir}/${ocrFileName}`;
allPages.push({ page: pageNum, path: publicPath });
// Send page-complete event
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "page_done",
page: pageNum,
totalPages: numPages,
text: data.text,
confidence: data.confidence,
path: publicPath,
}) + "\n"
)
);
}
// Update DB
await prisma.pdfDocument.update({
where: { id },
data: {
ocrRan: true,
ocrResultDir: publicDir,
ocrPageCount: numPages,
pageCount: numPages,
},
});
// Send final complete event
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "complete",
pages: allPages,
pageCount: numPages,
}) + "\n"
)
);
controller.close();
} catch (err) {
console.error("OCR streaming error:", err);
controller.enqueue(
encoder.encode(
JSON.stringify({
type: "error",
error: "OCR processing failed",
}) + "\n"
)
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
"Cache-Control": "no-cache",
},
});
} catch (e) {
console.error("OCR error:", e);
return NextResponse.json(
{ error: "OCR processing failed" },
{ status: 500 }
);
}
}
@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import sharp from "sharp";
import { readFile, mkdir } from "fs/promises";
import path from "path";
/**
* Pixel-level analysis: renders EVERY page of the PDF as a PNG,
* inverts colors and boosts contrast to reveal hidden white-on-white text.
* Returns an array of per-page result image paths.
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const doc = await prisma.pdfDocument.findUnique({ where: { id } });
if (!doc) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user || doc.userId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// If already processed, return existing results
if (doc.pixelResultDir && doc.pixelPageCount > 0) {
const pages = Array.from({ length: doc.pixelPageCount }, (_, i) => ({
page: i + 1,
path: `${doc.pixelResultDir}/page_${i + 1}.png`,
}));
return NextResponse.json({ pages, pageCount: doc.pixelPageCount });
}
try {
const pdfPath = path.join(process.cwd(), "public", doc.filePath);
const pdfBuffer = await readFile(pdfPath);
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
const loadingTask = pdfjsLib.getDocument({
data: new Uint8Array(pdfBuffer),
});
const pdfDoc = await loadingTask.promise;
const numPages = pdfDoc.numPages;
const resultsDir = path.join(
process.cwd(),
"public",
"results",
user.id,
id,
"pixel"
);
await mkdir(resultsDir, { recursive: true });
const publicDir = `/results/${user.id}/${id}/pixel`;
const pages: { page: number; path: string }[] = [];
const { createCanvas } = await import("canvas");
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
const scale = 2.0;
const viewport = page.getViewport({ scale });
const width = Math.floor(viewport.width);
const height = Math.floor(viewport.height);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
await page.render({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasContext: ctx as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvas: canvas as any,
viewport,
}).promise;
const imageBuffer = canvas.toBuffer("image/png");
const resultFileName = `page_${pageNum}.png`;
const resultPath = path.join(resultsDir, resultFileName);
await sharp(imageBuffer)
.negate({ alpha: false })
.normalize()
.sharpen()
.toFile(resultPath);
pages.push({
page: pageNum,
path: `${publicDir}/${resultFileName}`,
});
}
await prisma.pdfDocument.update({
where: { id },
data: {
pixelResultDir: publicDir,
pixelPageCount: numPages,
pageCount: numPages,
},
});
return NextResponse.json({ pages, pageCount: numPages });
} catch (e) {
console.error("Pixel analysis error:", e);
return NextResponse.json(
{ error: "Pixel analysis failed" },
{ status: 500 }
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: {
documents: { orderBy: { createdAt: "desc" } },
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json({ documents: user.documents });
}
+88
View File
@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import { writeFile, mkdir } from "fs/promises";
import { v4 as uuidv4 } from "uuid";
import path from "path";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_DOCUMENTS = 5;
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { documents: { orderBy: { createdAt: "asc" } } },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const formData = await req.formData();
const file = formData.get("pdf") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "File exceeds 10 MB limit" },
{ status: 400 }
);
}
if (!file.name.toLowerCase().endsWith(".pdf")) {
return NextResponse.json(
{ error: "Only PDF files are accepted" },
{ status: 400 }
);
}
// Enforce 5-document limit — delete oldest if at cap
if (user.documents.length >= MAX_DOCUMENTS) {
const oldest = user.documents[0];
await prisma.pdfDocument.delete({ where: { id: oldest.id } });
// Note: could also delete files from disk here
}
const fileId = uuidv4();
const uploadsDir = path.join(process.cwd(), "public", "uploads", user.id);
await mkdir(uploadsDir, { recursive: true });
const fileName = `${fileId}.pdf`;
const filePath = path.join(uploadsDir, fileName);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
const publicPath = `/uploads/${user.id}/${fileName}`;
// Detect page count from the uploaded PDF
let pageCount = 0;
try {
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) });
const pdfDoc = await loadingTask.promise;
pageCount = pdfDoc.numPages;
} catch {
// fallback: if pdfjs fails, just store 0
}
const doc = await prisma.pdfDocument.create({
data: {
userId: user.id,
originalName: file.name,
filePath: publicPath,
fileSize: file.size,
pageCount,
},
});
return NextResponse.json({ document: doc }, { status: 201 });
}
+636
View File
@@ -0,0 +1,636 @@
"use client";
import { useEffect, useState, use, useCallback } from "react";
import Link from "next/link";
type DocData = {
id: string;
originalName: string;
filePath: string;
pixelResultDir: string | null;
pixelPageCount: number;
ocrResultDir: string | null;
ocrPageCount: number;
ocrRan: boolean;
fileSize: number;
pageCount: number;
createdAt: string;
};
type PixelPage = { page: number; path: string };
type OcrPage = {
page: number;
path: string;
text?: string;
confidence?: number;
};
export default function ViewerPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [doc, setDoc] = useState<DocData | null>(null);
const [loading, setLoading] = useState(true);
// Pixel state
const [pixelLoading, setPixelLoading] = useState(false);
const [pixelPages, setPixelPages] = useState<PixelPage[]>([]);
const [pixelCurrentPage, setPixelCurrentPage] = useState(1);
// OCR state
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrPages, setOcrPages] = useState<OcrPage[]>([]);
const [ocrCurrentPage, setOcrCurrentPage] = useState(1);
const [ocrProgress, setOcrProgress] = useState({ page: 0, total: 0 });
const [activeTab, setActiveTab] = useState<"original" | "pixel" | "ocr">(
"original"
);
const fetchDoc = useCallback(async () => {
setLoading(true);
const res = await fetch("/api/documents");
if (res.ok) {
const data = await res.json();
const found = data.documents.find((d: DocData) => d.id === id);
if (found) {
setDoc(found);
// Auto-trigger pixel if not done yet
if (!found.pixelResultDir || found.pixelPageCount === 0) {
runPixelAnalysis(found.id);
} else {
// Load cached pixel pages
const pages = Array.from(
{ length: found.pixelPageCount },
(_, i) => ({
page: i + 1,
path: `${found.pixelResultDir}/page_${i + 1}.png`,
})
);
setPixelPages(pages);
}
// If OCR was already run, load the pages
if (found.ocrRan && found.ocrResultDir && found.ocrPageCount > 0) {
loadOcrPages(found.ocrResultDir, found.ocrPageCount);
}
}
}
setLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
fetchDoc();
}, [fetchDoc]);
const runPixelAnalysis = async (docId: string) => {
setPixelLoading(true);
try {
const res = await fetch(`/api/documents/${docId}/pixel`, {
method: "POST",
});
if (res.ok) {
const data = await res.json();
setPixelPages(data.pages);
setDoc((prev) =>
prev
? {
...prev,
pixelResultDir:
data.pages[0]?.path?.replace(/\/page_\d+\.png$/, "") ?? null,
pixelPageCount: data.pageCount,
pageCount: data.pageCount,
}
: prev
);
}
} catch (e) {
console.error("Pixel analysis error:", e);
}
setPixelLoading(false);
};
const loadOcrPages = async (dir: string, count: number) => {
const pages: OcrPage[] = [];
for (let i = 1; i <= count; i++) {
const pagePath = `${dir}/page_${i}.txt`;
try {
const res = await fetch(pagePath);
const text = res.ok ? await res.text() : "(failed to load)";
pages.push({ page: i, path: pagePath, text });
} catch {
pages.push({ page: i, path: pagePath, text: "(failed to load)" });
}
}
setOcrPages(pages);
};
const runOcr = async () => {
if (!doc || doc.ocrRan) return;
setOcrLoading(true);
setOcrProgress({ page: 0, total: 0 });
setOcrPages([]);
try {
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
method: "POST",
});
if (!res.ok) {
if (res.status === 409) {
const data = await res.json();
if (data.pages) {
await loadOcrPages(
data.pages[0]?.path?.replace(/\/page_\d+\.txt$/, ""),
data.pageCount
);
setDoc((prev) => (prev ? { ...prev, ocrRan: true } : prev));
}
}
setOcrLoading(false);
return;
}
// Stream reading (NDJSON)
const reader = res.body?.getReader();
if (!reader) {
setOcrLoading(false);
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "progress") {
setOcrProgress({ page: event.page, total: event.totalPages });
} else if (event.type === "page_done") {
setOcrProgress({ page: event.page, total: event.totalPages });
setOcrPages((prev) => [
...prev,
{
page: event.page,
path: event.path,
text: event.text,
confidence: event.confidence,
},
]);
} else if (event.type === "complete") {
setDoc((prev) =>
prev
? {
...prev,
ocrRan: true,
ocrResultDir:
event.pages[0]?.path?.replace(
/\/page_\d+\.txt$/,
""
) ?? null,
ocrPageCount: event.pageCount,
}
: prev
);
}
} catch {
// skip malformed lines
}
}
}
} catch (e) {
console.error("OCR error:", e);
}
setOcrLoading(false);
};
const downloadAll = (type: "pixel" | "ocr") => {
const pages = type === "pixel" ? pixelPages : ocrPages;
pages.forEach((p) => {
const a = document.createElement("a");
a.href = p.path;
a.download = p.path.split("/").pop() || `page_${p.page}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
};
if (loading) {
return (
<main className="flex min-h-[60vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
</main>
);
}
if (!doc) {
return (
<main className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
<p className="text-zinc-400">Document not found.</p>
<Link
href="/dashboard"
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-semibold transition hover:bg-violet-500"
>
Back to Dashboard
</Link>
</main>
);
}
return (
<main className="mx-auto max-w-[1600px] px-4 py-6">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link
href="/dashboard"
className="rounded-lg border border-white/10 p-2 transition hover:border-white/20 hover:bg-white/5"
>
<svg
className="h-4 w-4 text-zinc-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</Link>
<div>
<h1 className="text-lg font-bold">{doc.originalName}</h1>
<p className="text-xs text-zinc-500">
{doc.pageCount > 0 ? `${doc.pageCount} pages \u00b7 ` : ""}
Uploaded {new Date(doc.createdAt).toLocaleString()}
</p>
</div>
</div>
{/* Mobile tab switcher */}
<div className="flex gap-1 rounded-xl bg-white/5 p-1 sm:hidden">
{(["original", "pixel", "ocr"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 rounded-lg px-3 py-1.5 text-xs font-semibold capitalize transition ${
activeTab === tab
? "bg-violet-600 text-white"
: "text-zinc-400 hover:text-white"
}`}
>
{tab === "ocr" ? "OCR" : tab}
</button>
))}
</div>
</div>
{/* 3-Panel Grid */}
<div className="grid gap-4 sm:grid-cols-3">
{/* Panel 1: Original */}
<Panel
title="Original PDF"
badge={doc.pageCount > 0 ? `${doc.pageCount} pages` : "Source"}
badgeColor="bg-blue-500/10 text-blue-400"
visible={activeTab === "original"}
>
<iframe
src={doc.filePath}
className="h-full w-full rounded-lg"
title="Original PDF"
/>
</Panel>
{/* Panel 2: Pixel Analysis */}
<Panel
title="Pixel Analysis"
badge={
pixelLoading
? "Processing..."
: pixelPages.length > 0
? `${pixelPages.length} pages`
: "Pending"
}
badgeColor="bg-violet-500/10 text-violet-400"
visible={activeTab === "pixel"}
onDownload={
pixelPages.length > 0 ? () => downloadAll("pixel") : undefined
}
>
{pixelLoading ? (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
<p className="text-sm text-zinc-500">
Analyzing all pages...
</p>
</div>
</div>
) : pixelPages.length > 0 ? (
<div className="flex h-full flex-col">
{/* Page navigator */}
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
<button
onClick={() =>
setPixelCurrentPage((p) => Math.max(1, p - 1))
}
disabled={pixelCurrentPage <= 1}
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
>
Prev
</button>
<span className="text-xs text-zinc-400">
Page {pixelCurrentPage} / {pixelPages.length}
</span>
<button
onClick={() =>
setPixelCurrentPage((p) =>
Math.min(pixelPages.length, p + 1)
)
}
disabled={pixelCurrentPage >= pixelPages.length}
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
>
Next
</button>
</div>
{/* Image display */}
<div className="flex-1 overflow-auto p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={pixelPages[pixelCurrentPage - 1]?.path}
alt={`Pixel analysis page ${pixelCurrentPage}`}
className="w-full rounded-lg object-contain"
/>
</div>
{/* Page thumbnails */}
{pixelPages.length > 1 && (
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
{pixelPages.map((p) => (
<button
key={p.page}
onClick={() => setPixelCurrentPage(p.page)}
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
pixelCurrentPage === p.page
? "border-violet-500 bg-violet-500/20 text-violet-300"
: "border-white/10 text-zinc-500 hover:text-zinc-300"
}`}
>
{p.page}
</button>
))}
</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
Pixel analysis will run automatically...
</div>
)}
</Panel>
{/* Panel 3: Tesseract OCR */}
<Panel
title="Tesseract OCR"
badge={
doc.ocrRan
? `${ocrPages.length} pages`
: ocrLoading
? `Processing ${ocrProgress.page}/${ocrProgress.total}`
: "Manual"
}
badgeColor={
doc.ocrRan
? "bg-emerald-500/10 text-emerald-400"
: "bg-amber-500/10 text-amber-400"
}
visible={activeTab === "ocr"}
onDownload={
ocrPages.length > 0 ? () => downloadAll("ocr") : undefined
}
>
{ocrLoading ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
<p className="text-sm text-zinc-400">
Running Tesseract OCR...
</p>
{/* Progress bar */}
{ocrProgress.total > 0 && (
<div className="w-full max-w-xs">
<div className="mb-1 flex justify-between text-[10px] text-zinc-500">
<span>
Page {ocrProgress.page} of {ocrProgress.total}
</span>
<span>
{Math.round(
(ocrProgress.page / ocrProgress.total) * 100
)}
%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{
width: `${(ocrProgress.page / ocrProgress.total) * 100}%`,
}}
/>
</div>
</div>
)}
{/* Live results feed */}
{ocrPages.length > 0 && (
<div className="mt-2 w-full max-w-xs rounded-lg border border-white/5 bg-white/[0.02] p-3">
<p className="mb-1 text-[10px] font-semibold text-zinc-500">
Pages completed:
</p>
{ocrPages.map((p) => (
<div
key={p.page}
className="flex items-center justify-between text-[10px] text-zinc-400"
>
<span>Page {p.page}</span>
{p.confidence !== undefined && (
<span className="text-emerald-400">
{p.confidence.toFixed(0)}%
</span>
)}
</div>
))}
</div>
)}
</div>
) : ocrPages.length > 0 ? (
<div className="flex h-full flex-col">
{/* Page navigator */}
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
<button
onClick={() =>
setOcrCurrentPage((p) => Math.max(1, p - 1))
}
disabled={ocrCurrentPage <= 1}
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
>
Prev
</button>
<span className="text-xs text-zinc-400">
Page {ocrCurrentPage} / {ocrPages.length}
{ocrPages[ocrCurrentPage - 1]?.confidence !== undefined && (
<span className="ml-2 text-emerald-400">
({ocrPages[ocrCurrentPage - 1].confidence!.toFixed(0)}%
conf)
</span>
)}
</span>
<button
onClick={() =>
setOcrCurrentPage((p) =>
Math.min(ocrPages.length, p + 1)
)
}
disabled={ocrCurrentPage >= ocrPages.length}
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
>
Next
</button>
</div>
{/* OCR text display */}
<div className="flex-1 overflow-auto p-4">
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-zinc-300">
{ocrPages[ocrCurrentPage - 1]?.text ||
"(no text extracted)"}
</pre>
</div>
{/* Page thumbnails */}
{ocrPages.length > 1 && (
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
{ocrPages.map((p) => (
<button
key={p.page}
onClick={() => setOcrCurrentPage(p.page)}
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
ocrCurrentPage === p.page
? "border-emerald-500 bg-emerald-500/20 text-emerald-300"
: "border-white/10 text-zinc-500 hover:text-zinc-300"
}`}
>
{p.page}
</button>
))}
</div>
)}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-4">
<div className="rounded-full bg-amber-500/10 p-4">
<svg
className="h-8 w-8 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div className="text-center">
<p className="mb-1 text-sm font-medium text-zinc-300">
OCR Not Yet Run
</p>
<p className="mb-4 text-xs text-zinc-500">
Runs Tesseract on every page. Only one run allowed per doc.
</p>
<button
onClick={runOcr}
disabled={doc.ocrRan}
className="rounded-full bg-emerald-600 px-6 py-2.5 text-sm font-semibold transition hover:bg-emerald-500 disabled:opacity-50"
>
Run Tesseract OCR
</button>
</div>
</div>
)}
</Panel>
</div>
</main>
);
}
function Panel({
title,
badge,
badgeColor,
children,
visible,
onDownload,
}: {
title: string;
badge: string;
badgeColor: string;
children: React.ReactNode;
visible: boolean;
onDownload?: () => void;
}) {
return (
<div
className={`flex flex-col overflow-hidden rounded-2xl border border-white/5 bg-white/[0.02] ${
visible ? "" : "hidden sm:flex"
}`}
>
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
<h3 className="text-sm font-semibold">{title}</h3>
<div className="flex items-center gap-2">
{onDownload && (
<button
onClick={onDownload}
className="rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold text-zinc-400 transition hover:border-white/20 hover:text-white"
title="Download all pages"
>
<svg
className="mr-1 inline-block h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
All
</button>
)}
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${badgeColor}`}
>
{badge}
</span>
</div>
</div>
<div className="flex-1" style={{ minHeight: "65vh" }}>
{children}
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import Navbar from "@/components/Navbar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
<Navbar />
{children}
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
type Document = {
id: string;
originalName: string;
filePath: string;
fileSize: number;
createdAt: string;
ocrRan: boolean;
};
export default function DashboardPage() {
const [docs, setDocs] = useState<Document[]>([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const router = useRouter();
const fetchDocs = useCallback(async () => {
const res = await fetch("/api/documents");
if (res.ok) {
const data = await res.json();
setDocs(data.documents);
}
}, []);
useEffect(() => {
void fetchDocs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleUpload = async (file: File) => {
if (!file.name.toLowerCase().endsWith(".pdf")) {
alert("Only PDF files are accepted.");
return;
}
if (file.size > 10 * 1024 * 1024) {
alert("File must be smaller than 10 MB.");
return;
}
setUploading(true);
const formData = new FormData();
formData.append("pdf", file);
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (res.ok) {
const data = await res.json();
router.push(`/dashboard/${data.document.id}`);
} else {
const err = await res.json();
alert(err.error || "Upload failed");
}
setUploading(false);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleUpload(file);
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this document?")) return;
await fetch(`/api/documents/${id}/delete`, { method: "DELETE" });
fetchDocs();
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<main className="mx-auto max-w-5xl px-6 py-10">
{/* Upload zone */}
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`relative flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 text-center transition-colors ${
dragOver
? "border-violet-500 bg-violet-500/5"
: "border-white/10 bg-white/[0.02] hover:border-white/20"
}`}
>
<div className="mb-4 rounded-full bg-violet-600/10 p-4">
<svg
className="h-8 w-8 text-violet-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
</div>
<p className="mb-1 text-lg font-semibold">
{uploading ? "Uploading..." : "Drop your PDF here"}
</p>
<p className="mb-4 text-sm text-zinc-500">
or click to browse &middot; Max 10 MB &middot; {docs.length}/5 slots
used
</p>
<label className="cursor-pointer rounded-full bg-violet-600 px-6 py-2.5 text-sm font-semibold transition hover:bg-violet-500">
Choose File
<input
type="file"
accept=".pdf"
onChange={handleFileChange}
className="hidden"
disabled={uploading}
/>
</label>
</div>
{/* Document list */}
<section className="mt-12">
<h2 className="mb-4 text-xl font-bold">Your Documents</h2>
{docs.length === 0 ? (
<p className="text-zinc-500">
No documents yet. Upload a PDF to get started.
</p>
) : (
<div className="grid gap-3">
{docs.map((doc) => (
<div
key={doc.id}
className="group flex items-center justify-between rounded-xl border border-white/5 bg-white/[0.02] p-4 transition hover:border-white/10 hover:bg-white/[0.04]"
>
<button
onClick={() => router.push(`/dashboard/${doc.id}`)}
className="flex flex-1 items-center gap-4 text-left"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-violet-600/10">
<svg
className="h-5 w-5 text-violet-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
</div>
<div>
<p className="font-medium">{doc.originalName}</p>
<p className="text-xs text-zinc-500">
{formatSize(doc.fileSize)} &middot;{" "}
{new Date(doc.createdAt).toLocaleDateString()} &middot;{" "}
{doc.ocrRan ? "OCR complete" : "OCR pending"}
</p>
</div>
</button>
<button
onClick={() => handleDelete(doc.id)}
className="ml-4 rounded-lg p-2 text-zinc-600 opacity-0 transition hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
title="Delete"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
))}
</div>
)}
</section>
</main>
);
}
+19 -10
View File
@@ -1,8 +1,8 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #0a0a0a;
--foreground: #ededed;
}
@theme inline {
@@ -12,15 +12,24 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
}
/* Smooth scrollbar for dark theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
+3 -2
View File
@@ -14,8 +14,9 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "XRayPDF — Reveal Hidden Text in PDFs",
description:
"Upload homework PDFs and uncover hidden text with pixel analysis and Tesseract OCR.",
};
export default function RootLayout({
+116 -51
View File
@@ -1,65 +1,130 @@
import Image from "next/image";
"use client";
import { signIn, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function Home() {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (session) router.replace("/dashboard");
}, [session, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="relative flex min-h-screen flex-col overflow-hidden bg-[#0a0a0a] text-white">
{/* Ambient glow */}
<div className="pointer-events-none absolute -top-40 left-1/2 h-[600px] w-[900px] -translate-x-1/2 rounded-full bg-violet-600/20 blur-[160px]" />
{/* Nav */}
<nav className="relative z-10 flex items-center justify-between px-8 py-6 md:px-16">
<div className="flex items-center gap-2 text-xl font-bold tracking-tight">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-violet-600 text-sm font-black">
X
</span>
XRayPDF
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<button
onClick={() => signIn("google")}
disabled={status === "loading"}
className="rounded-full bg-white px-6 py-2.5 text-sm font-semibold text-black transition hover:bg-zinc-200 disabled:opacity-50"
>
{status === "loading" ? "Loading..." : "Sign in with Google"}
</button>
</nav>
{/* Hero */}
<main className="relative z-10 flex flex-1 flex-col items-center justify-center px-6 text-center">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-medium text-zinc-400 backdrop-blur">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Open Source &middot; Free to Use
</div>
<h1 className="max-w-3xl text-5xl font-extrabold leading-[1.1] tracking-tight md:text-7xl">
Reveal{" "}
<span className="bg-gradient-to-r from-violet-400 via-fuchsia-400 to-pink-400 bg-clip-text text-transparent">
Hidden Text
</span>{" "}
in Any PDF
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-zinc-400 md:text-xl">
Upload your homework PDF and instantly uncover invisible text hidden
with white fonts, tiny sizes, or steganographic tricks — powered by
pixel analysis &amp; Tesseract&nbsp;OCR.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
<button
onClick={() => signIn("google")}
className="group relative inline-flex items-center justify-center gap-2 rounded-full bg-violet-600 px-8 py-3.5 text-sm font-semibold transition hover:bg-violet-500"
>
<svg
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
Get Started — It&apos;s Free
</button>
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
href="https://github.com/yourusername/XRayPDF"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-full border border-white/10 px-8 py-3.5 text-sm font-semibold text-zinc-300 transition hover:border-white/25 hover:text-white"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
View on GitHub
</a>
</div>
</main>
{/* Feature cards */}
<section className="relative z-10 mx-auto grid max-w-5xl gap-4 px-6 pb-24 md:grid-cols-3">
{[
{
icon: "🔍",
title: "Pixel Analysis",
desc: "Inverts and contrast-boosts every page to expose hidden white-on-white text instantly.",
},
{
icon: "🔡",
title: "Tesseract OCR",
desc: "Server-side OCR detects tiny or cleverly-colored text that pixel analysis alone can't catch.",
},
{
icon: "📄",
title: "Side-by-Side View",
desc: "Compare original, pixel-revealed, and OCR results in a beautiful three-panel layout.",
},
].map((f) => (
<div
key={f.title}
className="rounded-2xl border border-white/5 bg-white/[0.02] p-6 backdrop-blur transition hover:border-white/10 hover:bg-white/[0.04]"
>
<div className="mb-3 text-3xl">{f.icon}</div>
<h3 className="mb-1 text-lg font-semibold">{f.title}</h3>
<p className="text-sm leading-relaxed text-zinc-500">{f.desc}</p>
</div>
))}
</section>
{/* Footer */}
<footer className="relative z-10 border-t border-white/5 py-6 text-center text-xs text-zinc-600">
&copy; {new Date().getFullYear()} XRayPDF &mdash; Built for students,
by students.
</footer>
</div>
);
}