Compare commits

..

No commits in common. "feature/dal-refactor" and "main" have entirely different histories.

52 changed files with 1080 additions and 36088 deletions

1
.env
View file

@ -1,5 +1,4 @@
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
# values generated by Gemini
# Generated for security

View file

@ -1,5 +1,4 @@
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
# values generated by Gemini
# Generated for security

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -1,59 +0,0 @@
# WebCalibre: Application Context Document (v1.0)
## 1 Purpose & Vision
WebCalibre is a specialized Digital Asset Manager (DAM) designed to bridge the gap between cloud storage (OneDrive) and local metadata enrichment. It allows users to organize files while extracting and preserving deep technical metadata (EXIF for images, Document Info for PDFs) that standard file explorers ignore.
## 2 Core Technical Stack
- Framework: Next.js 15+ (App Router)
- Language: TypeScript
- Database: PostgreSQL via Prisma ORM
- Authentication: NextAuth.js (Auth.js)
- Styling: Material UI (MUI)
- Storage: Integrated with Microsoft OneDrive (via Microsoft Graph API)
- Processing:
- **Images:** sharp + exif-reader
- **PDFs: pdf-parse-new (v2 logic)
-
## 3 Application Architecture
### **A. Data Access Pattern (DAL)**
The project follows a strict Data Access Layer pattern located in src/data-access/.
- **Purpose:** All Prisma queries and database interactions are isolated here.
- **Benefit:** Components and Server Actions do not talk directly to the database; they call functions from file-nodes.ts or users.ts. This provides a single point of truth for data fetching and improves security by centralizing authorization checks.
**B. The Data Layer** (prisma/schema.prisma)
The app uses a **Recursive Tree Structure** for files and folders:
- **FileNode:** Represents both files and folders. Folders have a parentId pointing to another FileNode.
- **Metadata:** Stored as a JSONB field in the database, allowing for flexible, unstructured data from different file types.
**C. The Extraction Engine** (src/lib/metadata-extractor.ts)
A server-side utility that:
1. Identifies file type by extension.
2. Parses the file Buffer.
3. Images: Extracts camera make, model, GPS coordinates (DMS), aperture, and ISO.
4. PDFs: Extracts Author, Title, Subject, Page Count, and a Text Preview.
5. Sanitization: Normalizes raw binary buffers and complex objects into JSON-safe strings.
**D. The Transformation Layer** (src/lib/transformers.ts)
Converts raw, lowercase, or inconsistent metadata into a GUI-ready object. It handles unit conversions (e.g., Shutter Speed 0.02 → 1/50s) and coordinate mapping for Google Maps.
4. **Current Feature Roadmap & Status**
|Feature|Status|Description|
|-------|------|-----------|
|Dashboard|✅ Active|Tabular view of FileNodes. Supports "Magic Fill" and file listing.|
|Upload & Enrich|✅ FixedUploads files to OneDrive and creates sub-folders.|
|PDF Extraction|✅ Active|Uses pdf-parse-new for high-fidelity metadata.
|Library|⏳ Planned|A "Discovery" view (Gallery for images, Bookshelf for PDFs)||File Detail View|🚀 Next Up|A dedicated page for deep-diving into metadata (The "Double-Click" view).|
1. **Known Logic & UX Patterns**
- **Magic Fill:** A core "Wow" feature where the app reads the file binary before saving to suggest metadata properties.
- **Folder Logic:** Folders can be created at the "Root" (WebCalibre folder on OneDrive) or nested within existing directories.
- **Server Actions:** All database and storage mutations are handled via Next.js Server Actions for security and speed.
1. **Critical Fixes Applied**
- PDF Worker Crash: Resolved by using pdf-parse-new and disabling the web-worker in the Node.js environment.
- Folder Reset Bug: Fixed in upload-view.tsx by removing the logic that cleared the targetFolderId when toggling the "New Folder" input.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

Binary file not shown.

2020
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "next dev",
"backfill": "tsx scripts/backfill-hashes.ts",
"build": "next build",
"start": "next start",
"lint": "eslint",
@ -22,20 +21,12 @@
"@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid": "^8.24.0",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "7.4.0",
"epub": "^1.3.0",
"epub2": "^3.0.2",
"exif-reader": "^2.0.3",
"@prisma/client": "^7.2.0",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"pdf-parse": "^2.4.5",
"pdf-parse-new": "^2.0.0",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-dropzone": "^15.0.0",
"server-only": "^0.0.1",
"sharp": "^0.34.5"
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/node": "^20",
@ -45,8 +36,7 @@
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prisma": "7.4.0",
"tsx": "^4.21.0",
"prisma": "^7.2.0",
"typescript": "^5"
}
}

View file

@ -1,13 +1,12 @@
// prisma.config.ts
import { config } from "dotenv";
config({ path: ".env.local" });
import { defineConfig, env } from "prisma/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
// This is where Prisma 7 looks for the connection string
url: env("DATABASE_URL"),
// This is now the ONLY place where the DB connection string is defined
url: process.env.DATABASE_URL,
},
});

View file

@ -1,58 +1,20 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"azureAdUserId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'USER',
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"azureAdUserId" TEXT,
"displayName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileNode" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"size" BIGINT,
"hash" TEXT,
"isFolder" BOOLEAN NOT NULL DEFAULT false,
"oneDriveId" TEXT,
"path" TEXT NOT NULL,
@ -67,17 +29,11 @@ CREATE TABLE "FileNode" (
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
@ -91,15 +47,8 @@ CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
-- CreateIndex
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,52 @@
/*
Warnings:
- You are about to drop the column `displayName` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "displayName",
ADD COLUMN "emailVerified" TIMESTAMP(3),
ADD COLUMN "image" TEXT,
ADD COLUMN "name" TEXT,
ALTER COLUMN "azureAdUserId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[hash]` on the table `FileNode` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "FileNode_hash_key" ON "FileNode"("hash");

View file

@ -1,2 +0,0 @@
-- DropIndex
DROP INDEX "FileNode_hash_key";

View file

@ -60,7 +60,6 @@ model FileNode {
id String @id
name String
size BigInt? // Preserved your BigInt size column
hash String?
isFolder Boolean @default(false)
oneDriveId String? @unique
path String

View file

@ -1,59 +0,0 @@
import 'dotenv/config';
import { prisma } from '../src/lib/prisma';
import * as nodeCrypto from 'crypto';
function generateFileHash(buffer: Buffer): string {
return nodeCrypto.createHash('sha256').update(buffer).digest('hex');
}
/**
* Replace this with your actual OneDrive download logic!
*/
async function getFromOneDrive(oneDriveId: string): Promise<Buffer> {
// For now, let's pretend we downloaded it to test the DB update
// DELETE THESE 2 LINES when you add your real OneDrive fetch code:
console.log(` ⬇️ Downloading ${oneDriveId}...`);
return Buffer.from(`mock-data-for-${oneDriveId}`);
}
async function backfill() {
console.log('🏁 Starting backfill with Prisma Adapter...');
try {
const files = await prisma.fileNode.findMany({
where: { isFolder: false
},
});
console.log(`📂 Found ${files.length} files to process.`);
for (const file of files) {
try {
process.stdout.write(`Processing: ${file.name}... `);
// 1. Get the file content
const buffer = await getFromOneDrive(file.oneDriveId!);
// 2. Generate the hash
const hash = generateFileHash(buffer);
// 3. Update the database
await prisma.fileNode.update({
where: { id: file.id },
data: { hash: hash }
});
console.log(`✅ Success! (Hash: ${hash.substring(0, 8)}...)`);
} catch (err) {
console.log(`❌ Failed: ${err instanceof Error ? err.message : err}`);
}
}
} catch (error) {
console.error('🚨 Script Error:', error);
} finally {
await prisma.$disconnect();
console.log('🏁 Finished.');
}
}
backfill();

View file

@ -1,57 +1,66 @@
// src/app/api/download/route.ts
"use server"
import { NextRequest, NextResponse } from 'next/server';
import { auth } from "@/auth";
import { getFileNodeById } from "@/data-access/file-nodes";
import { getOneDriveContentStream } from "@/services/onedrive";
import { auth } from "@/auth"; // Import the auth function from your central config
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
// 1. Authenticate the user session
// 1. Check Authentication using the V5 auth() helper
const session = await auth();
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 });
// In V5, tokens are usually handled in the session callback
if (!session || !session.accessToken) {
return new NextResponse("Unauthorized - No Access Token found", { status: 401 });
}
// 2. Extract parameters from URL
// 2. Get parameters from URL
const { searchParams } = new URL(request.url);
const fileId = searchParams.get('id');
const fileNodeId = searchParams.get('id');
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
if (!fileId) {
if (!fileNodeId) {
return new NextResponse("File ID is required", { status: 400 });
}
// 3. DAL: Fetch file metadata from local database
const fileNode = await getFileNodeById(fileId);
// 3. Find the file in your Postgres FileNode table
const fileNode = await prisma.fileNode.findUnique({
where: { id: fileNodeId }
});
if (!fileNode || !fileNode.oneDriveId) {
return new NextResponse("File not found", { status: 404 });
return new NextResponse("File not found in database", { status: 404 });
}
// 4. SERVICE: Get the binary stream from Microsoft Graph
// The service layer automatically handles the 'getFreshAccessToken' logic
const graphResponse = await getOneDriveContentStream(session.user.id, fileNode.oneDriveId);
// 4. Fetch the file stream from Microsoft Graph
const graphResponse = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${fileNode.oneDriveId}/content`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
if (!graphResponse.ok) {
const errorText = await graphResponse.text();
console.error('MS Graph Error:', errorText);
return new NextResponse(`OneDrive Error: ${graphResponse.statusText}`, { status: graphResponse.status });
}
// 5. Stream the response directly to the client
// We pass the graphResponse.body (ReadableStream) directly to NextResponse
// src/app/api/download/route.ts
const metadata = fileNode.metadata as any;
const contentType = metadata?.mimeType || 'application/octet-stream';
return new NextResponse(graphResponse.body, {
status: 200,
headers: {
'Content-Type': fileNode.mimeType || 'application/octet-stream',
// Note: Using encodeURIComponent for filename to handle special characters
'Content-Disposition': `${mode}; filename="${encodeURIComponent(fileNode.name)}"`,
},
});
return new NextResponse(graphResponse.body, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Disposition': `${mode}; filename="${encodeURIComponent(fileNode.name)}"`,
},
});
} catch (error: any) {
console.error('Download Route Error:', error);
return new NextResponse(
JSON.stringify({ error: "Internal Server Error", message: error.message }),
{ status: 500 }
);
} catch (error) {
console.error('Download error:', error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}

View file

@ -1,90 +1,138 @@
// src/app/dashboard/actions.ts
'use server';
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import {
getAllFileNodes,
getFileNodeById,
updateFileNode,
deleteFileNode
} from "@/data-access/file-nodes";
import {
getOneDriveItem,
deleteFromOneDrive,
uploadToOneDrive
} from "@/services/onedrive";
//import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { getOneDriveFileBuffer } from "@/services/onedrive"; // Import your working service
import { extractMetadata } from "@/lib/metadata-extractor";
/**
* 1. FETCH: Get all file nodes
* Now simply calls the DAL. Error handling is left to the caller (the UI).
* 1. FETCH: Get all file nodes for the Dashboard
*/
export async function getFileNodes() {
return await getAllFileNodes();
try {
const nodes = await prisma.fileNode.findMany({
orderBy: {
updatedAt: 'desc',
},
});
return nodes;
} catch (error) {
console.error("Error fetching file nodes:", error);
return [];
}
}
/**
* 2. DOWNLOAD: Generates the authenticated OneDrive URL
* Orchestrates the session check, DAL lookup, and Service call.
*/
export async function getDownloadUrlAction(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const file = await getFileNodeById(id);
const file = await prisma.fileNode.findUnique({ where: { id } });
if (!file || !file.oneDriveId) throw new Error("File not found or missing cloud ID");
// Service handles token refresh and graph request internally
const data = await getOneDriveItem(session.user.id, file.oneDriveId);
const accessToken = await getFreshAccessToken(session.user.id);
const res = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${file.oneDriveId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) throw new Error("Failed to contact OneDrive");
const data = await res.json();
const downloadUrl = data["@microsoft.graph.downloadUrl"];
if (!downloadUrl) throw new Error("OneDrive did not provide a download URL");
return downloadUrl;
if (!downloadUrl) throw new Error("OneDrive did not provide a download link");
return { downloadUrl };
}
/**
* 3. DELETE: Removes from both Cloud and Database
* 3. DELETE: Remove from OneDrive (via ID) and Database
* Folders are virtual (DB only), so cloud deletion is skipped if oneDriveId is null.
*/
export async function deleteFileNodeAction(id: string) {
export async function deleteFileAction(fileId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const file = await getFileNodeById(id);
if (!file) throw new Error("File record not found");
// Phase 1: Cloud Deletion
if (file.oneDriveId) {
await deleteFromOneDrive(session.user.id, file.oneDriveId);
}
// Phase 2: Database Deletion
await deleteFileNode(id);
const node = await prisma.fileNode.findUnique({
where: { id: fileId },
});
if (!node) {
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Delete Error:", error);
return { success: false, error: "Failed to delete file" };
}
// @ts-ignore
const isAdmin = session.user.role === "ADMIN";
const isOwner = node.ownerId === session.user.id;
if (!isAdmin && !isOwner) {
throw new Error("Permission Denied.");
}
try {
const accessToken = await getFreshAccessToken(session.user.id);
// Only attempt cloud deletion if it's a file/storage with a oneDriveId.
// Virtual folders created in the DB have no oneDriveId and are skipped.
if (accessToken && node.oneDriveId) {
const onedriveRes = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!onedriveRes.ok && onedriveRes.status !== 404) {
console.warn("OneDrive Deletion Warning: Cloud record might still exist.");
}
}
} catch (cloudError) {
console.error("Cloud cleanup failed:", cloudError);
}
try {
await prisma.fileNode.delete({ where: { id: fileId } });
revalidatePath("/dashboard");
revalidatePath("/upload");
return { success: true };
} catch (dbError) {
throw new Error("Failed to remove the record from the database.");
}
}
/**
* 4. UPDATE: Modify record and optionally sync new content to OneDrive
* 4. MOVE: Assign file to folder or folder to another folder (Virtual Move)
*/
export async function updateFileNodeAction(id: string, formData: FormData) {
export async function moveNodeAction(nodeId: string, newParentId: string | null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
if (nodeId === newParentId) throw new Error("Cannot move to self.");
try {
await prisma.fileNode.update({
where: { id: nodeId },
data: { parentId: newParentId }
});
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
throw new Error("Move failed.");
}
}
/**
* 5. UPDATE & REPLACE: Full update of metadata and OneDrive content
*/
export async function updateFileFullAction(formData: FormData) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const parentIdRaw = formData.get("parentId") as string;
@ -95,62 +143,44 @@ export async function updateFileNodeAction(id: string, formData: FormData) {
let metadata = JSON.parse(metadataStr);
try {
const node = await getFileNodeById(id);
const accessToken = await getFreshAccessToken(session.user.id);
const node = await prisma.fileNode.findUnique({ where: { id } });
// If a new file is uploaded, push it to OneDrive first
// Update physical file content only if a new file is uploaded and we have a target oneDriveId
if (newFile && newFile.size > 0 && node?.oneDriveId) {
await uploadToOneDrive(session.user.id, newFile, node.oneDriveId);
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}/content`;
const uploadRes = await fetch(onedrivePath, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": newFile.type
},
body: Buffer.from(await newFile.arrayBuffer()),
});
if (!uploadRes.ok) throw new Error("OneDrive content update failed");
metadata.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN';
metadata.mimeType = newFile.type;
}
// Update the database via DAL
await updateFileNode(id, {
name,
description,
parentId,
metadata,
size: newFile ? BigInt(newFile.size) : undefined,
await prisma.fileNode.update({
where: { id },
data: {
name,
description,
parentId,
metadata,
size: newFile ? BigInt(newFile.size) : undefined,
updatedAt: new Date(),
}
});
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Update Error:", error);
return { success: false, error: "Failed to update record" };
}
}
export async function getMetadataPreviewAction(fileId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const node = await getFileNodeById(fileId);
if (!node || !node.oneDriveId) throw new Error("No OneDrive ID found");
console.log(`📡 Attempting fetch via Service for: ${node.name}`);
const token = await getFreshAccessToken(session.user.id);
if (!token) throw new Error("Could not retrieve access token");
// Call your existing service
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
console.log(`📦 Buffer received: ${buffer.length} bytes`);
const extractedData = await extractMetadata(buffer, node.name);
console.log("✅ Extracted:", extractedData);
return { success: true, data: extractedData };
} catch (error: any) {
console.error("❌ Service Fetch Error:", error.message);
// If this still says ENOTFOUND, the code is fine, but the terminal is blocked.
return { success: false, error: error.message };
console.error("Full Update Failure:", error);
throw new Error(error.message || "Failed to update record.");
}
}

