Compare commits
No commits in common. "feature/dal-refactor" and "main" have entirely different histories.
feature/da
...
main
52 changed files with 1080 additions and 36088 deletions
1
.env
1
.env
|
|
@ -1,5 +1,4 @@
|
|||
|
||||
|
||||
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
||||
# values generated by Gemini
|
||||
# Generated for security
|
||||
|
|
|
|||
|
|
@ -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
23792
docs/Ai-converstion.md
23792
docs/Ai-converstion.md
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -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
2183
docs/notes.md
2183
docs/notes.md
File diff suppressed because it is too large
Load diff
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
2020
package-lock.json
generated
2020
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- DropIndex
|
||||
DROP INDEX "FileNode_hash_key";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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, {
|
||||
return new NextResponse(graphResponse.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Type': fileNode.mimeType || 'application/octet-stream',
|
||||
// Note: Using encodeURIComponent for filename to handle special characters
|
||||
'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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
const node = await prisma.fileNode.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
// Phase 1: Cloud Deletion
|
||||
if (file.oneDriveId) {
|
||||
await deleteFromOneDrive(session.user.id, file.oneDriveId);
|
||||
if (!node) {
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Phase 2: Database Deletion
|
||||
await deleteFileNode(id);
|
||||
// @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. MOVE: Assign file to folder or folder to another folder (Virtual Move)
|
||||
*/
|
||||
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) {
|
||||
console.error("Delete Error:", error);
|
||||
return { success: false, error: "Failed to delete file" };
|
||||
throw new Error("Move failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. UPDATE: Modify record and optionally sync new content to OneDrive
|
||||
* 5. UPDATE & REPLACE: Full update of metadata and OneDrive content
|
||||
*/
|
||||
export async function updateFileNodeAction(id: string, formData: FormData) {
|
||||
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, {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,7 +13,7 @@ import {
|
|||
Stack,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Tooltip,
|
||||
Tooltip
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
|
|
@ -21,7 +22,6 @@ import {
|
|||
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";
|
||||
|
|
@ -132,7 +104,6 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
setLoading(true);
|
||||
try {
|
||||
await syncOneDrive();
|
||||
setLastSynced(new Date()); // Update the time
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Sync failed:", error);
|
||||
|
|
@ -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' }}>
|
||||
<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>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
|
@ -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'); }}>
|
||||
<Tooltip title="View in Tab">
|
||||
<IconButton size="small" color="info" onClick={() => handleViewInTab(params.row.id)}>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="success" onClick={(e) => { e.stopPropagation(); window.location.href = `/api/download?id=${params.row.id}&mode=attachment`; }}>
|
||||
</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}`); }}>
|
||||
<Tooltip title="Edit Details">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => 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); }}>
|
||||
</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: {
|
||||
|
|
@ -294,9 +269,8 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
}}
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-row:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
|
||||
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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, {
|
||||
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.toUpperCase(),
|
||||
mimeType: item.file?.mimeType || null,
|
||||
...deepMetadata // Merge the extracted Author, Title, etc.
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
// 1. Authorization Guard
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "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,
|
||||
// 2. Update the record
|
||||
await prisma.fileNode.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
parentId,
|
||||
metadata: updatedMetadata,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
if (!session) redirect("/");
|
||||
|
||||
// Security: Ensure the user is logged in
|
||||
if (!session?.user?.id) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
<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 startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
|
||||
Back
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={4}>
|
||||
<TextField label="File Name" fullWidth value={name} onChange={(e) => setName(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
|
||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
|
||||
Edit File Details
|
||||
</Typography>
|
||||
|
||||
<TextField select fullWidth label="Destination" value={parentId} onChange={(e) => setParentId(e.target.value)}>
|
||||
<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 } }}
|
||||
/>
|
||||
|
||||
{/* 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) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index].selected = e.target.checked;
|
||||
setCustomMetadata(updated);
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<TextField fullWidth size="small" value={row.key} disabled={row.isPending} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||
<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].key = e.target.value;
|
||||
setCustomMetadata(updated);
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField fullWidth size="small" value={row.value} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
||||
const newNode = await prisma.fileNode.create({
|
||||
data: {
|
||||
id: internalId,
|
||||
oneDriveId: null,
|
||||
name,
|
||||
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" } })
|
||||
});
|
||||
|
||||
await createFileNode({
|
||||
const { uploadUrl } = await sessionRes.json();
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// 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: uploadedFileData.id,
|
||||
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."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
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';
|
||||
if (!session) redirect("/");
|
||||
|
||||
// --- 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'
|
||||
// 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 }
|
||||
});
|
||||
} 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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[]>([]);
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
|
||||
// UI Status State
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'hashing' | 'saving'>('idle');
|
||||
|
||||
// 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) {
|
||||
console.error("Extraction failed:", err);
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Failed to create folder");
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
setIsCreatingFolder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setSelectedFile(file);
|
||||
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
|
||||
|
||||
const removeMetadataRow = (index: number) => {
|
||||
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// --- 2. UPLOAD EXECUTION ---
|
||||
const executeUpload = async (preCalculatedHash?: string) => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
let currentParentId = targetFolderId;
|
||||
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index][field] = val;
|
||||
setCustomMetadata(updated);
|
||||
};
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setStatus('uploading');
|
||||
|
||||
// STEP B: Handle File Upload
|
||||
if (selectedFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("hash", preCalculatedHash || "");
|
||||
formData.append("parentId", currentParentId || "root");
|
||||
formData.append("file", file);
|
||||
formData.append("description", description);
|
||||
formData.append("parentId", parentId);
|
||||
|
||||
const metadataObject = rows
|
||||
.filter(r => r.selected && r.key.trim() !== "")
|
||||
.reduce((acc, curr) => {
|
||||
acc[curr.key.trim()] = curr.value;
|
||||
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(metadataObject));
|
||||
|
||||
const uploadResult = await uploadFileAction(formData);
|
||||
if (!uploadResult.success) throw new Error(uploadResult.error || "Upload failed");
|
||||
}
|
||||
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: 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
|
||||
} catch (err) {
|
||||
alert("Upload failed.");
|
||||
setStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
|
||||
<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">
|
||||
Upload & Enrich
|
||||
Add to Library
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
|
||||
{/* FOLDER SELECTION */}
|
||||
<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
|
||||
select fullWidth label="Parent Destination"
|
||||
value={targetFolderId}
|
||||
onChange={(e) => setTargetFolderId(e.target.value)}
|
||||
helperText="Choose where your file will live"
|
||||
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 Directory --</em></MenuItem>
|
||||
{folders?.map((f) => (
|
||||
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
|
||||
{folders.map((f: any) => (
|
||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Button
|
||||
variant={showNewFolderInput ? "contained" : "outlined"}
|
||||
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
||||
sx={{ height: 56, minWidth: 56 }}
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowFolderInput(!showFolderInput)}
|
||||
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
|
||||
>
|
||||
<CreateNewFolderIcon />
|
||||
</Button>
|
||||
</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
|
||||
{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="e.g. Finance 2026"
|
||||
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 }}
|
||||
variant="contained"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={isCreatingFolder || !newFolderName}
|
||||
>
|
||||
Select File to Upload
|
||||
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
|
||||
</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>
|
||||
)}
|
||||
</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}>
|
||||
{/* 2. Upload Area */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="bold" color="primary.main">Magic Extract</Typography>
|
||||
<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">
|
||||
Auto-pull metadata from file content.
|
||||
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</Typography>
|
||||
</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"}
|
||||
) : (
|
||||
"Click to Select File"
|
||||
)}
|
||||
</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
|
||||
{/* 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>
|
||||
</Stack>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
18
src/auth.ts
18
src/auth.ts
|
|
@ -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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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' }
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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('');
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue