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:
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 · Max 10 MB · {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)} ·{" "}
|
||||
{new Date(doc.createdAt).toLocaleDateString()} ·{" "}
|
||||
{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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 · 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 & Tesseract 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'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">
|
||||
© {new Date().getFullYear()} XRayPDF — Built for students,
|
||||
by students.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Navbar() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0a]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6">
|
||||
{/* Logo */}
|
||||
<Link href="/dashboard" className="flex items-center gap-2 text-lg font-bold tracking-tight text-white">
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-violet-600 text-xs font-black">
|
||||
X
|
||||
</span>
|
||||
XRayPDF
|
||||
</Link>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{session?.user && (
|
||||
<>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
{session.user.image && (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-zinc-400">
|
||||
{session.user.name ?? session.user.email}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="rounded-lg border border-white/10 px-4 py-1.5 text-sm font-medium text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export default withAuth(
|
||||
);
|
||||
|
||||
export const config = {
|
||||
matcher: ["/"],
|
||||
matcher: ["/dashboard/:path*", "/api/:path*"],
|
||||
};
|
||||
Reference in new issue
Block a user