View file

@ -1,7 +1,8 @@
'use client';
// src/app/dashboard-view.tsx
// src/app/dashboard/dashboard-view.tsx
import { useState } from "react";
import { styled } from '@mui/material/styles';
import {
Button,
CircularProgress,
@ -12,16 +13,15 @@ import {
Stack,
TextField,
InputAdornment,
Tooltip,
Tooltip
} from "@mui/material";
import {
DataGrid,
GridColDef,
Toolbar,
QuickFilter,
QuickFilterControl,
Toolbar,
QuickFilter,
QuickFilterControl,
QuickFilterClear,
GridEventListener,
} from "@mui/x-data-grid";
import SyncIcon from "@mui/icons-material/Sync";
import RefreshIcon from "@mui/icons-material/Refresh";
@ -35,42 +35,25 @@ import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { syncOneDrive } from "./sync-actions";
import { deleteFileNodeAction } from "./actions";
import { deleteFileAction } from "./actions";
import { useRouter } from "next/navigation";
// --- 1. Styled Component for Search Placement ---
const StyledQuickFilter = styled(QuickFilter)({
marginLeft: 'auto', // Pushes the search box to the right side of the toolbar
});
// --- 2. Custom Toolbar Component ---
function CustomToolbar() {
return (
<Toolbar >
<Box sx={{
display: 'flex',
width: '100%',
alignItems: 'center',
p: 2,
borderBottom: '1px solid',
borderColor: 'divider'
}}>
<Toolbar sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" fontWeight="bold" color="primary">
Library
</Typography>
{/* The 'expanded' prop ensures the search input is always visible by default */}
<StyledQuickFilter expanded>
<QuickFilter sx={{ display: 'flex', alignItems: 'center' }}>
<QuickFilterControl
render={({ ref, ...other }) => (
render={({ ref, ...controlProps }, state) => (
<TextField
{...other}
sx={{ width: 300 }}
{...controlProps}
inputRef={ref}
placeholder="Search library..."
variant="outlined"
size="small"
placeholder="Search files and metadata..."
sx={{ width: 350 }}
slotProps={{
input: {
startAdornment: (
@ -78,33 +61,23 @@ function CustomToolbar() {
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: other.value ? (
endAdornment: state.value ? (
<InputAdornment position="end">
<QuickFilterClear
edge="end"
size="small"
material={{ sx: { marginRight: -0.75 } }}
>
<QuickFilterClear size="small">
<CancelIcon fontSize="small" />
</QuickFilterClear>
</InputAdornment>
) : null,
// Ensure other props are spread correctly
...other.slotProps?.input,
},
...other.slotProps,
}}
/>
)}
/>
</StyledQuickFilter>
</Box>
</QuickFilter>
</Toolbar>
);
}
// --- 3. Main Dashboard View ---
interface DashboardViewProps {
initialFiles: any[];
user?: {
@ -118,7 +91,6 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter();
const isAdmin = user?.role === "ADMIN";
const [lastSynced, setLastSynced] = useState<Date | null>(new Date()); // Defaults to 'Just now' on load
const getVirtualPath = (parentId: string | null): string => {
if (!parentId) return "WebCalibre";
@ -129,11 +101,10 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
};
const handleSync = async () => {
setLoading(true);
try {
await syncOneDrive();
setLastSynced(new Date()); // Update the time
router.refresh();
setLoading(true);
try {
await syncOneDrive();
router.refresh();
} catch (error) {
console.error("Sync failed:", error);
} finally {
@ -150,19 +121,22 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
try {
await deleteFileNodeAction(id);
await deleteFileAction(id);
router.refresh();
} catch (error: any) {
alert(error.message || "Failed to delete file");
}
};
// --- NEW: Double Click Handler ---
const handleRowDoubleClick: GridEventListener<'rowDoubleClick'> = (params) => {
// Only navigate if it's a file. If it's a folder, we could eventually navigate into it.
if (!params.row.isFolder) {
router.push(`/dashboard/files/${params.id}`);
}
// --- NEW DOWNLOAD FUNCTIONS ---
const handleDownload = (id: string) => {
// Triggers local folder download via Content-Disposition: attachment
window.location.href = `/api/download?id=${id}&mode=attachment`;
};
const handleViewInTab = (id: string) => {
// Opens in a new tab via Content-Disposition: inline
window.open(`/api/download?id=${id}&mode=inline`, '_blank');
};
const columns: GridColDef[] = [
@ -172,12 +146,10 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
flex: 1.5,
minWidth: 250,
renderCell: (params) => (
<Tooltip title={params.row.isFolder ? "" : "Double-click to view deep metadata"} arrow>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%', cursor: 'pointer' }}>
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
<Typography variant="body2">{params.value}</Typography>
</Box>
</Tooltip>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
<Typography variant="body2">{params.value}</Typography>
</Box>
)
},
{
@ -193,7 +165,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
width: 120,
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"),
renderCell: (params) => (
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold' }}>
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}>
{params.value}
</Typography>
)
@ -204,87 +176,90 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
width: 100,
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB`
},
{
field: "metadata_search",
headerName: "Search Metadata",
width: 0,
valueGetter: (value, row) => row.metadata ? JSON.stringify(row.metadata) : ""
},
{
field: "actions",
headerName: "Actions",
width: 180,
width: 180, // Increased width to accommodate new buttons
align: 'right',
renderCell: (params) => {
const isOwner = params.row.ownerId === user?.id;
const isFolder = params.row.isFolder;
return (
<Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" sx={{ height: '100%' }}>
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
{!isFolder && (
<>
<IconButton size="small" color="info" onClick={(e) => { e.stopPropagation(); window.open(`/api/download?id=${params.row.id}&mode=inline`, '_blank'); }}>
<OpenInNewIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="success" onClick={(e) => { e.stopPropagation(); window.location.href = `/api/download?id=${params.row.id}&mode=attachment`; }}>
<DownloadIcon fontSize="small" />
</IconButton>
<Tooltip title="View in Tab">
<IconButton size="small" color="info" onClick={() => handleViewInTab(params.row.id)}>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Download to Folder">
<IconButton size="small" color="success" onClick={() => handleDownload(params.row.id)}>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</>
)}
{(isAdmin || isOwner) && (
<>
<IconButton size="small" color="primary" onClick={(e) => { e.stopPropagation(); router.push(`/update/${params.row.id}`); }}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDelete(params.row.id, params.row.name); }}>
<DeleteIcon fontSize="small" />
</IconButton>
<Tooltip title="Edit Details">
<IconButton
size="small"
color="primary"
onClick={() => router.push(`/update/${params.row.id}`)}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
color="error"
onClick={() => handleDelete(params.row.id, params.row.name)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</>
)}
</Stack>
);
}
},
{
field: "metadata_search",
headerName: "Metadata Search",
width: 0,
valueGetter: (value, row) => {
if (!row.metadata) return "";
return Object.entries(row.metadata)
.filter(([k]) => k !== 'type' && k !== 'mimeType')
.map(([k, v]) => `${k}:${v}`)
.join(" ");
}
}
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}>
{lastSynced && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontStyle: 'italic' }}
suppressHydrationWarning
>
Last synced: {lastSynced.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
)}
<Button
variant="outlined"
startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={handleRefresh}
>
Refresh
<Box className="space-y-4">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}>
Refresh List
</Button>
<Button
variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleSync}
disabled={loading}
>
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}>
Sync OneDrive
</Button>
</Box>
<Box sx={{ height: 750, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
<DataGrid
rows={initialFiles}
columns={columns}
// --- THE FIX: showToolbar must be true, and toolbar slot must be assigned ---
showToolbar
slots={{ toolbar: CustomToolbar }}
showToolbar
disableRowSelectionOnClick
onRowDoubleClick={handleRowDoubleClick} // ADDED THIS HANDLER
initialState={{
columns: {
columnVisibilityModel: {
@ -293,10 +268,9 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
},
}}
sx={{
border: 'none',
'& .MuiDataGrid-row:hover': {
cursor: 'pointer',
},
border: 'none',
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
}}
/>
</Box>

View file

@ -1,132 +0,0 @@
// src/app/dashboard/files/[id]/page.tsx
import { getFileNodeById } from "@/data-access/file-nodes";
import { mapMetadata } from "@/lib/transformers";
import { notFound } from "next/navigation";
import {
Box, Container, Typography, Paper, Grid, Divider, Stack, Button
} from "@mui/material";
import DescriptionIcon from '@mui/icons-material/Description';
import PhotoIcon from '@mui/icons-material/Photo';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import Link from "next/link";
export default async function FileDetailPage(props: {
params: Promise<{ id: string }>
}) {
// 1. Unwrapping params for Next.js 15/16+
const { id } = await props.params;
// 2. Fetch data via DAL
const file = await getFileNodeById(id);
// 3. Check schema-correct property 'isFolder'
if (!file || file.isFolder) {
notFound();
}
// 4. Transform data for the UI
const data = mapMetadata(file);
// 5. Derived extension logic
const extension = file.name.split('.').pop()?.toLowerCase();
const isPDF = extension === 'pdf';
// 6. Handle BigInt for Size
const sizeKB = file.size ? (Number(file.size) / 1024).toFixed(2) : "0";
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Navigation back to dashboard */}
<Link href="/dashboard" style={{ textDecoration: 'none' }}>
<Button
startIcon={<ArrowBackIcon />}
sx={{ mb: 3 }}
>
Back to Dashboard
</Button>
</Link>
<Paper elevation={3} sx={{ p: { xs: 3, md: 5 }, borderRadius: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
{isPDF ? (
<DescriptionIcon sx={{ color: '#f44336', fontSize: 40 }} />
) : (
<PhotoIcon sx={{ color: '#2196f3', fontSize: 40 }} />
)}
<Box>
<Typography variant="h4" fontWeight="900">{file.name}</Typography>
<Typography variant="body2" color="text.secondary">
ID: {file.id}
</Typography>
</Box>
</Stack>
<Grid container spacing={4}>
{/* Left Column: Core Info & Preview */}
<Grid item xs={12} md={7}>
<Typography variant="h6" gutterBottom fontWeight="bold">Summary</Typography>
<Divider sx={{ mb: 2 }} />
{data.textPreview && (
<Box sx={{ bgcolor: 'grey.50', p: 2, borderRadius: 2, mb: 3, border: '1px solid #ddd' }}>
<Typography variant="subtitle2" color="primary" gutterBottom sx={{ fontSize: '0.7rem', fontWeight: 'bold' }}>
DOCUMENT PREVIEW
</Typography>
<Typography variant="body2" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
"{data.textPreview}..."
</Typography>
</Box>
)}
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">FILE TYPE</Typography>
<Typography variant="body1" fontWeight="500">{data.type}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">SIZE</Typography>
<Typography variant="body1" fontWeight="500">{sizeKB} KB</Typography>
</Box>
{data.pageCount > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">PAGE COUNT</Typography>
<Typography variant="body1" fontWeight="500">{data.pageCount}</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">UPLOADED AT</Typography>
<Typography variant="body1" fontWeight="500">
{new Date(file.createdAt).toLocaleString()}
</Typography>
</Box>
</Stack>
</Grid>
{/* Right Column: Deep Metadata */}
<Grid item xs={12} md={5}>
<Typography variant="h6" gutterBottom fontWeight="bold">Extracted Attributes</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={1}>
{Object.entries(file.metadata as Record<string, any> || {}).map(([key, value]) => (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5, borderBottom: '1px solid #f0f0f0' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: '500' }}>
{key}
</Typography>
<Typography variant="body2" sx={{ textAlign: 'right', pl: 2 }}>
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</Typography>
</Box>
))}
{/* FIXED: Properly closed logic for empty metadata */}
{Object.keys(file.metadata as object || {}).length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
No extra metadata extracted for this file.
</Typography>
)}
</Stack>
</Grid>
</Grid>
</Paper>
</Container>
);
}

View file

@ -1,74 +1,62 @@
// src/app/dashboard/sync-actions.ts
'use server';
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { upsertFileNode } from "@/data-access/file-nodes";
import { getWebCalibreChildren } from "@/services/onedrive";
import { extractMetadata } from "@/lib/metadata-extractor"; // Import your utility
import { getFreshAccessToken } from "@/lib/auth-utils";
export async function syncOneDrive() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const items = await getWebCalibreChildren(session.user.id);
const accessToken = await getFreshAccessToken(session.user.id);
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return { success: true, count: 0 };
const data = await response.json();
let syncedCount = 0;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
for (const item of items) {
for (const item of data.value) {
const isFolder = !!item.folder;
if (isFolder && uuidRegex.test(item.name)) continue;
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toLowerCase() || 'unknown');
let deepMetadata = {};
// --- NEW: Extraction Logic ---
// Only extract for specific types to save time/bandwidth
const supportedTypes = ['pdf', 'epub', 'jpg', 'jpeg', 'png', 'webp'];
if (!isFolder && supportedTypes.includes(extension)) {
const downloadUrl = item['@microsoft.graph.downloadUrl'];
if (downloadUrl) {
try {
// Fetch the file content as an ArrayBuffer
const response = await fetch(downloadUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Extract the "deep" metadata (Author, Title, etc.)
deepMetadata = await extractMetadata(buffer, item.name);
} catch (extractError) {
console.error(`Could not extract metadata for ${item.name}:`, extractError);
}
}
// Only skip if it's a folder AND it's a UUID (storage container)
// If a user named a file with a UUID, we still want it.
if (isFolder && uuidRegex.test(item.name)) {
continue;
}
// 2. Save to database with combined metadata
await upsertFileNode(item.id, {
name: item.name,
size: BigInt(item.size || 0),
isFolder: isFolder,
path: item.parentReference?.path + '/' + item.name,
ownerId: session.user.id,
metadata: {
type: extension.toUpperCase(),
mimeType: item.file?.mimeType || null,
...deepMetadata // Merge the extracted Author, Title, etc.
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
await prisma.fileNode.upsert({
where: { oneDriveId: item.id }, // Primary match
update: {
name: item.name,
size: BigInt(item.size || 0),
isFolder: isFolder,
path: item.parentReference?.path + '/' + item.name,
updatedAt: new Date(),
},
create: {
id: crypto.randomUUID(),
oneDriveId: item.id,
name: item.name,
size: BigInt(item.size || 0),
isFolder: isFolder,
path: item.parentReference?.path + '/' + item.name,
ownerId: session.user.id,
metadata: { type: extension, mimeType: item.file?.mimeType || null },
}
});
syncedCount++;
}
revalidatePath('/dashboard');
return { success: true, count: syncedCount };
} catch (error: any) {
console.error("Sync Error:", error.message);
throw new Error("Failed to sync with OneDrive");
throw new Error(error.message);
}
}

View file

@ -1,9 +1,9 @@
'use server';
import { auth } from "@/auth";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { createFileNode } from "@/data-access/file-nodes";
import { ensureOneDriveFolder, uploadLargeFile } from "@/services/onedrive";
export async function uploadFileAction(formData: FormData) {
const session = await auth();
@ -11,32 +11,58 @@ export async function uploadFileAction(formData: FormData) {
const file = formData.get("file") as File;
const folderName = "WebCalibre";
const accessToken = await getFreshAccessToken(session.user.id);
try {
// 1. Logic: Ensure destination exists
await ensureOneDriveFolder(session.user.id, folderName);
// 1. Create/Check WebCalibre Folder
const folderPath = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
const folderCheck = await fetch(folderPath, {
headers: { Authorization: `Bearer ${accessToken}` }
});
// 2. Logic: Perform the cloud upload
const driveItem = await uploadLargeFile(session.user.id, file, folderName);
if (folderCheck.status === 404) {
await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: folderName, folder: {} })
});
}
// 3. Logic: Save the result to our DB
await createFileNode({
// 2. Create Upload Session (Supports files > 4MB)
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${file.name}:/createUploadSession`;
const sessionRes = await fetch(sessionUrl, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "rename" } })
});
const { uploadUrl } = await sessionRes.json();
// 3. Upload File Data
const buffer = Buffer.from(await file.arrayBuffer());
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
const driveItem = await uploadRes.json();
// 4. Record in PostgreSQL
await prisma.fileNode.create({
data: {
oneDriveId: driveItem.id,
name: file.name,
size: BigInt(file.size),
isFolder: false,
path: `/${folderName}/${file.name}`,
ownerId: session.user.id,
metadata: {
type: file.name.split('.').pop()?.toUpperCase(),
mimeType: file.type
}
});
metadata: { type: file.name.split('.').pop()?.toUpperCase() }
}
});
revalidatePath("/dashboard");
return { success: true };
} catch (error: any) {
console.error("Upload Action Error:", error);
return { success: false, error: error.message || "Upload failed" };
}
revalidatePath("/dashboard");
return { success: true };
}

View file

@ -3,7 +3,7 @@
import { useState } from "react";
import { Button, Typography, Box, LinearProgress } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { uploadFileAction } from "./upload-actions";
import { uploadFileToOneDrive } from "./upload-actions";
export default function UploadForm() {
const [uploading, setUploading] = useState(false);
@ -14,7 +14,7 @@ export default function UploadForm() {
setUploading(true);
try {
await uploadFileAction(formData);
await uploadFileToOneDrive(formData);
alert("Uploaded successfully!");
} catch (error) {
console.error(error);

View file

@ -1,13 +1,15 @@
'use server';
// src/app/settings/actions.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { getUserByEmail, getUserById, updateUserRole } from "@/data-access/users";
/**
* Toggles a user's role between 'ADMIN' and 'USER' using the DAL pattern.
* Toggles a user's role between 'ADMIN' and 'USER'.
* * Security Logic:
* 1. Checks if the caller is the Bootstrap Admin (via .env).
* 2. Checks if the caller has the 'ADMIN' role in the database.
* 3. Prevents the Bootstrap Admin from being demoted to 'USER'.
*/
export async function toggleUserRoleAction(targetUserId: string) {
const session = await auth();
@ -17,34 +19,46 @@ export async function toggleUserRoleAction(targetUserId: string) {
throw new Error("Unauthorized: No session found.");
}
// 1. Authorization: Verify caller's permissions via DAL
// 1. Authorization: Who is trying to change the role?
const isBootstrap = callerEmail === process.env.INITIAL_ADMIN_EMAIL;
const callerDbRecord = await getUserByEmail(callerEmail);
const callerDbRecord = await prisma.user.findUnique({
where: { email: callerEmail },
select: { role: true }
});
const isAdmin = isBootstrap || callerDbRecord?.role === "ADMIN";
if (!isAdmin) {
throw new Error("Forbidden: You do not have permission to manage roles.");
}
// 2. Fetch target user via DAL
const targetUser = await getUserById(targetUserId);
// 2. Fetch the target user to be modified
const targetUser = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, email: true, role: true }
});
if (!targetUser) {
throw new Error("User not found.");
}
// 3. Protection: Prevent demoting the primary bootstrap admin
// This ensures you don't accidentally lock yourself out of the settings page.
if (targetUser.email === process.env.INITIAL_ADMIN_EMAIL && targetUser.role === "ADMIN") {
throw new Error("Security Restriction: The primary Bootstrap Admin role cannot be removed.");
}
// 4. Logic: Determine new role
// 4. Determine new role
const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN";
// 5. Execute Update via DAL
await updateUserRole(targetUserId, newRole);
// 5. Execute Update
await prisma.user.update({
where: { id: targetUserId },
data: { role: newRole }
});
// 6. UI Invalidation
// 6. Refresh the data on the Settings page
revalidatePath("/settings");
return {

View file

@ -1,23 +1,15 @@
'use server';
// src/app/update/[id]/_actions.ts
//src/app/update/[id]/_actions.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
/**
* SERVER ACTION: Updates file metadata and organizational data.
*/
export async function updateFileAction(formData: FormData) {
const session = await auth();
// 1. Authorization Guard
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
if (!session?.user?.id) throw new Error("Unauthorized");
// 2. Data Extraction from FormData
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
@ -25,44 +17,33 @@ export async function updateFileAction(formData: FormData) {
const customMetadataRaw = formData.get("customMetadata") as string;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
try {
// 3. Parse the incoming metadata from the UI
const newMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
// 1. Get existing record to preserve system metadata (like mimeType)
const existing = await prisma.fileNode.findUnique({ where: { id } });
const existingMetadata = (existing?.metadata as Record<string, any>) || {};
// 4. DAL: Fetch existing record to safely merge system fields
const existing = await getFileNodeById(id);
if (!existing) throw new Error("File record not found");
if (existing.ownerId !== session.user.id) throw new Error("Permission denied");
const existingMetadata = (existing.metadata as Record<string, any>) || {};
// 5. Logic: Merge Strategy
// We keep internal system fields like 'mimeType' but allow the
// user-approved 'newMetadata' (including extracted GPS/Author) to take precedence.
const updatedMetadata = {
...existingMetadata, // Keep everything we currently have
...newMetadata, // Overwrite with the fields the user just approved/edited
};
// 6. DAL: Perform the update via your file-nodes logic
await updateFileNode(id, {
name: name || existing.name,
description,
parentId,
metadata: updatedMetadata,
// 2. Update the record
await prisma.fileNode.update({
where: { id },
data: {
name,
description,
parentId,
metadata: {
...customMetadata, // User's new keys
type: name.split('.').pop()?.toUpperCase() || existingMetadata.type || "FILE",
mimeType: existingMetadata.mimeType // Preserve the original mimeType
}
}
});
// 7. Cache Invalidation
revalidatePath("/dashboard");
revalidatePath(`/update/${id}`);
return { success: true };
} catch (error: any) {
console.error("Update action error:", error);
return {
success: false,
message: error.message || "An unexpected error occurred during update"
};
console.error("Update error:", error);
return { success: false, message: error.message };
}
}

View file

@ -4,53 +4,31 @@ import { prisma } from "@/lib/prisma";
import { Container } from "@mui/material";
import UpdateView from "./update-view";
// Note: params is now handled as a Promise
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
// Security: Ensure the user is logged in
if (!session?.user?.id) {
redirect("/");
}
if (!session) redirect("/");
// 1. Await the params to get the actual ID from the URL
// 1. Await the params to get the actual ID
const { id } = await params;
// 2. Fetch the specific file.
// We include ownerId in the where clause to prevent users from editing each other's files.
// 2. Fetch the specific file using the awaited ID
const fileNode = await prisma.fileNode.findUnique({
where: {
id: id,
ownerId: session.user.id
}
where: { id: id }
});
if (!fileNode) {
notFound();
}
if (!fileNode) notFound();
// 3. Fetch folders for the destination dropdown (if you decide to allow moving files)
// Fetch folders for the destination dropdown
const folders = await prisma.fileNode.findMany({
where: {
isFolder: true,
ownerId: session.user.id
},
where: { isFolder: true },
orderBy: { name: 'asc' },
select: { id: true, name: true }
});
// 4. Convert Decimal/BigInt fields to strings/numbers if necessary for client serialization
const serializedFileNode = {
...fileNode,
size: fileNode.size ? fileNode.size.toString() : "0" // BigInt cannot be passed directly to Client Components
// metadata is already a JSON object, so it passes through fine
};
return (
<Container maxWidth="md" sx={{ py: 8 }}>
<UpdateView
fileNode={serializedFileNode}
folders={folders}
/>
<UpdateView fileNode={fileNode} folders={folders} />
</Container>
);
}

View file

@ -1,96 +1,41 @@
'use client';
// src/app/update/[id]/update-view.tsx
import { useState } from "react";
import {
Box, Button, Typography, Paper, Stack,
TextField, MenuItem, IconButton, Grid,
Checkbox, CircularProgress, Tooltip
TextField, MenuItem, IconButton, Grid, Divider
} from "@mui/material";
import SaveIcon from "@mui/icons-material/Save";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import AssignmentIcon from '@mui/icons-material/Assignment';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import MapIcon from '@mui/icons-material/Map';
import FolderIcon from "@mui/icons-material/Folder";
import { useRouter } from "next/navigation";
import { updateFileAction } from "./_actions";
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
interface MetadataPair {
key: string;
value: string;
selected: boolean;
isPending?: boolean;
}
/** * Utility to turn nested objects into flat key-value pairs for the UI
*/
const flattenObject = (obj: any, prefix = ''): Record<string, string> => {
let results: Record<string, string> = {};
for (const key in obj) {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(results, flattenObject(value, newKey));
} else {
results[newKey] = String(value);
}
}
return results;
};
export default function UpdateView({ fileNode, folders: availablefolders }: { fileNode: any; folders: any[]; }) {
export default function UpdateView({ fileNode, folders }: any) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [isExtracting, setIsExtracting] = useState(false);
// 1. Initialize Basic Info
const [name, setName] = useState(fileNode.name);
const [description, setDescription] = useState(fileNode.description || "");
const [parentId, setParentId] = useState(fileNode.parentId || "");
const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
.filter(([key]) => !['type', 'mimeType', 'magicFilled', 'details'].includes(key))
.map(([key, value]) => ({
key,
value: String(value),
selected: true,
isPending: false
}));
// 2. Parse existing JSON metadata into Key/Value array for the UI
// We filter out 'type' and 'mimeType' as they are system-managed
const initialMetadata = Object.entries(fileNode.metadata || {})
.filter(([key]) => !['type', 'mimeType'].includes(key))
.map(([key, value]) => ({ key, value: String(value) }));
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
const handleMagicEnhance = async () => {
setIsExtracting(true);
try {
const result = await getMetadataPreviewAction(fileNode.id);
if (result.success) {
// Flatten the nested 'details' and top level props
const flatData = flattenObject(result.data);
const extractedRows: MetadataPair[] = Object.entries(flatData)
.filter(([key]) => !['type', 'mimeType', 'title'].includes(key) && !key.includes('Binary Data'))
.map(([key, value]) => ({
key,
value: String(value),
selected: true,
isPending: true
}));
setCustomMetadata(prev => {
const existingKeys = new Set(prev.map(r => r.key));
const filteredNew = extractedRows.filter(r => !existingKeys.has(r.key));
return [...prev, ...filteredNew];
});
}
} catch (err) {
alert("Failed to extract metadata.");
} finally {
setIsExtracting(false);
}
};
const handleUpdate = async () => {
setLoading(true);
const formData = new FormData();
@ -99,10 +44,9 @@ export default function UpdateView({ fileNode, folders: availablefolders }: { fi
formData.append("description", description);
formData.append("parentId", parentId);
// Convert array back to object for storage
const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.selected && curr.key.trim()) {
acc[curr.key.trim()] = curr.value;
}
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
return acc;
}, {} as Record<string, string>);
@ -120,88 +64,105 @@ export default function UpdateView({ fileNode, folders: availablefolders }: { fi
return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>Back</Button>
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
Back
</Button>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>Edit File Details</Typography>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
Edit File Details
</Typography>
<Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" fontWeight="bold">Enrich Metadata</Typography>
<Typography variant="caption" color="text.secondary">Extract GPS, Camera Specs, and Dimensions.</Typography>
</Box>
<Button
variant="contained" color="secondary"
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
onClick={handleMagicEnhance} disabled={isExtracting}
>
{isExtracting ? 'Extracting...' : 'Magic Fill'}
</Button>
</Stack>
</Box>
<Stack spacing={4} sx={{ mt: 2 }}>
{/* Name Field */}
<TextField
label="File Name"
fullWidth value={name}
onChange={(e) => setName(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<Stack spacing={4}>
<TextField label="File Name" fullWidth value={name} onChange={(e) => setName(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
<TextField select fullWidth label="Destination" value={parentId} onChange={(e) => setParentId(e.target.value)}>
{/* Folder Select */}
<TextField
id="update-dest-select"
select fullWidth label="Destination Folder"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true }
}}
>
<MenuItem value=""><em>-- Root --</em></MenuItem>
{availablefolders?.map((f: any) => (<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>))}
{folders.map((f: any) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
{/* Custom Metadata Section */}
<Box>
<Stack direction="row" justifyContent="space-between" mb={2}>
<Typography variant="h6" fontWeight="700"><AssignmentIcon /> Attributes</Typography>
<Button startIcon={<AddCircleOutlineIcon />} onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}>Add Field</Button>
</Stack>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Custom Attributes
</Typography>
<Button
startIcon={<AddCircleOutlineIcon />}
size="small"
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])}
>
Add Field
</Button>
</Box>
<Stack spacing={2}>
{customMetadata.map((row, index) => (
<Box key={index}>
<Grid container spacing={1} alignItems="center">
<Grid item xs={1}>
<Checkbox checked={row.selected} onChange={(e) => {
<Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={5}>
<TextField
fullWidth size="small" placeholder="Key"
value={row.key}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].selected = e.target.checked;
updated[index].key = e.target.value;
setCustomMetadata(updated);
}} />
</Grid>
<Grid item xs={4}>
<TextField fullWidth size="small" value={row.key} disabled={row.isPending} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].key = e.target.value;
setCustomMetadata(updated);
}} />
</Grid>
<Grid item xs={6}>
<TextField fullWidth size="small" value={row.value} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].value = e.target.value;
setCustomMetadata(updated);
}} />
</Grid>
<Grid item xs={1}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}><DeleteOutlineIcon /></IconButton>
</Grid>
}}
/>
</Grid>
{row.key.toLowerCase().includes('latitude') && row.value && (
<Box sx={{ ml: 7, mt: 0.5 }}>
<Button size="small" startIcon={<MapIcon />} target="_blank"
href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}>
View on Map
</Button>
</Box>
)}
</Box>
<Grid item xs={6}>
<TextField
fullWidth size="small" placeholder="Value"
value={row.value}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].value = e.target.value;
setCustomMetadata(updated);
}}
/>
</Grid>
<Grid item xs={1}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))}
</Stack>
</Box>
<TextField label="Description" multiline rows={3} fullWidth value={description} onChange={(e) => setDescription(e.target.value)} />
<TextField
label="Description"
multiline rows={4} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<Button variant="contained" size="large" fullWidth startIcon={<SaveIcon />} onClick={handleUpdate} disabled={loading}>
<Button
variant="contained" size="large" fullWidth
startIcon={<SaveIcon />}
onClick={handleUpdate}
disabled={loading}
sx={{ py: 1.5, fontWeight: 'bold' }}
>
{loading ? "Saving..." : "Save Changes"}
</Button>
</Stack>

View file

@ -1,52 +1,55 @@
'use server';
//src/app/upload/_actions.ts)
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
import { revalidatePath } from "next/cache";
/**
* FIXED: Changed findUnique to findFirst to avoid runtime database crashes
* 1. CREATE FOLDER: Virtual Only
* Logic: User-created organizational folders exist ONLY in the database.
* No call to OneDrive is made here.
*/
export async function checkDuplicateAction(hash: string) {
const existing = await prisma.fileNode.findFirst({
where: { hash },
select: { name: true, parentId: true }
});
return existing;
}
export async function createFolderAction(name: string, parentId?: string | null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const internalId = crypto.randomUUID();
const newNode = await createFileNode({
id: internalId,
oneDriveId: null,
name,
isFolder: true,
path: `virtual:/${name}`,
ownerId: session.user.id,
parentId: parentId || null,
metadata: { type: "FOLDER" }
const newNode = await prisma.fileNode.create({
data: {
id: internalId,
oneDriveId: null, // Virtual folders do not have a cloud ID
name: name,
isFolder: true,
path: `virtual:/${name}`,
ownerId: session.user.id,
parentId: parentId || null,
metadata: { type: "FOLDER" }
}
});
revalidatePath("/upload");
revalidatePath("/dashboard");
return { success: true, node: newNode };
} catch (error: any) {
console.error("Folder creation error:", error);
throw new Error(error.message || "Failed to create virtual folder");
}
}
/**
* 2. UPLOAD FILE: Physical Container
* Logic: Creates a physical folder (UUID) on OneDrive to hold the file.
* This ensures every file has a unique storage space in the cloud.
*/
export async function uploadFileAction(formData: FormData) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const file = formData.get("file") as File;
const hash = formData.get("hash") as string;
const description = formData.get("description") as string || "";
const parentIdRaw = formData.get("parentId") as string | null;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
@ -56,22 +59,62 @@ export async function uploadFileAction(formData: FormData) {
if (!file) throw new Error("No file selected");
const accessToken = await getFreshAccessToken(session.user.id);
const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID();
const internalId = crypto.randomUUID(); // This UUID will be the OneDrive folder name
try {
await ensureOneDriveFolder(session.user.id, rootFolder);
// 1. Create the Physical Storage Folder on OneDrive
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
name: internalId,
folder: {},
"@microsoft.graph.conflictBehavior": "fail"
})
});
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const subFolderData = await subFolderRes.json();
if (!createSubFolderRes.ok) {
const errorData = await createSubFolderRes.json();
throw new Error(errorData.error?.message || "Storage directory creation failed");
}
const subFolderData = await createSubFolderRes.json();
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
// 2. Create Upload Session inside the new Physical Folder
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
const sessionRes = await fetch(sessionUrl, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
});
const { uploadUrl } = await sessionRes.json();
const buffer = Buffer.from(await file.arrayBuffer());
await createFileNode({
id: internalId,
oneDriveId: uploadedFileData.id,
// 3. PUT the file binary
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
const uploadedFileData = await uploadRes.json();
const oneDriveId = uploadedFileData.id;
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
// 4. Create record in Database
// Link it to the VIRTUAL folder via parentId
await prisma.fileNode.create({
data: {
id: internalId,
oneDriveId: oneDriveId,
name: file.name,
hash: hash,
description: description,
size: BigInt(file.size),
isFolder: false,
@ -80,86 +123,13 @@ export async function uploadFileAction(formData: FormData) {
parentId: parentId,
metadata: {
...customMetadata,
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
type: extension,
mimeType: file.type
}
});
}
});
revalidatePath("/dashboard");
revalidatePath("/upload");
return { success: true };
} catch (error: any) {
console.error("Upload refactor error:", error);
return { success: false, error: error.message };
}
}
export async function getFoldersAction() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const folders = await getAllFolders();
return folders;
} catch (error) {
console.error("Error in getFoldersAction:", error);
return [];
}
}
export async function executeBulkItemAction(formData: FormData) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized: You must be logged in to perform bulk actions.");
}
const file = formData.get("file") as File;
const hash = formData.get("hash") as string;
const targetFolderIdRaw = formData.get("targetFolderId") as string | null;
const targetFolderId = (targetFolderIdRaw === "" || targetFolderIdRaw === "root")
? null
: targetFolderIdRaw;
if (!file || !hash) {
throw new Error("Missing required file or hash data for bulk operation.");
}
try {
const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID();
// A. Direct upload to OneDrive
await ensureOneDriveFolder(session.user.id, rootFolder);
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const subFolderData = await subFolderRes.json();
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
// B. Write or update PostgreSQL records via the safe wrapper
const result = await upsertFileNodeByHash({
name: file.name,
hash: hash,
oneDriveId: uploadedFileData.id,
parentId: targetFolderId,
size: BigInt(file.size),
ownerId: session.user.id,
});
revalidatePath("/dashboard");
return {
success: true,
id: result.node.id,
mode: result.mode
};
} catch (error: any) {
console.error("Bulk Item Execution Failure:", error);
return {
success: false,
error: error.message || "An unexpected error occurred during upload."
};
}
revalidatePath("/dashboard");
revalidatePath("/upload");
return { success: true };
}

View file

@ -1,243 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box, Button, Typography, Paper, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, Chip,
Stack, MenuItem, Select, IconButton, Tooltip, LinearProgress
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
import { calculateFileHash } from '@/lib/hashing-client';
import { checkDuplicateAction, getFoldersAction, executeBulkItemAction } from '../_actions';
interface UploadQueueItem {
id: string;
file: File;
path: string;
hash: string | null;
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
targetFolderId: string;
}
export default function BulkUploadPage() {
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [dbFolders, setDbFolders] = useState<{id: string, name: string}[]>([]);
useEffect(() => {
async function loadFolders() {
try {
const folders = await getFoldersAction();
setDbFolders(folders);
} catch (err) {
console.error("Failed to load folders:", err);
}
}
loadFolders();
}, []);
const onDrop = useCallback((acceptedFiles: File[]) => {
const newItems = acceptedFiles.map(file => ({
id: crypto.randomUUID(),
file,
path: (file as any).path || file.name,
hash: null,
status: 'queued' as const,
targetFolderId: ""
}));
setQueue(prev => [...prev, ...newItems]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
setQueue(curr => curr.map(item => item.id === id ? { ...item, ...updates } : item));
};
const removeItem = (id: string) => {
setQueue(prev => prev.filter(item => item.id !== id));
};
const copyFirstRowDestination = () => {
if (queue.length < 2) return;
const firstFolderId = queue[0].targetFolderId;
setQueue(current => current.map(item => ({ ...item, targetFolderId: firstFolderId })));
};
useEffect(() => {
if (!isProcessing) return;
const runAnalysis = async () => {
const nextIndex = queue.findIndex(item => item.status === 'queued');
if (nextIndex === -1) { setIsProcessing(false); return; }
const item = queue[nextIndex];
try {
updateItem(item.id, { status: 'hashing' });
const hash = await calculateFileHash(item.file);
updateItem(item.id, { status: 'checking', hash });
const existing = await checkDuplicateAction(hash);
updateItem(item.id, {
status: existing ? 'duplicate' : 'ready',
targetFolderId: existing?.parentId || item.targetFolderId
});
} catch (err) {
updateItem(item.id, { status: 'error' });
}
};
runAnalysis();
}, [queue, isProcessing]);
const handleExecute = async () => {
setIsExecuting(true);
const itemsToProcess = queue.filter(i => i.status === 'ready' || i.status === 'duplicate');
const BATCH_SIZE = 3;
for (let i = 0; i < itemsToProcess.length; i += BATCH_SIZE) {
const batch = itemsToProcess.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async (item) => {
updateItem(item.id, { status: 'uploading' });
try {
const formData = new FormData();
formData.append("file", item.file);
formData.append("hash", item.hash || "");
formData.append("targetFolderId", item.targetFolderId);
const result = await executeBulkItemAction(formData);
if (result.success) {
updateItem(item.id, { status: 'success' });
} else {
console.error(`Execution failed for ${item.path}:`, result.error);
updateItem(item.id, { status: 'error' });
}
} catch (error) {
console.error(`Network error for ${item.path}:`, error);
updateItem(item.id, { status: 'error' });
}
}));
}
setIsExecuting(false);
};
return (
<Box sx={{ p: 4, maxWidth: 1400, mx: 'auto' }}>
<Typography variant="h4" fontWeight={900} gutterBottom color="primary">
Bulk Upload & Recovery
</Typography>
{isExecuting && <LinearProgress sx={{ mb: 2 }} />}
<Paper
{...getRootProps()}
sx={{
p: 4, mb: 4, textAlign: 'center', cursor: 'pointer',
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
bgcolor: isDragActive ? 'primary.50' : 'grey.50'
}}
>
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
<FolderIcon sx={{ fontSize: 40, color: 'primary.main' }} />
<Typography>Drag Folders or Files Here</Typography>
</Paper>
{queue.length > 0 && (
<>
<TableContainer component={Paper} sx={{ mb: 3, maxHeight: 600 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ width: 50 }} />
<TableCell><strong>File Path</strong></TableCell>
<TableCell><strong>Status</strong></TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<strong>Destination</strong>
<IconButton size="small" onClick={copyFirstRowDestination} color="primary" disabled={isExecuting}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Box>
</TableCell>
<TableCell align="right"><strong>Size</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id} hover>
<TableCell>
<IconButton
size="small"
color="error"
onClick={() => removeItem(item.id)}
disabled={isExecuting || item.status === 'uploading'}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
<TableCell sx={{ fontSize: '0.75rem', maxWidth: 300, overflow: 'hidden' }}>
{item.path}
</TableCell>
<TableCell>
<Chip
label={item.status.toUpperCase()}
size="small"
color={
item.status === 'success' ? 'success' :
item.status === 'duplicate' ? 'warning' :
item.status === 'error' ? 'error' : 'default'
}
/>
</TableCell>
<TableCell>
<Select
value={item.targetFolderId}
onChange={(e) => updateItem(item.id, { targetFolderId: e.target.value })}
size="small" fullWidth displayEmpty
disabled={item.status === 'success' || isExecuting}
sx={{ fontSize: '0.8rem' }}
>
<MenuItem value="">Root Directory</MenuItem>
{dbFolders.map(f => <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>)}
</Select>
</TableCell>
<TableCell align="right">
{(item.file.size / 1024 / 1024).toFixed(2)}MB
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={() => setQueue([])} disabled={isExecuting}>Clear Queue</Button>
<Button
variant="contained"
startIcon={<PlayArrowIcon />}
onClick={() => setIsProcessing(true)}
disabled={isProcessing || isExecuting}
>
Analyze
</Button>
<Button
variant="contained"
color="success"
startIcon={<CloudUploadIcon />}
onClick={handleExecute}
disabled={isProcessing || isExecuting || !queue.some(i => i.status === 'ready' || i.status === 'duplicate')}
>
Execute Upload/Restore
</Button>
</Stack>
</>
)}
</Box>
);
}

View file

@ -1,181 +1,25 @@
// src/app/upload/bulk/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import UploadView from "./upload-view";
import { Container } from "@mui/material";
import { prisma } from "@/lib/prisma";
"use client";
export default async function UploadPage() {
const session = await auth();
if (!session) redirect("/");
import React, { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box, Button, Typography, Paper, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
Alert, Stack
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
// --- OUR UTILITIES ---
import { calculateFileHash } from '@/lib/hashing-client';
import { checkDuplicateAction } from '@/app/upload/_actions';
interface UploadQueueItem {
id: string;
file: File;
path: string;
hash: string | null;
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
error?: string;
}
export default function BulkUploadPage() {
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
// 1. Handle File & Folder Drops
const onDrop = useCallback((acceptedFiles: File[]) => {
const newItems = acceptedFiles.map(file => ({
id: crypto.randomUUID(),
file,
path: (file as any).path || file.name, // Captures subfolder structure
hash: null,
status: 'queued' as const,
}));
setQueue(prev => [...prev, ...newItems]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
// 2. The "Processing Engine"
// This effect runs whenever the queue changes or isProcessing toggles
useEffect(() => {
if (!isProcessing) return;
const runQueue = async () => {
// Find the next file that hasn't been hashed/checked yet
const nextIndex = queue.findIndex(item => item.status === 'queued');
if (nextIndex === -1) {
setIsProcessing(false);
return;
}
const item = queue[nextIndex];
try {
// Step A: Hashing
updateItem(item.id, { status: 'hashing' });
const hash = await calculateFileHash(item.file);
// Step B: Duplicate Check
updateItem(item.id, { status: 'checking', hash });
const existing = await checkDuplicateAction(hash);
// Step C: Mark Results
updateItem(item.id, {
status: existing ? 'duplicate' : 'ready'
});
} catch (err) {
updateItem(item.id, { status: 'error', error: 'Process failed' });
}
};
runQueue();
}, [queue, isProcessing]);
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
setQueue(current => current.map(item => item.id === id ? { ...item, ...updates } : item));
};
const duplicateCount = queue.filter(i => i.status === 'duplicate').length;
// Fetch only folders so the user can select a destination
const folders = await prisma.fileNode.findMany({
where: { isFolder: true },
orderBy: { name: 'asc' },
select: { id: true, name: true, parentId: true }
});
return (
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
<Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
Bulk Uploads & Restore
</Typography>
<Paper
{...getRootProps()}
sx={{
p: 6, mb: 4, textAlign: 'center', cursor: 'pointer',
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
bgcolor: isDragActive ? 'primary.50' : 'background.paper',
transition: 'all 0.2s'
}}
>
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
<FolderIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6">Drop Folders or Files Here</Typography>
<Typography variant="body2" color="text.secondary">
Perfect for camera imports or full system restores
</Typography>
</Paper>
{duplicateCount > 0 && (
<Alert severity="warning" sx={{ mb: 3 }}>
{duplicateCount} duplicate(s) found. These files already exist in your library.
</Alert>
)}
{queue.length > 0 && (
<TableContainer component={Paper} sx={{ maxHeight: 500, borderRadius: 2 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Location / Path</TableCell>
<TableCell>Size</TableCell>
<TableCell>Status</TableCell>
<TableCell>SHA-256 Hash</TableCell>
</TableRow>
</TableHead>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id} hover>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{item.path}
</TableCell>
<TableCell>
{(item.file.size / 1024 / 1024).toFixed(2)} MB
</TableCell>
<TableCell>
<Chip
label={item.status.toUpperCase()}
size="small"
color={
item.status === 'duplicate' ? 'warning' :
item.status === 'ready' ? 'info' :
item.status === 'success' ? 'success' : 'default'
}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
{item.hash ? `${item.hash.substring(0, 16)}...` : '---'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Stack direction="row" spacing={2} sx={{ mt: 4 }}>
<Button
variant="contained"
size="large"
startIcon={isProcessing ? <LinearProgress sx={{ width: 20 }} /> : <CloudUploadIcon />}
disabled={isProcessing || queue.length === 0}
onClick={() => setIsProcessing(true)}
>
{isProcessing ? 'Analyzing...' : `Analyze ${queue.length} Files`}
</Button>
<Button
variant="outlined"
color="inherit"
disabled={isProcessing}
onClick={() => setQueue([])}
>
Clear All
</Button>
</Stack>
</Box>
<Container maxWidth="md" sx={{ py: 8 }}>
{/* Pass folders to the view */}
<UploadView user={session.user} folders={folders} />
</Container>
);
}

View file

@ -1,345 +1,251 @@
'use client';
// src/app/upload/upload-view.tsx
import { useState, useRef } from "react";
import {
Box, Button, Typography, Paper, Stack,
TextField, IconButton, Divider,
Grid,
CircularProgress, Checkbox, MenuItem,
Collapse,
Dialog, DialogTitle, DialogContent,
DialogContentText, DialogActions
TextField, MenuItem, IconButton, Divider,
Grid, CircularProgress
} from "@mui/material";
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import FolderIcon from "@mui/icons-material/Folder";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import AssignmentIcon from '@mui/icons-material/Assignment';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import AssignmentIcon from '@mui/icons-material/Assignment';
import ClearIcon from '@mui/icons-material/Clear';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { useRouter } from "next/navigation";
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
import { calculateFileHash } from "@/lib/hashing-client";
import { uploadFileAction, createFolderAction, checkDuplicateAction } from "./_actions";
import { uploadFileAction, createFolderAction } from "./_actions";
interface MetadataRow {
interface MetadataPair {
key: string;
value: string;
isPending?: boolean;
selected?: boolean;
}
export default function UploadView({ folders }: { user: any; folders: any[] }) {
export default function UploadView({ user, folders = [] }: any) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
// Form State
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [targetFolderId, setTargetFolderId] = useState<string>("");
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState("");
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
// Folder Creation State
const [showFolderInput, setShowFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [rows, setRows] = useState<MetadataRow[]>([]);
// UI Status State
const [isExtracting, setIsExtracting] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'hashing' | 'saving'>('idle');
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
// Duplicate Dialog State
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [duplicateInfo, setDuplicateInfo] = useState<{ name: string; hash: string } | null>(null);
const canSubmit = selectedFile !== null || newFolderName.trim().length > 0;
// --- 1. MAGIC EXTRACTION ---
const handleMagicEnhance = async () => {
if (!selectedFile) return;
setIsExtracting(true);
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
setIsCreatingFolder(true);
try {
const result = await getMetadataPreviewAction(selectedFile.name);
// FIX: Pass the current parentId to the action so it nests correctly
const result = await createFolderAction(newFolderName, parentId);
if (result.success) {
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
key: k,
value: typeof v === 'object' ? JSON.stringify(v) : String(v),
isPending: true,
selected: true
}));
setRows(prev => {
const existingKeys = new Set(prev.map(r => r.key));
const newUniqueRows = extractedRows.filter(r => !existingKeys.has(r.key));
return [...prev, ...newUniqueRows];
});
setNewFolderName("");
setShowFolderInput(false);
router.refresh();
}
} catch (err: any) {
alert(err.message || "Failed to create folder");
} finally {
setIsCreatingFolder(false);
}
};
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
const removeMetadataRow = (index: number) => {
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
};
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
const updated = [...customMetadata];
updated[index][field] = val;
setCustomMetadata(updated);
};
const handleUpload = async () => {
if (!file) return;
setStatus('uploading');
const formData = new FormData();
formData.append("file", file);
formData.append("description", description);
formData.append("parentId", parentId);
const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
return acc;
}, {} as Record<string, string>);
formData.append("customMetadata", JSON.stringify(metadataObj));
try {
const result = await uploadFileAction(formData);
if (result.success) {
setStatus('success');
setFile(null);
setDescription("");
setCustomMetadata([]);
router.push("/dashboard");
router.refresh();
}
} catch (err) {
console.error("Extraction failed:", err);
} finally {
setIsExtracting(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) setSelectedFile(file);
};
// --- 2. UPLOAD EXECUTION ---
const executeUpload = async (preCalculatedHash?: string) => {
setSaveStatus('saving');
try {
let currentParentId = targetFolderId;
// STEP A: Handle New Folder Creation
if (newFolderName.trim()) {
const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
if (folderResult.success) {
currentParentId = folderResult.node.id;
} else {
throw new Error(folderResult.error || "Failed to create folder");
}
}
// STEP B: Handle File Upload
if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("hash", preCalculatedHash || "");
formData.append("parentId", currentParentId || "root");
const metadataObject = rows
.filter(r => r.selected && r.key.trim() !== "")
.reduce((acc, curr) => {
acc[curr.key.trim()] = curr.value;
return acc;
}, {} as Record<string, string>);
formData.append("customMetadata", JSON.stringify(metadataObject));
const uploadResult = await uploadFileAction(formData);
if (!uploadResult.success) throw new Error(uploadResult.error || "Upload failed");
}
router.push("/dashboard");
router.refresh();
} catch (err: any) {
console.error("Save failed:", err);
alert(err.message || "An error occurred while saving.");
setSaveStatus('idle');
}
};
// --- 3. SAVE HANDLER (With Hash Intercept) ---
const handleSave = async () => {
if (!canSubmit) return;
if (selectedFile) {
setSaveStatus('hashing');
// Calculate local SHA-256
const fileHash = await calculateFileHash(selectedFile);
// Check database via Server Action
const duplicate = await checkDuplicateAction(fileHash);
if (duplicate) {
setDuplicateInfo({ name: duplicate.name, hash: fileHash });
setDuplicateDialogOpen(true);
return; // Dialog takes over from here
}
await executeUpload(fileHash);
} else {
await executeUpload(); // Folder only
alert("Upload failed.");
setStatus('idle');
}
};
return (
<>
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
Upload & Enrich
</Typography>
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
Add to Library
</Typography>
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
{/* FOLDER SELECTION */}
<Box>
<Stack direction="row" spacing={1}>
<TextField
select fullWidth label="Parent Destination"
value={targetFolderId}
onChange={(e) => setTargetFolderId(e.target.value)}
helperText="Choose where your file will live"
>
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders?.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<Button
variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
sx={{ height: 56, minWidth: 56 }}
>
<CreateNewFolderIcon />
</Button>
</Stack>
<Stack spacing={4} sx={{ mt: 4 }}>
{/* 1. Destination */}
<Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon color="primary" /> 1. Destination
</Typography>
<Stack direction="row" spacing={1}>
<TextField
id="project-destination-select"
select
fullWidth
label="Target Project / Folder"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
size="small"
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true },
}}
>
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
{folders.map((f: any) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<IconButton
color="primary"
onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
>
<CreateNewFolderIcon />
</IconButton>
</Stack>
<Collapse in={showNewFolderInput}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
NEW SUB-FOLDER NAME
</Typography>
<TextField
fullWidth size="small" placeholder="e.g. Finance 2026"
{showFolderInput && (
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{parentId ? `Create inside current selection` : `Create at Root`}
</Typography>
<Stack direction="row" spacing={1}>
<TextField
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()}
/>
</Box>
</Collapse>
</Box>
{/* FILE SELECTION */}
<Box>
<input
type="file" id="file-upload-input" style={{ display: 'none' }}
onChange={handleFileChange} ref={fileInputRef}
/>
{!selectedFile ? (
<Button
variant="outlined" fullWidth startIcon={<CloudUploadIcon />}
onClick={() => fileInputRef.current?.click()}
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
>
Select File to Upload
</Button>
) : (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50' }}>
<Stack direction="row" spacing={2} alignItems="center">
<CloudUploadIcon color="primary" />
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
</Stack>
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
<ClearIcon />
</IconButton>
</Paper>
)}
</Box>
</Stack>
<Divider sx={{ my: 4 }} />
{/* MAGIC EXTRACT */}
<Box sx={{ mb: 4, p: 2.5, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Box>
<Typography variant="subtitle1" fontWeight="bold" color="primary.main">Magic Extract</Typography>
<Typography variant="caption" color="text.secondary">
Auto-pull metadata from file content.
</Typography>
<Button
variant="contained"
onClick={handleCreateFolder}
disabled={isCreatingFolder || !newFolderName}
>
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
</Button>
</Stack>
</Box>
<Button
variant="contained" onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
sx={{ borderRadius: 20, px: 3 }}
>
{isExtracting ? "Extracting..." : "Run"}
</Button>
</Stack>
)}
</Box>
{/* METADATA PREVIEW */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Metadata Fields
{/* 2. Upload Area */}
<Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CloudUploadIcon color="primary" /> 2. Upload File
</Typography>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<Button
variant="outlined"
fullWidth
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }}
onClick={() => fileInputRef.current?.click()}
>
{file ? (
<Box>
<Typography color="success.main" fontWeight="bold"> {file.name}</Typography>
<Typography variant="caption" color="text.secondary">
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB)
</Typography>
</Box>
) : (
"Click to Select File"
)}
</Button>
</Box>
{/* 3. Custom Attributes */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> 3. Custom Attributes
</Typography>
<Button startIcon={<AddCircleOutlineIcon />} size="small" onClick={addMetadataRow}>
Add Field
</Button>
</Box>
<Stack spacing={2}>
{rows.map((row, index) => (
{customMetadata.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={1}>
<Checkbox checked={row.selected} size="small" onChange={(e) => {
const updated = [...rows];
updated[index].selected = e.target.checked;
setRows(updated);
}} />
</Grid>
<Grid item xs={5}>
<TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
const updated = [...rows];
updated[index].key = e.target.value;
setRows(updated);
}} />
<TextField
fullWidth size="small" placeholder="Key (e.g. Project-ID)"
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
/>
</Grid>
<Grid item xs={5}>
<TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
const updated = [...rows];
updated[index].value = e.target.value;
setRows(updated);
}} />
<Grid item xs={6}>
<TextField
fullWidth size="small" placeholder="Value"
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)}
/>
</Grid>
<Grid item xs={1}>
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
<IconButton color="error" onClick={() => removeMetadataRow(index)}>
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))}
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
Add Manual Field
</Button>
<TextField
label="General Description"
multiline rows={2} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Stack>
</Box>
{/* FINAL BUTTON */}
<Button
variant="contained" size="large" fullWidth onClick={handleSave}
disabled={!canSubmit || saveStatus !== 'idle'}
sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload}
sx={{ py: 2, fontWeight: 'bold' }}
>
{saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
saveStatus === 'saving' ? "Uploading to OneDrive..." :
"Complete Upload & Save"}
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'}
</Button>
</Paper>
{/* --- DUPLICATE ALERT DIALOG --- */}
<Dialog
open={duplicateDialogOpen}
onClose={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
PaperProps={{ sx: { borderRadius: 3, p: 1, maxWidth: 450 } }}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'warning.dark', fontWeight: 'bold' }}>
<WarningAmberIcon fontSize="large" /> Duplicate Content
</DialogTitle>
<DialogContent>
{/* FIX: Added component="div" here.
This prevents the "<div> cannot be a descendant of <p>" error
*/}
<DialogContentText component="div">
The file you selected has exactly the same content as a file already in your library:
<Box sx={{ mt: 2, p: 2, bgcolor: 'warning.50', border: '1px solid', borderColor: 'warning.light', borderRadius: 2, color: 'text.primary', fontWeight: '600' }}>
{duplicateInfo?.name}
</Box>
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
Would you like to skip this upload or create a second copy?
</Typography>
</DialogContentText>
</DialogContent>
<DialogActions sx={{ p: 2, gap: 1 }}>
<Button
onClick={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
variant="outlined" color="inherit" fullWidth
>
Cancel
</Button>
<Button
onClick={() => { setDuplicateDialogOpen(false); executeUpload(duplicateInfo?.hash); }}
variant="contained" color="warning" fullWidth
>
Upload Anyway
</Button>
</DialogActions>
</Dialog>
</>
</Stack>
</Paper>
);
}

View file

@ -1,4 +1,3 @@
// src/auth.config.ts
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
import type { NextAuthConfig } from "next-auth";
@ -10,10 +9,20 @@ export default {
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
authorization: {
params: {
// offline_access is vital for getting the refresh_token
scope: "openid profile email offline_access Files.ReadWrite Files.Read",
scope: "openid profile offline_access email Files.ReadWrite Files.Read",
prompt: "consent", // Forces Microsoft to show the permission screen
access_type: "offline",
},
},
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
azureAdUserId: profile.oid ?? profile.sub,
};
},
}),
],
} satisfies NextAuthConfig;

View file

@ -7,18 +7,22 @@ import authConfig from "./auth.config";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig, // This now spreads the default export from auth.config.ts
...authConfig,
callbacks: {
async jwt({ token, account, user }) {
// 1. Handle OAuth tokens (from first sign-in)
// This captures the tokens directly from the Microsoft Azure response
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
// 2. Attach User ID and Role to the token
// This runs when the user first logs in
if (user) {
token.sub = user.id;
// @ts-ignore
// @ts-ignore - 'role' is a custom field in your Postgres User table
token.role = user.role;
}
@ -26,10 +30,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
async session({ session, token }) {
// 3. Pass values from the JWT Token into the Client-facing Session
// This makes the tokens and IDs available to your API routes and Components
if (session?.user) {
session.user.id = token.sub as string;
// @ts-ignore
// @ts-ignore - Attaching the role for UI permissions
session.user.role = token.role as string;
// IMPORTANT: We must attach the accessToken here so the
// /api/download route can use it to fetch from MS Graph
session.accessToken = token.accessToken as string;
}
return session;
@ -40,7 +50,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
async linkAccount({ account, user }) {
console.log("🔗 Account linked successfully for user:", user.id);
if (!account.refresh_token) {
console.warn("⚠️ WARNING: No refresh_token received!");
console.warn("⚠️ WARNING: No refresh_token received in linkAccount event!");
}
}
}

View file

@ -1,18 +0,0 @@
'use client';
import { signOut } from "next-auth/react";
import { Button } from "@mui/material";
import LogoutIcon from '@mui/icons-material/Logout';
export default function LogoutButton() {
return (
<Button
variant="outlined"
color="error"
startIcon={<LogoutIcon />}
onClick={() => signOut({ callbackUrl: "/" })}
>
Sign Out
</Button>
);
}

View file

@ -9,7 +9,6 @@ import {
import MenuIcon from '@mui/icons-material/Menu';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudSyncIcon from '@mui/icons-material/CloudSync'; // Great icon for Bulk/Restore
import DashboardIcon from '@mui/icons-material/Dashboard';
import SettingsIcon from '@mui/icons-material/Settings';
import Link from 'next/link';
@ -45,7 +44,6 @@ export default function Navbar({ user }: NavbarProps) {
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
{ text: 'Bulk Upload / Restore', icon: <CloudSyncIcon />, href: '/upload/bulk' },
];
// Only push Settings if the user has Admin rights

View file

@ -1,19 +1,12 @@
'use client';
import React from "react";
import { SessionProvider } from "next-auth/react";
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/lib/theme'; // Import your custom theme here
// ... other imports like ThemeProvider
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<AppRouterCacheProvider options={{ enableCssLayer: true }}>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
{/* CssBaseline resets browser styles to match MUI and your theme */}
<CssBaseline />
{children}
</ThemeProvider>
</AppRouterCacheProvider>

View file

@ -1,156 +0,0 @@
// src/data-access/file-nodes.ts
import "server-only";
import { getOneDriveFileBuffer } from "@/services/onedrive";
import { extractMetadata } from "@/lib/metadata-extractor";
import { prisma } from "@/lib/prisma";
export async function getAllFileNodes() {
return await prisma.fileNode.findMany({
orderBy: {
updatedAt: 'desc',
},
});
}
export async function getFileNodeById(id: string) {
return await prisma.fileNode.findUnique({
where: { id },
});
}
export async function updateFileNode(id: string, data: any) {
return await prisma.fileNode.update({
where: { id },
data: {
...data,
updatedAt: new Date(),
},
});
}
export async function deleteFileNode(id: string) {
return await prisma.fileNode.delete({
where: { id },
});
}
export async function createFileNode(data: {
id?: string;
oneDriveId: string | null;
name: string;
hash?: string | null;
description?: string;
isFolder: boolean;
path: string;
ownerId: string;
parentId?: string | null;
size?: bigint;
metadata: any;
}) {
return await prisma.fileNode.create({
data: {
...data,
id: data.id ?? crypto.randomUUID(),
}
});
}
export async function upsertFileNode(oneDriveId: string, data: {
name: string;
size: bigint;
isFolder: boolean;
path: string;
ownerId: string;
metadata: any;
hash?: string | null;
}) {
return await prisma.fileNode.upsert({
where: { oneDriveId },
update: {
name: data.name,
size: data.size,
isFolder: data.isFolder,
path: data.path,
hash: data.hash,
updatedAt: new Date(),
},
create: {
id: crypto.randomUUID(),
oneDriveId: oneDriveId,
name: data.name,
size: data.size,
isFolder: data.isFolder,
path: data.path,
ownerId: data.ownerId,
metadata: data.metadata,
hash: data.hash,
}
});
}
export async function getAllFolders() {
return await prisma.fileNode.findMany({
where: {
isFolder: true
},
select: {
id: true,
name: true
},
orderBy: {
name: 'asc'
}
});
}
/**
* UPSERT BY HASH: FIXED FOR SYSTEM STABILITY
*/
export async function upsertFileNodeByHash(data: {
name: string;
hash: string;
oneDriveId: string;
parentId?: string | null;
size: bigint;
ownerId: string;
}) {
// Use findFirst instead of findUnique to protect against schema constraints
const existing = await prisma.fileNode.findFirst({
where: { hash: data.hash }
});
if (existing) {
// 🛡️ DISASTER RECOVERY MODE (RESTORE)
const updatedNode = await prisma.fileNode.update({
where: { id: existing.id },
data: {
oneDriveId: data.oneDriveId,
parentId: data.parentId || existing.parentId,
updatedAt: new Date(),
}
});
return { node: updatedNode, mode: 'restored' as const };
}
// ✨ NEW UPLOAD MODE
const fileExtension = data.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
const newNode = await prisma.fileNode.create({
data: {
id: crypto.randomUUID(),
oneDriveId: data.oneDriveId,
name: data.name,
hash: data.hash,
size: data.size,
isFolder: false,
ownerId: data.ownerId,
parentId: data.parentId || null,
path: `/WebCalibre/Bulk/${data.name}`,
metadata: {
type: fileExtension,
mimeType: "application/octet-stream"
},
}
});
return { node: newNode, mode: 'created' as const };
}

View file

@ -1,42 +0,0 @@
import "server-only";
import { prisma } from "@/lib/prisma";
/**
* FETCH: Get user by Email
* Used for authorization checks in actions.
*/
export async function getUserByEmail(email: string) {
return await prisma.user.findUnique({
where: { email },
select: { id: true, email: true, role: true }
});
}
/**
* FETCH: Get user by ID
*/
export async function getUserById(id: string) {
return await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, role: true }
});
}
/**
* UPDATE: Update user role
*/
export async function updateUserRole(id: string, role: "ADMIN" | "USER") {
return await prisma.user.update({
where: { id },
data: { role }
});
}
/**
* FETCH: List all users (for the settings table)
*/
export async function getAllUsers() {
return await prisma.user.findMany({
orderBy: { email: 'asc' }
});
}

View file

@ -1,4 +1,3 @@
// src/lib/auth-utils.ts
import { prisma } from "@/lib/prisma";
export async function getFreshAccessToken(userId: string) {
@ -12,6 +11,7 @@ export async function getFreshAccessToken(userId: string) {
}
// 2. Check if the token is expired (with a 1-minute buffer)
// account.expires_at is usually in seconds, so we multiply by 1000
const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000;
if (!isExpired && account.access_token) {
@ -30,17 +30,12 @@ export async function getFreshAccessToken(userId: string) {
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
grant_type: "refresh_token",
refresh_token: account.refresh_token,
// CRITICAL: Re-declare scopes to ensure the new access_token has OneDrive permissions
scope: "openid profile offline_access Files.ReadWrite Files.Read",
}),
});
const tokens = await response.json();
if (!response.ok) {
console.error("❌ Microsoft Token Refresh Response Error:", tokens);
throw tokens;
}
if (!response.ok) throw tokens;
// 4. Update the Account table with the new tokens
await prisma.account.update({
@ -48,7 +43,6 @@ export async function getFreshAccessToken(userId: string) {
data: {
access_token: tokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
// Microsoft sometimes rotates the refresh_token; save it if they provide a new one
refresh_token: tokens.refresh_token ?? account.refresh_token,
},
});
@ -56,7 +50,6 @@ export async function getFreshAccessToken(userId: string) {
return tokens.access_token;
} catch (error) {
console.error("❌ Failed to refresh Microsoft token:", error);
// Returning a specific error string helps Auth.js or your components handle re-auth
throw new Error("RefreshAccessTokenError");
}
}

View file

@ -1,9 +0,0 @@
// src/lib/hashing-client.ts
// src/lib/hashing-client.ts
export async function calculateFileHash(file: File): Promise<string> {
const arrayBuffer = await file.arrayBuffer();
// Native browser API (SubtleCrypto)
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

View file

@ -1,9 +0,0 @@
// src/lib/hashing.ts
import crypto from 'crypto';
/**
* Generates an MD5 hash from a file buffer.
*/
export function generateFileHash(buffer: Buffer): string {
return crypto.createHash('md5').update(buffer).digest('hex');
}

View file

@ -1,83 +0,0 @@
// src/lib/metadata-extractor.ts
// 1. Update your import to use the new named export
//import * as PDFLib from 'pdf-parse';
//import { PDFParse } from 'pdf-parse';
//import {pdf} from 'pdf-parse';
import * as PdfParse from 'pdf-parse-new';
import sharp from 'sharp';
import exifReader from 'exif-reader';
/**
* Converts EXIF DMS (Degrees, Minutes, Seconds) array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER:
* Converts Buffers to strings, standardizes keys, and handles GPS conversion.
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
// Specifically handle GPS Latitude/Longitude Arrays
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
if (extension === 'pdf') {
// Create parser instance
const parser = new PdfParse.SmartPDFParser({
oversaturationFactor: 2.0,
enableFastPath: true
});
console.log(`--- PDF Debug Start: ${filename} ---`);
const result = await parser.parse(buffer);
console.log(`Parsed ${result.numpages} pages using ${result._meta.method}`);
console.log(`Parsed ${result.info} info using ${result._meta.method}`);
console.log(`Parsed ${result.info} info using ${result._meta.method}`);
console.log(JSON.stringify(result.info, null, 2));
//const { text, numpages, info } = await pdf(buffer);
//const parser = new PDFParse(buffer);
// `text` → full document text
// `numpages` → page count
// `info` → metadata (author, creation date, etc.)
// console.log(`Pages: ${numpages}`);
// console.log(`Author: ${info.Author}`);
// console.log(text.slice(0, 200)); // preview first 200 chars
return {
type: 'PDF',
title: filename
};
}
}

View file

@ -1,145 +0,0 @@
// src/lib/metadata-extractor.ts
// 1. Update your import to use the new named export
import * as PDFLib from 'pdf-parse';
import sharp from 'sharp';
import exifReader from 'exif-reader';
/**
* Converts EXIF DMS (Degrees, Minutes, Seconds) array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER:
* Converts Buffers to strings, standardizes keys, and handles GPS conversion.
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
// Specifically handle GPS Latitude/Longitude Arrays
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
if (extension === 'pdf') {
console.log(`--- PDF Debug Start: ${filename} ---`);
// Initialize using the data property for our Buffer
const parser = new PDFLib.PDFParse({
data: buffer,
// disableWorker: true, // Crucial for Next.js
// verbosity: 0
});
try {
const result = await parser.getInfo({ parsePageInfo: true });
// LOGGING TO TERMINAL
console.log(`✅ Total pages: ${result.total}`);
console.log(`✅ Title: ${result.info?.Title}`);
console.log(`✅ Author: ${result.info?.Author}`);
console.log(`✅ Creator: ${result.info?.Creator}`);
// Date info
const dates = result.getDateNode();
console.log(`✅ Creation Date: ${dates.CreationDate}`);
console.log('--- PDF Debug End ---');
}
// return {
// type: 'PDF',
// title: filename,
// pageCount: result.total,
// details: result.info, // This sends the raw info object back
// };
// } catch (err: any) {
// console.error("❌ PDF Parsing Error inside debug block:", err.message);
// return { type: 'PDF', error: err.message };
// } finally {
// await parser.destroy();
// }
// --- 1. PDF EXTRACTION (v2 Class-based) ---
// if (extension === 'pdf') {
// // Access PDFParse from the namespace
// const parser = new PDFLib.PDFParse({
// data: buffer,
// disableWorker: true, // Prevents the .mjs worker error
// verbosity: 0
// });
// try {
// const infoResult = await parser.getInfo({ parsePageInfo: true });
// const textResult = await parser.getText();
// return {
// type: 'PDF',
// title: filename,
// details: sanitizeMetadata(infoResult.info || {}),
// pageCount: infoResult.total || 0,
// textPreview: textResult.text ? textResult.text.substring(0, 200).replace(/\s+/g, ' ') : ""
// };
// } finally {
// // Free memory
// await parser.destroy();
// }
// }
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
const image = sharp(buffer);
const metadata = await image.metadata();
let details = {};
if (metadata.exif) {
try {
const rawExif = exifReader(metadata.exif);
details = sanitizeMetadata(rawExif);
} catch (e) {
console.warn("EXIF Parse failed");
}
}
return {
type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: `${metadata.width}x${metadata.height}`,
title: filename,
details: details
};
}
return { type: 'FILE', title: filename };
} catch (error) {
console.error(`Extraction failed: ${filename}`, error);
return { type: 'FILE', title: filename, error: "Extraction failed" };
}
}

View file

@ -1,117 +0,0 @@
// src/lib/metadata-extractor.ts
// src/lib/metadata-extractor.ts
import * as PdfParse from 'pdf-parse-new';
import sharp from 'sharp';
import exifReader from 'exif-reader';
import EPub from 'epub2'; // New Import
/**
* Converts EXIF DMS array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') dd = dd * -1;
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
try {
// --- 1. PDF EXTRACTION ---
if (extension === 'pdf') {
const parser = new PdfParse.SmartPDFParser({ oversaturationFactor: 2.0, enableFastPath: true });
const result = await parser.parse(buffer);
return {
type: 'PDF',
title: filename,
pageCount: result.numpages || 0,
details: sanitizeMetadata(result.info || {}),
textPreview: result.text ? result.text.substring(0, 200).replace(/\s+/g, ' ') : ""
};
}
// --- 2. IMAGE EXTRACTION ---
if (['jpg', 'jpeg', 'png', 'webp','heic'].includes(extension || '')) {
const image = sharp(buffer);
const metadata = await image.metadata();
let details = {};
if (metadata.exif) {
try {
const rawExif = exifReader(metadata.exif);
details = sanitizeMetadata(rawExif);
} catch (e) { console.warn("EXIF Parse failed"); }
}
return {
type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: `${metadata.width}x${metadata.height}`,
title: filename,
details: details
};
}
// --- 3. EPUB EXTRACTION (New Section) ---
if (extension === 'epub') {
return new Promise((resolve, reject) => {
// We initialize the EPub instance with a null image path and use the buffer
const epub = new EPub(buffer);
epub.on('error', (err) => {
console.error("EPUB Parser Error:", err);
resolve({ type: 'EPUB', title: filename, error: "Failed to parse EPUB" });
});
epub.on('end', () => {
// Standardizing the metadata for your UI
resolve({
type: 'EPUB',
title: epub.metadata.title || filename,
author: epub.metadata.creator || 'Unknown',
details: sanitizeMetadata(epub.metadata),
// Use the number of chapters/manifest items as a rough "page" guide
pageCount: epub.spine.contents.length || 0,
textPreview: epub.metadata.description
? epub.metadata.description.substring(0, 200).replace(/<[^>]*>?/gm, '')
: ""
});
});
epub.parse();
});
}
return { type: 'FILE', title: filename };
} catch (error: any) {
console.error(`❌ Extraction failed for ${filename}:`, error.message);
return { type: 'FILE', title: filename, error: error.message };
}
}

View file

@ -1,6 +1,7 @@
import { createTheme } from '@mui/material/styles';
import { Roboto } from 'next/font/google';
// Load the font optimized for Next.js
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
subsets: ['latin'],
@ -8,36 +9,39 @@ const roboto = Roboto({
});
const theme = createTheme({
// 1. Color Palette (Clean & Professional for a Library App)
palette: {
mode: 'light',
primary: {
main: '#1976d2',
main: '#1976d2', // Professional Blue
},
secondary: {
main: '#9c27b0',
main: '#9c27b0', // Purple for accents
},
background: {
default: '#f4f6f8',
default: '#f4f6f8', // Light grey for the app background
paper: '#ffffff',
},
},
// 2. Typography
typography: {
fontFamily: roboto.style.fontFamily,
h6: {
fontWeight: 600,
},
},
// 3. Component Defaults
components: {
// Keep your button styles
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
textTransform: 'none', // Prevents all-caps buttons
borderRadius: 8,
},
},
},
// Keep your paper styles
MuiPaper: {
defaultProps: {
elevation: 2,
@ -48,12 +52,6 @@ const theme = createTheme({
},
},
},
// ADD THIS: Ensures the theme is compatible with MUI v7's Grid logic
MuiStack: {
defaultProps: {
useFlexGap: true,
},
},
},
});

View file

@ -1,111 +0,0 @@
/**
* Helper: Converts [Degrees, Minutes, Seconds] to Decimal Degrees
*/
const convertDMSToDD = (dms: number[] | undefined, ref: string | undefined): number | null => {
if (!dms || dms.length < 3) return null;
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return parseFloat(dd.toFixed(6));
};
/**
* MAPPER: Transforms the raw database JSON into a structured GUI object.
* Designed to handle the lowercase keys generated by the sanitizer.
*/
export const mapImageMetadata = (metadata: any) => {
if (!metadata || !metadata.details) {
return { title: metadata?.title || "Unknown File" };
}
const { details, dimensions, title } = metadata;
const { image = {}, photo = {}, gpsInfo = {} } = details;
return {
fileName: title,
device: `${image.make || ''} ${image.model || ''}`.trim() || 'Unknown Device',
// Dates from EXIF are strings after sanitization
timestamp: photo.dateTimeOriginal ? new Date(photo.dateTimeOriginal) : null,
resolution: {
width: photo.pixelXDimension || dimensions?.split('x')[0],
height: photo.pixelYDimension || dimensions?.split('x')[1],
},
settings: {
aperture: photo.fNumber ? `f/${photo.fNumber}` : 'N/A',
shutterSpeed: photo.exposureTime
? (photo.exposureTime < 1
? `1/${Math.round(1 / photo.exposureTime)}s`
: `${photo.exposureTime}s`)
: 'N/A',
iso: photo.iSOSpeedRatings || 'N/A',
focalLength: photo.focalLength ? `${photo.focalLength}mm` : 'N/A',
},
location: {
latitude: convertDMSToDD(gpsInfo.gpsLatitude, gpsInfo.gpsLatitudeRef),
longitude: convertDMSToDD(gpsInfo.gpsLongitude, gpsInfo.gpsLongitudeRef),
altitude: gpsInfo.gpsAltitude ? Math.round(gpsInfo.gpsAltitude) : null,
mapUrl: (gpsInfo.gpsLatitude && gpsInfo.gpsLongitude)
? `https://www.google.com/maps?q=${convertDMSToDD(gpsInfo.gpsLatitude, gpsInfo.gpsLatitudeRef)},${convertDMSToDD(gpsInfo.gpsLongitude, gpsInfo.gpsLongitudeRef)}`
: null
}
};
};
// src/lib/transformers.ts
/**
* Transforms the raw database FileNode into a structured object for the UI.
*/
export const mapMetadata = (node: any) => {
if (!node) return { title: "Unknown File" };
const meta = node.metadata || {};
const fileName = node.name;
const extension = fileName.split('.').pop()?.toLowerCase();
// --- PDF Logic ---
if (extension === 'pdf') {
return {
fileName,
type: 'PDF Document',
title: meta.title || fileName,
author: meta.author || 'Unknown Author',
subject: meta.subject || 'N/A',
pageCount: meta.pageCount || 0,
keywords: meta.keywords || 'None',
textPreview: node.description || meta.textPreview || '', // description field often stores the preview
creator: meta.creator || 'N/A',
producer: meta.producer || 'N/A'
};
}
// --- Image Logic (Sharp/Exif) ---
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
// Note: your extractor lowercase keys via the sanitizer
return {
fileName,
type: 'Image',
device: `${meta.image?.make || ''} ${meta.image?.model || ''}`.trim() || 'Unknown Device',
timestamp: meta.photo?.dateTimeOriginal ? new Date(meta.photo.dateTimeOriginal) : null,
settings: {
aperture: meta.photo?.fNumber ? `f/${meta.photo.fNumber}` : 'N/A',
iso: meta.photo?.iSOSpeedRatings || 'N/A',
focalLength: meta.photo?.focalLength ? `${meta.photo.focalLength}mm` : 'N/A',
}
};
}
// --- Default Fallback ---
return {
fileName,
type: extension?.toUpperCase() || 'File',
details: meta
};
};

View file

@ -1,25 +0,0 @@
// src/services/metadata-service.ts
import { getOneDriveFileBuffer } from "./onedrive";
import { extractMetadata } from "@/lib/metadata-extractor";
import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
export async function enrichFileMetadata(fileId: string, token: string) {
const node = await getFileNodeById(fileId);
if (!node || !node.oneDriveId) throw new Error("Node not found");
// Fetch file from OneDrive
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
// Extract deep metadata (PDF, JPEG, etc.)
const deepMetadata = await extractMetadata(buffer, node.name);
// Merge with existing metadata
const updatedMetadata = {
...(node.metadata as object),
...deepMetadata,
magicFilled: true
};
// Update DB
return await updateFileNode(fileId, { metadata: updatedMetadata });
}

View file

@ -1,224 +0,0 @@
// src/services/onedrive.ts
import "server-only";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { auth } from "@/auth";
/**
* PRIVATE HELPER: graphRequest
* This internal function handles the heavy lifting of fetching tokens
* and making the actual HTTP call to Microsoft.
*/
async function graphRequest(userId: string, endpoint: string, options: RequestInit = {}) {
// 1. Automatically handle token refresh logic
const token = await getFreshAccessToken(userId);
const baseUrl = "https://graph.microsoft.com/v1.0";
const res = await fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
// 2. Centralized Error Handling for OneDrive
if (!res.ok) {
const errorData = await res.text();
console.error(`OneDrive API Error [${endpoint}]:`, errorData);
throw new Error(`OneDrive API failed: ${res.statusText}`);
}
return res;
}
/**
* SERVICE: Download File Content
* Returns the raw binary stream from OneDrive.
*/
export async function getOneDriveContentStream(userId: string, oneDriveId: string) {
return await graphRequest(userId, `/me/drive/items/${oneDriveId}/content`);
}
/**
* SERVICE: Get File Metadata
* Used to get the @microsoft.graph.downloadUrl or driveItem properties.
*/
export async function getOneDriveItem(userId: string, oneDriveId: string) {
const res = await graphRequest(userId, `/me/drive/items/${oneDriveId}`);
return res.json();
}
/**
* SERVICE: Upload File
* Handles the PUT request to OneDrive for new or updated files.
*/
export async function uploadToOneDrive(userId: string, file: File, oneDriveId?: string) {
// If oneDriveId exists, we update. Otherwise, we'd use a path (needs expansion for new files).
const endpoint = oneDriveId
? `/me/drive/items/${oneDriveId}/content`
: `/me/drive/root:/${file.name}:/content`;
return await graphRequest(userId, endpoint, {
method: "PUT",
headers: { "Content-Type": file.type },
body: Buffer.from(await file.arrayBuffer()),
});
}
/**
* SERVICE: Delete from Cloud
*/
export async function deleteFromOneDrive(userId: string, oneDriveId: string) {
return await graphRequest(userId, `/me/drive/items/${oneDriveId}`, {
method: "DELETE",
});
}
/**
* SERVICE: List Children of the WebCalibre folder
*/
export async function getWebCalibreChildren(userId: string) {
const res = await graphRequest(userId, "/me/drive/root:/WebCalibre:/children");
const data = await res.json();
return data.value; // Returns the array of driveItems
}
/**
* SERVICE: Ensure a specific folder exists in OneDrive
* Returns the folder ID
*/
export async function ensureOneDriveFolder(userId: string, folderName: string) {
try {
const res = await graphRequest(userId, `/me/drive/root:/${folderName}`);
const data = await res.json();
return data.id;
} catch (error) {
// If 404, create it
const createRes = await graphRequest(userId, `/me/drive/root/children`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: folderName, folder: {} })
});
const data = await createRes.json();
return data.id;
}
}
/**
* SERVICE: Upload Large File via Session
* This replaces the basic PUT for better reliability
*/
export async function uploadLargeFile(userId: string, file: File, folderName: string) {
// 1. Create Upload Session
const sessionRes = await graphRequest(userId, `/me/drive/root:/${folderName}/${file.name}:/createUploadSession`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "rename" } })
});
const { uploadUrl } = await sessionRes.json();
// 2. Upload the data to the provided URL (No Authorization header needed for the uploadUrl itself)
const buffer = Buffer.from(await file.arrayBuffer());
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
if (!uploadRes.ok) throw new Error("Upload session failed");
return await uploadRes.json(); // Returns the DriveItem
}
/**
* SERVICE: Create a folder by name inside a parent path
*/
export async function createOneDriveFolder(userId: string, parentPath: string, folderName: string) {
return await graphRequest(userId, `/me/drive/root:/${parentPath}:/children`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: folderName,
folder: {},
"@microsoft.graph.conflictBehavior": "fail"
})
});
}
/**
* SERVICE: Upload to a specific folder ID (using session)
*/
export async function uploadToFolderId(userId: string, file: File, folderId: string) {
const sessionRes = await graphRequest(userId, `/me/drive/items/${folderId}:/${encodeURIComponent(file.name)}:/createUploadSession`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
});
const { uploadUrl } = await sessionRes.json();
const buffer = Buffer.from(await file.arrayBuffer());
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
if (!uploadRes.ok) throw new Error("Upload failed");
return await uploadRes.json();
}
/**
* Retrieves the access token from the active NextAuth session.
* This is required to authorize requests to the Microsoft Graph API.
*/
async function getAccessToken(): Promise<string> {
const session = await auth();
// We cast to 'any' because the default Session type often
// needs custom augmentation to show the accessToken.
const token = (session as any)?.accessToken;
if (!token) {
// This will help you debug if the session is missing the token
console.error("OneDrive Service Error: No access token found in session.");
throw new Error("Authentication required: No access token available.");
}
return token;
}
/**
* Fetches raw file content from OneDrive.
* Parameterized token allows this to be used in different contexts (User actions, Webhooks, etc.)
*/
export async function getOneDriveFileBuffer(oneDriveId: string, token: string): Promise<Buffer> {
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${oneDriveId}/content`,
{
method: 'GET', // Explicit is better
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
// CRITICAL: Next.js tends to cache fetch calls.
// We do NOT want to cache large binary buffers in memory/disk.
cache: 'no-store',
}
);
if (!response.ok) {
const errorBody = await response.text().catch(() => "No error body");
console.error(`OneDrive Download Error (${response.status}):`, errorBody);
throw new Error(`OneDrive download failed: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}