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"
|
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
||||||
# values generated by Gemini
|
# values generated by Gemini
|
||||||
# Generated for security
|
# Generated for security
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
||||||
# values generated by Gemini
|
# values generated by Gemini
|
||||||
# Generated for security
|
# 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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"backfill": "tsx scripts/backfill-hashes.ts",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
|
@ -22,20 +21,12 @@
|
||||||
"@mui/material-nextjs": "^7.3.6",
|
"@mui/material-nextjs": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.24.0",
|
"@mui/x-data-grid": "^8.24.0",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "7.4.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"epub": "^1.3.0",
|
|
||||||
"epub2": "^3.0.2",
|
|
||||||
"exif-reader": "^2.0.3",
|
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"pdf-parse": "^2.4.5",
|
|
||||||
"pdf-parse-new": "^2.0.0",
|
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3"
|
||||||
"react-dropzone": "^15.0.0",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"sharp": "^0.34.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|
@ -45,8 +36,7 @@
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"prisma": "7.4.0",
|
"prisma": "^7.2.0",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
// prisma.config.ts
|
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
config({ path: ".env.local" });
|
config({ path: ".env.local" });
|
||||||
|
|
||||||
import { defineConfig, env } from "prisma/config";
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
datasource: {
|
datasource: {
|
||||||
// This is where Prisma 7 looks for the connection string
|
// This is now the ONLY place where the DB connection string is defined
|
||||||
url: env("DATABASE_URL"),
|
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
|
-- CreateTable
|
||||||
CREATE TABLE "User" (
|
CREATE TABLE "User" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT,
|
"azureAdUserId" TEXT NOT NULL,
|
||||||
"email" TEXT NOT NULL,
|
"email" TEXT NOT NULL,
|
||||||
"role" "Role" NOT NULL DEFAULT 'USER',
|
"displayName" TEXT,
|
||||||
"emailVerified" TIMESTAMP(3),
|
|
||||||
"image" TEXT,
|
|
||||||
"azureAdUserId" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
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
|
-- CreateTable
|
||||||
CREATE TABLE "FileNode" (
|
CREATE TABLE "FileNode" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"size" BIGINT,
|
"size" BIGINT,
|
||||||
"hash" TEXT,
|
|
||||||
"isFolder" BOOLEAN NOT NULL DEFAULT false,
|
"isFolder" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"oneDriveId" TEXT,
|
"oneDriveId" TEXT,
|
||||||
"path" TEXT NOT NULL,
|
"path" TEXT NOT NULL,
|
||||||
|
|
@ -67,17 +29,11 @@ CREATE TABLE "FileNode" (
|
||||||
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
|
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
|
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
|
||||||
|
|
@ -91,15 +47,8 @@ CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path");
|
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
|
-- AddForeignKey
|
||||||
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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
|
id String @id
|
||||||
name String
|
name String
|
||||||
size BigInt? // Preserved your BigInt size column
|
size BigInt? // Preserved your BigInt size column
|
||||||
hash String?
|
|
||||||
isFolder Boolean @default(false)
|
isFolder Boolean @default(false)
|
||||||
oneDriveId String? @unique
|
oneDriveId String? @unique
|
||||||
path String
|
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
|
// src/app/api/download/route.ts
|
||||||
|
"use server"
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth"; // Import the auth function from your central config
|
||||||
import { getFileNodeById } from "@/data-access/file-nodes";
|
import { prisma } from '@/lib/prisma';
|
||||||
import { getOneDriveContentStream } from "@/services/onedrive";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// 1. Authenticate the user session
|
// 1. Check Authentication using the V5 auth() helper
|
||||||
const session = await auth();
|
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 { searchParams } = new URL(request.url);
|
||||||
const fileId = searchParams.get('id');
|
const fileNodeId = searchParams.get('id');
|
||||||
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
|
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
|
||||||
|
|
||||||
if (!fileId) {
|
if (!fileNodeId) {
|
||||||
return new NextResponse("File ID is required", { status: 400 });
|
return new NextResponse("File ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. DAL: Fetch file metadata from local database
|
// 3. Find the file in your Postgres FileNode table
|
||||||
const fileNode = await getFileNodeById(fileId);
|
const fileNode = await prisma.fileNode.findUnique({
|
||||||
|
where: { id: fileNodeId }
|
||||||
|
});
|
||||||
|
|
||||||
if (!fileNode || !fileNode.oneDriveId) {
|
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
|
// 4. Fetch the file stream from Microsoft Graph
|
||||||
// The service layer automatically handles the 'getFreshAccessToken' logic
|
const graphResponse = await fetch(
|
||||||
const graphResponse = await getOneDriveContentStream(session.user.id, fileNode.oneDriveId);
|
`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
|
// 5. Stream the response directly to the client
|
||||||
// We pass the graphResponse.body (ReadableStream) directly to NextResponse
|
return new NextResponse(graphResponse.body, {
|
||||||
// src/app/api/download/route.ts
|
|
||||||
const metadata = fileNode.metadata as any;
|
|
||||||
const contentType = metadata?.mimeType || 'application/octet-stream';
|
|
||||||
|
|
||||||
return new NextResponse(graphResponse.body, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
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)}"`,
|
'Content-Disposition': `${mode}; filename="${encodeURIComponent(fileNode.name)}"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error('Download Route Error:', error);
|
console.error('Download error:', error);
|
||||||
return new NextResponse(
|
return new NextResponse("Internal Server Error", { status: 500 });
|
||||||
JSON.stringify({ error: "Internal Server Error", message: error.message }),
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,90 +1,138 @@
|
||||||
// src/app/dashboard/actions.ts
|
|
||||||
|
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
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 { 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
|
* 1. FETCH: Get all file nodes for the Dashboard
|
||||||
* Now simply calls the DAL. Error handling is left to the caller (the UI).
|
|
||||||
*/
|
*/
|
||||||
export async function getFileNodes() {
|
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
|
* 2. DOWNLOAD: Generates the authenticated OneDrive URL
|
||||||
* Orchestrates the session check, DAL lookup, and Service call.
|
|
||||||
*/
|
*/
|
||||||
export async function getDownloadUrlAction(id: string) {
|
export async function getDownloadUrlAction(id: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
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");
|
if (!file || !file.oneDriveId) throw new Error("File not found or missing cloud ID");
|
||||||
|
|
||||||
// Service handles token refresh and graph request internally
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
const data = await getOneDriveItem(session.user.id, file.oneDriveId);
|
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"];
|
const downloadUrl = data["@microsoft.graph.downloadUrl"];
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error("OneDrive did not provide a download URL");
|
if (!downloadUrl) throw new Error("OneDrive did not provide a download link");
|
||||||
return downloadUrl;
|
|
||||||
|
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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
try {
|
const node = await prisma.fileNode.findUnique({
|
||||||
const file = await getFileNodeById(id);
|
where: { id: fileId },
|
||||||
if (!file) throw new Error("File record not found");
|
});
|
||||||
|
|
||||||
// Phase 1: Cloud Deletion
|
if (!node) {
|
||||||
if (file.oneDriveId) {
|
revalidatePath("/dashboard");
|
||||||
await deleteFromOneDrive(session.user.id, file.oneDriveId);
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Database Deletion
|
// @ts-ignore
|
||||||
await deleteFileNode(id);
|
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");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Delete Error:", error);
|
throw new Error("Move failed.");
|
||||||
return { success: false, error: "Failed to delete file" };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const id = formData.get("id") as string;
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const description = formData.get("description") as string;
|
const description = formData.get("description") as string;
|
||||||
const parentIdRaw = formData.get("parentId") 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);
|
let metadata = JSON.parse(metadataStr);
|
||||||
|
|
||||||
try {
|
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) {
|
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.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN';
|
||||||
metadata.mimeType = newFile.type;
|
metadata.mimeType = newFile.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the database via DAL
|
await prisma.fileNode.update({
|
||||||
await updateFileNode(id, {
|
where: { id },
|
||||||
|
data: {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
parentId,
|
parentId,
|
||||||
metadata,
|
metadata,
|
||||||
size: newFile ? BigInt(newFile.size) : undefined,
|
size: newFile ? BigInt(newFile.size) : undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
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) {
|
} catch (error: any) {
|
||||||
console.error("❌ Service Fetch Error:", error.message);
|
console.error("Full Update Failure:", error);
|
||||||
// If this still says ENOTFOUND, the code is fine, but the terminal is blocked.
|
throw new Error(error.message || "Failed to update record.");
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
// src/app/dashboard-view.tsx
|
|
||||||
|
// src/app/dashboard/dashboard-view.tsx
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { styled } from '@mui/material/styles';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
|
@ -12,7 +13,7 @@ import {
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
Tooltip,
|
Tooltip
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
DataGrid,
|
DataGrid,
|
||||||
|
|
@ -21,7 +22,6 @@ import {
|
||||||
QuickFilter,
|
QuickFilter,
|
||||||
QuickFilterControl,
|
QuickFilterControl,
|
||||||
QuickFilterClear,
|
QuickFilterClear,
|
||||||
GridEventListener,
|
|
||||||
} from "@mui/x-data-grid";
|
} from "@mui/x-data-grid";
|
||||||
import SyncIcon from "@mui/icons-material/Sync";
|
import SyncIcon from "@mui/icons-material/Sync";
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
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 OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
|
||||||
import { syncOneDrive } from "./sync-actions";
|
import { syncOneDrive } from "./sync-actions";
|
||||||
import { deleteFileNodeAction } from "./actions";
|
import { deleteFileAction } from "./actions";
|
||||||
import { useRouter } from "next/navigation";
|
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() {
|
function CustomToolbar() {
|
||||||
return (
|
return (
|
||||||
<Toolbar >
|
<Toolbar sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Box sx={{
|
|
||||||
display: 'flex',
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
p: 2,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider'
|
|
||||||
}}>
|
|
||||||
<Typography variant="h6" fontWeight="bold" color="primary">
|
<Typography variant="h6" fontWeight="bold" color="primary">
|
||||||
Library
|
Library
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<QuickFilter sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
{/* The 'expanded' prop ensures the search input is always visible by default */}
|
|
||||||
<StyledQuickFilter expanded>
|
|
||||||
<QuickFilterControl
|
<QuickFilterControl
|
||||||
render={({ ref, ...other }) => (
|
render={({ ref, ...controlProps }, state) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...other}
|
{...controlProps}
|
||||||
sx={{ width: 300 }}
|
|
||||||
inputRef={ref}
|
inputRef={ref}
|
||||||
placeholder="Search library..."
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
|
placeholder="Search files and metadata..."
|
||||||
|
sx={{ width: 350 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
|
|
@ -78,33 +61,23 @@ function CustomToolbar() {
|
||||||
<SearchIcon fontSize="small" />
|
<SearchIcon fontSize="small" />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
endAdornment: other.value ? (
|
endAdornment: state.value ? (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<QuickFilterClear
|
<QuickFilterClear size="small">
|
||||||
edge="end"
|
|
||||||
size="small"
|
|
||||||
material={{ sx: { marginRight: -0.75 } }}
|
|
||||||
>
|
|
||||||
<CancelIcon fontSize="small" />
|
<CancelIcon fontSize="small" />
|
||||||
</QuickFilterClear>
|
</QuickFilterClear>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
) : null,
|
) : null,
|
||||||
// Ensure other props are spread correctly
|
|
||||||
...other.slotProps?.input,
|
|
||||||
},
|
},
|
||||||
...other.slotProps,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</StyledQuickFilter>
|
</QuickFilter>
|
||||||
</Box>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3. Main Dashboard View ---
|
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
initialFiles: any[];
|
initialFiles: any[];
|
||||||
user?: {
|
user?: {
|
||||||
|
|
@ -118,7 +91,6 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isAdmin = user?.role === "ADMIN";
|
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 => {
|
const getVirtualPath = (parentId: string | null): string => {
|
||||||
if (!parentId) return "WebCalibre";
|
if (!parentId) return "WebCalibre";
|
||||||
|
|
@ -132,7 +104,6 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await syncOneDrive();
|
await syncOneDrive();
|
||||||
setLastSynced(new Date()); // Update the time
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Sync failed:", error);
|
console.error("Sync failed:", error);
|
||||||
|
|
@ -150,19 +121,22 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
|
if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
|
||||||
try {
|
try {
|
||||||
await deleteFileNodeAction(id);
|
await deleteFileAction(id);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.message || "Failed to delete file");
|
alert(error.message || "Failed to delete file");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- NEW: Double Click Handler ---
|
// --- NEW DOWNLOAD FUNCTIONS ---
|
||||||
const handleRowDoubleClick: GridEventListener<'rowDoubleClick'> = (params) => {
|
const handleDownload = (id: string) => {
|
||||||
// Only navigate if it's a file. If it's a folder, we could eventually navigate into it.
|
// Triggers local folder download via Content-Disposition: attachment
|
||||||
if (!params.row.isFolder) {
|
window.location.href = `/api/download?id=${id}&mode=attachment`;
|
||||||
router.push(`/dashboard/files/${params.id}`);
|
};
|
||||||
}
|
|
||||||
|
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[] = [
|
const columns: GridColDef[] = [
|
||||||
|
|
@ -172,12 +146,10 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
flex: 1.5,
|
flex: 1.5,
|
||||||
minWidth: 250,
|
minWidth: 250,
|
||||||
renderCell: (params) => (
|
renderCell: (params) => (
|
||||||
<Tooltip title={params.row.isFolder ? "" : "Double-click to view deep metadata"} arrow>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%', cursor: 'pointer' }}>
|
|
||||||
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
|
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
|
||||||
<Typography variant="body2">{params.value}</Typography>
|
<Typography variant="body2">{params.value}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -193,7 +165,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
width: 120,
|
width: 120,
|
||||||
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"),
|
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"),
|
||||||
renderCell: (params) => (
|
renderCell: (params) => (
|
||||||
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold' }}>
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}>
|
||||||
{params.value}
|
{params.value}
|
||||||
</Typography>
|
</Typography>
|
||||||
)
|
)
|
||||||
|
|
@ -204,87 +176,90 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
width: 100,
|
width: 100,
|
||||||
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB`
|
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",
|
field: "actions",
|
||||||
headerName: "Actions",
|
headerName: "Actions",
|
||||||
width: 180,
|
width: 180, // Increased width to accommodate new buttons
|
||||||
align: 'right',
|
align: 'right',
|
||||||
renderCell: (params) => {
|
renderCell: (params) => {
|
||||||
const isOwner = params.row.ownerId === user?.id;
|
const isOwner = params.row.ownerId === user?.id;
|
||||||
const isFolder = params.row.isFolder;
|
const isFolder = params.row.isFolder;
|
||||||
|
|
||||||
return (
|
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 && (
|
{!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" />
|
<OpenInNewIcon fontSize="small" />
|
||||||
</IconButton>
|
</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" />
|
<DownloadIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isAdmin || isOwner) && (
|
{(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" />
|
<EditIcon fontSize="small" />
|
||||||
</IconButton>
|
</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" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</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 (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box className="space-y-4">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
|
||||||
{lastSynced && (
|
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}>
|
||||||
<Typography
|
Refresh List
|
||||||
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
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}>
|
||||||
variant="contained"
|
|
||||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
|
|
||||||
onClick={handleSync}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Sync OneDrive
|
Sync OneDrive
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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
|
<DataGrid
|
||||||
rows={initialFiles}
|
rows={initialFiles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
// --- THE FIX: showToolbar must be true, and toolbar slot must be assigned ---
|
|
||||||
showToolbar
|
|
||||||
slots={{ toolbar: CustomToolbar }}
|
slots={{ toolbar: CustomToolbar }}
|
||||||
|
showToolbar
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onRowDoubleClick={handleRowDoubleClick} // ADDED THIS HANDLER
|
|
||||||
initialState={{
|
initialState={{
|
||||||
columns: {
|
columns: {
|
||||||
columnVisibilityModel: {
|
columnVisibilityModel: {
|
||||||
|
|
@ -294,9 +269,8 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
'& .MuiDataGrid-row:hover': {
|
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
|
||||||
cursor: 'pointer',
|
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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';
|
'use server';
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { upsertFileNode } from "@/data-access/file-nodes";
|
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||||
import { getWebCalibreChildren } from "@/services/onedrive";
|
|
||||||
import { extractMetadata } from "@/lib/metadata-extractor"; // Import your utility
|
|
||||||
|
|
||||||
export async function syncOneDrive() {
|
export async function syncOneDrive() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
try {
|
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;
|
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;
|
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;
|
const isFolder = !!item.folder;
|
||||||
|
|
||||||
if (isFolder && uuidRegex.test(item.name)) continue;
|
// 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.
|
||||||
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toLowerCase() || 'unknown');
|
if (isFolder && uuidRegex.test(item.name)) {
|
||||||
|
continue;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Save to database with combined metadata
|
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
|
||||||
await upsertFileNode(item.id, {
|
|
||||||
|
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,
|
name: item.name,
|
||||||
size: BigInt(item.size || 0),
|
size: BigInt(item.size || 0),
|
||||||
isFolder: isFolder,
|
isFolder: isFolder,
|
||||||
path: item.parentReference?.path + '/' + item.name,
|
path: item.parentReference?.path + '/' + item.name,
|
||||||
ownerId: session.user.id,
|
ownerId: session.user.id,
|
||||||
metadata: {
|
metadata: { type: extension, mimeType: item.file?.mimeType || null },
|
||||||
type: extension.toUpperCase(),
|
}
|
||||||
mimeType: item.file?.mimeType || null,
|
|
||||||
...deepMetadata // Merge the extracted Author, Title, etc.
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
syncedCount++;
|
syncedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/dashboard');
|
revalidatePath('/dashboard');
|
||||||
return { success: true, count: syncedCount };
|
return { success: true, count: syncedCount };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Sync Error:", error.message);
|
throw new Error(error.message);
|
||||||
throw new Error("Failed to sync with OneDrive");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createFileNode } from "@/data-access/file-nodes";
|
|
||||||
import { ensureOneDriveFolder, uploadLargeFile } from "@/services/onedrive";
|
|
||||||
|
|
||||||
export async function uploadFileAction(formData: FormData) {
|
export async function uploadFileAction(formData: FormData) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
@ -11,32 +11,58 @@ export async function uploadFileAction(formData: FormData) {
|
||||||
|
|
||||||
const file = formData.get("file") as File;
|
const file = formData.get("file") as File;
|
||||||
const folderName = "WebCalibre";
|
const folderName = "WebCalibre";
|
||||||
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
|
|
||||||
try {
|
// 1. Create/Check WebCalibre Folder
|
||||||
// 1. Logic: Ensure destination exists
|
const folderPath = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
|
||||||
await ensureOneDriveFolder(session.user.id, folderName);
|
const folderCheck = await fetch(folderPath, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Logic: Perform the cloud upload
|
if (folderCheck.status === 404) {
|
||||||
const driveItem = await uploadLargeFile(session.user.id, file, folderName);
|
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
|
// 2. Create Upload Session (Supports files > 4MB)
|
||||||
await createFileNode({
|
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,
|
oneDriveId: driveItem.id,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: BigInt(file.size),
|
size: BigInt(file.size),
|
||||||
isFolder: false,
|
isFolder: false,
|
||||||
path: `/${folderName}/${file.name}`,
|
path: `/${folderName}/${file.name}`,
|
||||||
ownerId: session.user.id,
|
ownerId: session.user.id,
|
||||||
metadata: {
|
metadata: { type: file.name.split('.').pop()?.toUpperCase() }
|
||||||
type: file.name.split('.').pop()?.toUpperCase(),
|
|
||||||
mimeType: file.type
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
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 { useState } from "react";
|
||||||
import { Button, Typography, Box, LinearProgress } from "@mui/material";
|
import { Button, Typography, Box, LinearProgress } from "@mui/material";
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
import { uploadFileAction } from "./upload-actions";
|
import { uploadFileToOneDrive } from "./upload-actions";
|
||||||
|
|
||||||
export default function UploadForm() {
|
export default function UploadForm() {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
@ -14,7 +14,7 @@ export default function UploadForm() {
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
await uploadFileAction(formData);
|
await uploadFileToOneDrive(formData);
|
||||||
alert("Uploaded successfully!");
|
alert("Uploaded successfully!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
// src/app/settings/actions.ts
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
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) {
|
export async function toggleUserRoleAction(targetUserId: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
@ -17,34 +19,46 @@ export async function toggleUserRoleAction(targetUserId: string) {
|
||||||
throw new Error("Unauthorized: No session found.");
|
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 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";
|
const isAdmin = isBootstrap || callerDbRecord?.role === "ADMIN";
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
throw new Error("Forbidden: You do not have permission to manage roles.");
|
throw new Error("Forbidden: You do not have permission to manage roles.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch target user via DAL
|
// 2. Fetch the target user to be modified
|
||||||
const targetUser = await getUserById(targetUserId);
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id: targetUserId },
|
||||||
|
select: { id: true, email: true, role: true }
|
||||||
|
});
|
||||||
|
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
throw new Error("User not found.");
|
throw new Error("User not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Protection: Prevent demoting the primary bootstrap admin
|
// 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") {
|
if (targetUser.email === process.env.INITIAL_ADMIN_EMAIL && targetUser.role === "ADMIN") {
|
||||||
throw new Error("Security Restriction: The primary Bootstrap Admin role cannot be removed.");
|
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";
|
const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN";
|
||||||
|
|
||||||
// 5. Execute Update via DAL
|
// 5. Execute Update
|
||||||
await updateUserRole(targetUserId, newRole);
|
await prisma.user.update({
|
||||||
|
where: { id: targetUserId },
|
||||||
|
data: { role: newRole }
|
||||||
|
});
|
||||||
|
|
||||||
// 6. UI Invalidation
|
// 6. Refresh the data on the Settings page
|
||||||
revalidatePath("/settings");
|
revalidatePath("/settings");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
// src/app/update/[id]/_actions.ts
|
//src/app/update/[id]/_actions.ts
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
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) {
|
export async function updateFileAction(formData: FormData) {
|
||||||
const session = await auth();
|
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 id = formData.get("id") as string;
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const description = formData.get("description") 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 customMetadataRaw = formData.get("customMetadata") as string;
|
||||||
|
|
||||||
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
||||||
|
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 3. Parse the incoming metadata from the UI
|
// 1. Get existing record to preserve system metadata (like mimeType)
|
||||||
const newMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
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
|
// 2. Update the record
|
||||||
const existing = await getFileNodeById(id);
|
await prisma.fileNode.update({
|
||||||
if (!existing) throw new Error("File record not found");
|
where: { id },
|
||||||
if (existing.ownerId !== session.user.id) throw new Error("Permission denied");
|
data: {
|
||||||
|
name,
|
||||||
const existingMetadata = (existing.metadata as Record<string, any>) || {};
|
|
||||||
|
|
||||||
// 5. Logic: Merge Strategy
|
|
||||||
// We keep internal system fields like 'mimeType' but allow the
|
|
||||||
// user-approved 'newMetadata' (including extracted GPS/Author) to take precedence.
|
|
||||||
const updatedMetadata = {
|
|
||||||
...existingMetadata, // Keep everything we currently have
|
|
||||||
...newMetadata, // Overwrite with the fields the user just approved/edited
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. DAL: Perform the update via your file-nodes logic
|
|
||||||
await updateFileNode(id, {
|
|
||||||
name: name || existing.name,
|
|
||||||
description,
|
description,
|
||||||
parentId,
|
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("/dashboard");
|
||||||
revalidatePath(`/update/${id}`);
|
revalidatePath(`/update/${id}`);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Update action error:", error);
|
console.error("Update error:", error);
|
||||||
return {
|
return { success: false, message: error.message };
|
||||||
success: false,
|
|
||||||
message: error.message || "An unexpected error occurred during update"
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,53 +4,31 @@ import { prisma } from "@/lib/prisma";
|
||||||
import { Container } from "@mui/material";
|
import { Container } from "@mui/material";
|
||||||
import UpdateView from "./update-view";
|
import UpdateView from "./update-view";
|
||||||
|
|
||||||
|
// Note: params is now handled as a Promise
|
||||||
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
if (!session) redirect("/");
|
||||||
|
|
||||||
// Security: Ensure the user is logged in
|
// 1. Await the params to get the actual ID
|
||||||
if (!session?.user?.id) {
|
|
||||||
redirect("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Await the params to get the actual ID from the URL
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
// 2. Fetch the specific file.
|
// 2. Fetch the specific file using the awaited ID
|
||||||
// We include ownerId in the where clause to prevent users from editing each other's files.
|
|
||||||
const fileNode = await prisma.fileNode.findUnique({
|
const fileNode = await prisma.fileNode.findUnique({
|
||||||
where: {
|
where: { id: id }
|
||||||
id: id,
|
|
||||||
ownerId: session.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fileNode) {
|
if (!fileNode) notFound();
|
||||||
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({
|
const folders = await prisma.fileNode.findMany({
|
||||||
where: {
|
where: { isFolder: true },
|
||||||
isFolder: true,
|
|
||||||
ownerId: session.user.id
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
select: { id: true, name: true }
|
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 (
|
return (
|
||||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||||
<UpdateView
|
<UpdateView fileNode={fileNode} folders={folders} />
|
||||||
fileNode={serializedFileNode}
|
|
||||||
folders={folders}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,96 +1,41 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// src/app/update/[id]/update-view.tsx
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box, Button, Typography, Paper, Stack,
|
Box, Button, Typography, Paper, Stack,
|
||||||
TextField, MenuItem, IconButton, Grid,
|
TextField, MenuItem, IconButton, Grid, Divider
|
||||||
Checkbox, CircularProgress, Tooltip
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import SaveIcon from "@mui/icons-material/Save";
|
import SaveIcon from "@mui/icons-material/Save";
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import MapIcon from '@mui/icons-material/Map';
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { updateFileAction } from "./_actions";
|
import { updateFileAction } from "./_actions";
|
||||||
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
|
|
||||||
|
|
||||||
interface MetadataPair {
|
interface MetadataPair {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
selected: boolean;
|
|
||||||
isPending?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** * Utility to turn nested objects into flat key-value pairs for the UI
|
export default function UpdateView({ fileNode, folders }: any) {
|
||||||
*/
|
|
||||||
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[]; }) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
|
// 1. Initialize Basic Info
|
||||||
const [name, setName] = useState(fileNode.name);
|
const [name, setName] = useState(fileNode.name);
|
||||||
const [description, setDescription] = useState(fileNode.description || "");
|
const [description, setDescription] = useState(fileNode.description || "");
|
||||||
const [parentId, setParentId] = useState(fileNode.parentId || "");
|
const [parentId, setParentId] = useState(fileNode.parentId || "");
|
||||||
|
|
||||||
const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
|
// 2. Parse existing JSON metadata into Key/Value array for the UI
|
||||||
.filter(([key]) => !['type', 'mimeType', 'magicFilled', 'details'].includes(key))
|
// We filter out 'type' and 'mimeType' as they are system-managed
|
||||||
.map(([key, value]) => ({
|
const initialMetadata = Object.entries(fileNode.metadata || {})
|
||||||
key,
|
.filter(([key]) => !['type', 'mimeType'].includes(key))
|
||||||
value: String(value),
|
.map(([key, value]) => ({ key, value: String(value) }));
|
||||||
selected: true,
|
|
||||||
isPending: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
|
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 () => {
|
const handleUpdate = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
@ -99,10 +44,9 @@ export default function UpdateView({ fileNode, folders: availablefolders }: { fi
|
||||||
formData.append("description", description);
|
formData.append("description", description);
|
||||||
formData.append("parentId", parentId);
|
formData.append("parentId", parentId);
|
||||||
|
|
||||||
|
// Convert array back to object for storage
|
||||||
const metadataObj = customMetadata.reduce((acc, curr) => {
|
const metadataObj = customMetadata.reduce((acc, curr) => {
|
||||||
if (curr.selected && curr.key.trim()) {
|
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
||||||
acc[curr.key.trim()] = curr.value;
|
|
||||||
}
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string>);
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
|
@ -120,88 +64,105 @@ export default function UpdateView({ fileNode, folders: availablefolders }: { fi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
|
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
|
||||||
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>Back</Button>
|
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
|
||||||
|
Back
|
||||||
<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>
|
</Button>
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack spacing={4}>
|
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
|
||||||
<TextField label="File Name" fullWidth value={name} onChange={(e) => setName(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
|
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>
|
<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>
|
</TextField>
|
||||||
|
|
||||||
|
{/* Custom Metadata Section */}
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" justifyContent="space-between" mb={2}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
<Typography variant="h6" fontWeight="700"><AssignmentIcon /> Attributes</Typography>
|
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Button startIcon={<AddCircleOutlineIcon />} onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}>Add Field</Button>
|
<AssignmentIcon color="primary" /> Custom Attributes
|
||||||
</Stack>
|
</Typography>
|
||||||
|
<Button
|
||||||
|
startIcon={<AddCircleOutlineIcon />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])}
|
||||||
|
>
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{customMetadata.map((row, index) => (
|
{customMetadata.map((row, index) => (
|
||||||
<Box key={index}>
|
<Grid container spacing={1} key={index} alignItems="center">
|
||||||
<Grid container spacing={1} alignItems="center">
|
<Grid item xs={5}>
|
||||||
<Grid item xs={1}>
|
<TextField
|
||||||
<Checkbox checked={row.selected} onChange={(e) => {
|
fullWidth size="small" placeholder="Key"
|
||||||
const updated = [...customMetadata];
|
value={row.key}
|
||||||
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' }}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updated = [...customMetadata];
|
const updated = [...customMetadata];
|
||||||
updated[index].key = e.target.value;
|
updated[index].key = e.target.value;
|
||||||
setCustomMetadata(updated);
|
setCustomMetadata(updated);
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6}>
|
<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) => {
|
onChange={(e) => {
|
||||||
const updated = [...customMetadata];
|
const updated = [...customMetadata];
|
||||||
updated[index].value = e.target.value;
|
updated[index].value = e.target.value;
|
||||||
setCustomMetadata(updated);
|
setCustomMetadata(updated);
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={1}>
|
<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>
|
||||||
</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>
|
</Stack>
|
||||||
</Box>
|
</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"}
|
{loading ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,55 @@
|
||||||
'use server';
|
'use server';
|
||||||
//src/app/upload/_actions.ts)
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||||
import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
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) {
|
export async function createFolderAction(name: string, parentId?: string | null) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const internalId = crypto.randomUUID();
|
const internalId = crypto.randomUUID();
|
||||||
const newNode = await createFileNode({
|
|
||||||
|
const newNode = await prisma.fileNode.create({
|
||||||
|
data: {
|
||||||
id: internalId,
|
id: internalId,
|
||||||
oneDriveId: null,
|
oneDriveId: null, // Virtual folders do not have a cloud ID
|
||||||
name,
|
name: name,
|
||||||
isFolder: true,
|
isFolder: true,
|
||||||
path: `virtual:/${name}`,
|
path: `virtual:/${name}`,
|
||||||
ownerId: session.user.id,
|
ownerId: session.user.id,
|
||||||
parentId: parentId || null,
|
parentId: parentId || null,
|
||||||
metadata: { type: "FOLDER" }
|
metadata: { type: "FOLDER" }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
revalidatePath("/upload");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
|
|
||||||
return { success: true, node: newNode };
|
return { success: true, node: newNode };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error("Folder creation error:", error);
|
||||||
throw new Error(error.message || "Failed to create virtual folder");
|
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) {
|
export async function uploadFileAction(formData: FormData) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
const file = formData.get("file") as File;
|
const file = formData.get("file") as File;
|
||||||
const hash = formData.get("hash") as string;
|
|
||||||
const description = formData.get("description") as string || "";
|
const description = formData.get("description") as string || "";
|
||||||
const parentIdRaw = formData.get("parentId") as string | null;
|
const parentIdRaw = formData.get("parentId") as string | null;
|
||||||
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
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");
|
if (!file) throw new Error("No file selected");
|
||||||
|
|
||||||
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
const rootFolder = "WebCalibre";
|
const rootFolder = "WebCalibre";
|
||||||
const internalId = crypto.randomUUID();
|
const internalId = crypto.randomUUID(); // This UUID will be the OneDrive folder name
|
||||||
|
|
||||||
try {
|
// 1. Create the Physical Storage Folder on OneDrive
|
||||||
await ensureOneDriveFolder(session.user.id, rootFolder);
|
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);
|
if (!createSubFolderRes.ok) {
|
||||||
const subFolderData = await subFolderRes.json();
|
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,
|
id: internalId,
|
||||||
oneDriveId: uploadedFileData.id,
|
oneDriveId: oneDriveId,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
hash: hash,
|
|
||||||
description: description,
|
description: description,
|
||||||
size: BigInt(file.size),
|
size: BigInt(file.size),
|
||||||
isFolder: false,
|
isFolder: false,
|
||||||
|
|
@ -80,86 +123,13 @@ export async function uploadFileAction(formData: FormData) {
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
metadata: {
|
metadata: {
|
||||||
...customMetadata,
|
...customMetadata,
|
||||||
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
|
type: extension,
|
||||||
mimeType: file.type
|
mimeType: file.type
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
revalidatePath("/upload");
|
revalidatePath("/upload");
|
||||||
return { success: true };
|
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';
|
if (!session) redirect("/");
|
||||||
import { useDropzone } from 'react-dropzone';
|
|
||||||
import {
|
|
||||||
Box, Button, Typography, Paper, Table, TableBody,
|
|
||||||
TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
|
|
||||||
Alert, Stack
|
|
||||||
} from '@mui/material';
|
|
||||||
import FolderIcon from '@mui/icons-material/Folder';
|
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
|
||||||
|
|
||||||
// --- OUR UTILITIES ---
|
// Fetch only folders so the user can select a destination
|
||||||
import { calculateFileHash } from '@/lib/hashing-client';
|
const folders = await prisma.fileNode.findMany({
|
||||||
import { checkDuplicateAction } from '@/app/upload/_actions';
|
where: { isFolder: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
interface UploadQueueItem {
|
select: { id: true, name: true, parentId: true }
|
||||||
id: string;
|
|
||||||
file: File;
|
|
||||||
path: string;
|
|
||||||
hash: string | null;
|
|
||||||
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BulkUploadPage() {
|
|
||||||
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
// 1. Handle File & Folder Drops
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
||||||
const newItems = acceptedFiles.map(file => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
file,
|
|
||||||
path: (file as any).path || file.name, // Captures subfolder structure
|
|
||||||
hash: null,
|
|
||||||
status: 'queued' as const,
|
|
||||||
}));
|
|
||||||
setQueue(prev => [...prev, ...newItems]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
|
||||||
|
|
||||||
// 2. The "Processing Engine"
|
|
||||||
// This effect runs whenever the queue changes or isProcessing toggles
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isProcessing) return;
|
|
||||||
|
|
||||||
const runQueue = async () => {
|
|
||||||
// Find the next file that hasn't been hashed/checked yet
|
|
||||||
const nextIndex = queue.findIndex(item => item.status === 'queued');
|
|
||||||
|
|
||||||
if (nextIndex === -1) {
|
|
||||||
setIsProcessing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = queue[nextIndex];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step A: Hashing
|
|
||||||
updateItem(item.id, { status: 'hashing' });
|
|
||||||
const hash = await calculateFileHash(item.file);
|
|
||||||
|
|
||||||
// Step B: Duplicate Check
|
|
||||||
updateItem(item.id, { status: 'checking', hash });
|
|
||||||
const existing = await checkDuplicateAction(hash);
|
|
||||||
|
|
||||||
// Step C: Mark Results
|
|
||||||
updateItem(item.id, {
|
|
||||||
status: existing ? 'duplicate' : 'ready'
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
updateItem(item.id, { status: 'error', error: 'Process failed' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
runQueue();
|
|
||||||
}, [queue, isProcessing]);
|
|
||||||
|
|
||||||
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
|
|
||||||
setQueue(current => current.map(item => item.id === id ? { ...item, ...updates } : item));
|
|
||||||
};
|
|
||||||
|
|
||||||
const duplicateCount = queue.filter(i => i.status === 'duplicate').length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
|
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||||
<Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
|
{/* Pass folders to the view */}
|
||||||
Bulk Uploads & Restore
|
<UploadView user={session.user} folders={folders} />
|
||||||
</Typography>
|
</Container>
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,345 +1,251 @@
|
||||||
'use client';
|
'use client';
|
||||||
// src/app/upload/upload-view.tsx
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box, Button, Typography, Paper, Stack,
|
Box, Button, Typography, Paper, Stack,
|
||||||
TextField, IconButton, Divider,
|
TextField, MenuItem, IconButton, Divider,
|
||||||
Grid,
|
Grid, CircularProgress
|
||||||
CircularProgress, Checkbox, MenuItem,
|
|
||||||
Collapse,
|
|
||||||
Dialog, DialogTitle, DialogContent,
|
|
||||||
DialogContentText, DialogActions
|
|
||||||
} from "@mui/material";
|
} 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 AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
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 { useRouter } from "next/navigation";
|
||||||
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
|
import { uploadFileAction, createFolderAction } from "./_actions";
|
||||||
import { calculateFileHash } from "@/lib/hashing-client";
|
|
||||||
import { uploadFileAction, createFolderAction, checkDuplicateAction } from "./_actions";
|
|
||||||
|
|
||||||
interface MetadataRow {
|
interface MetadataPair {
|
||||||
key: string;
|
key: string;
|
||||||
value: 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 router = useRouter();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Form State
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [description, setDescription] = useState("");
|
||||||
const [targetFolderId, setTargetFolderId] = useState<string>("");
|
const [parentId, setParentId] = useState("");
|
||||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
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 [newFolderName, setNewFolderName] = useState("");
|
||||||
const [rows, setRows] = useState<MetadataRow[]>([]);
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||||
|
|
||||||
// UI Status State
|
const handleCreateFolder = async () => {
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
if (!newFolderName.trim()) return;
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'hashing' | 'saving'>('idle');
|
setIsCreatingFolder(true);
|
||||||
|
|
||||||
// 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);
|
|
||||||
try {
|
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) {
|
if (result.success) {
|
||||||
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
|
setNewFolderName("");
|
||||||
key: k,
|
setShowFolderInput(false);
|
||||||
value: typeof v === 'object' ? JSON.stringify(v) : String(v),
|
router.refresh();
|
||||||
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];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error("Extraction failed:", err);
|
alert(err.message || "Failed to create folder");
|
||||||
} finally {
|
} finally {
|
||||||
setIsExtracting(false);
|
setIsCreatingFolder(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) setSelectedFile(file);
|
const removeMetadataRow = (index: number) => {
|
||||||
|
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 2. UPLOAD EXECUTION ---
|
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
|
||||||
const executeUpload = async (preCalculatedHash?: string) => {
|
const updated = [...customMetadata];
|
||||||
setSaveStatus('saving');
|
updated[index][field] = val;
|
||||||
try {
|
setCustomMetadata(updated);
|
||||||
let currentParentId = targetFolderId;
|
};
|
||||||
|
|
||||||
// STEP A: Handle New Folder Creation
|
const handleUpload = async () => {
|
||||||
if (newFolderName.trim()) {
|
if (!file) return;
|
||||||
const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
|
setStatus('uploading');
|
||||||
if (folderResult.success) {
|
|
||||||
currentParentId = folderResult.node.id;
|
|
||||||
} else {
|
|
||||||
throw new Error(folderResult.error || "Failed to create folder");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP B: Handle File Upload
|
|
||||||
if (selectedFile) {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", selectedFile);
|
formData.append("file", file);
|
||||||
formData.append("hash", preCalculatedHash || "");
|
formData.append("description", description);
|
||||||
formData.append("parentId", currentParentId || "root");
|
formData.append("parentId", parentId);
|
||||||
|
|
||||||
const metadataObject = rows
|
const metadataObj = customMetadata.reduce((acc, curr) => {
|
||||||
.filter(r => r.selected && r.key.trim() !== "")
|
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
||||||
.reduce((acc, curr) => {
|
|
||||||
acc[curr.key.trim()] = curr.value;
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string>);
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
formData.append("customMetadata", JSON.stringify(metadataObject));
|
formData.append("customMetadata", JSON.stringify(metadataObj));
|
||||||
|
|
||||||
const uploadResult = await uploadFileAction(formData);
|
|
||||||
if (!uploadResult.success) throw new Error(uploadResult.error || "Upload failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadFileAction(formData);
|
||||||
|
if (result.success) {
|
||||||
|
setStatus('success');
|
||||||
|
setFile(null);
|
||||||
|
setDescription("");
|
||||||
|
setCustomMetadata([]);
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Save failed:", err);
|
|
||||||
alert(err.message || "An error occurred while saving.");
|
|
||||||
setSaveStatus('idle');
|
|
||||||
}
|
}
|
||||||
};
|
} catch (err) {
|
||||||
|
alert("Upload failed.");
|
||||||
// --- 3. SAVE HANDLER (With Hash Intercept) ---
|
setStatus('idle');
|
||||||
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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}>
|
||||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
|
|
||||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
||||||
Upload & Enrich
|
Add to Library
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
|
<Stack spacing={4} sx={{ mt: 4 }}>
|
||||||
{/* FOLDER SELECTION */}
|
{/* 1. Destination */}
|
||||||
<Box>
|
<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}>
|
<Stack direction="row" spacing={1}>
|
||||||
<TextField
|
<TextField
|
||||||
select fullWidth label="Parent Destination"
|
id="project-destination-select"
|
||||||
value={targetFolderId}
|
select
|
||||||
onChange={(e) => setTargetFolderId(e.target.value)}
|
fullWidth
|
||||||
helperText="Choose where your file will live"
|
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>
|
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
|
||||||
{folders?.map((f) => (
|
{folders.map((f: any) => (
|
||||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<Button
|
<IconButton
|
||||||
variant={showNewFolderInput ? "contained" : "outlined"}
|
color="primary"
|
||||||
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
onClick={() => setShowFolderInput(!showFolderInput)}
|
||||||
sx={{ height: 56, minWidth: 56 }}
|
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
|
||||||
>
|
>
|
||||||
<CreateNewFolderIcon />
|
<CreateNewFolderIcon />
|
||||||
</Button>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Collapse in={showNewFolderInput}>
|
{showFolderInput && (
|
||||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
NEW SUB-FOLDER NAME
|
{parentId ? `Create inside current selection` : `Create at Root`}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth size="small" placeholder="e.g. Finance 2026"
|
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)"
|
||||||
value={newFolderName}
|
value={newFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
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
|
<Button
|
||||||
variant="outlined" fullWidth startIcon={<CloudUploadIcon />}
|
variant="contained"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={handleCreateFolder}
|
||||||
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
|
disabled={isCreatingFolder || !newFolderName}
|
||||||
>
|
>
|
||||||
Select File to Upload
|
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
|
||||||
</Button>
|
</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>
|
</Stack>
|
||||||
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
|
</Box>
|
||||||
<ClearIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Paper>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 4 }} />
|
{/* 2. Upload Area */}
|
||||||
|
|
||||||
{/* MAGIC EXTRACT */}
|
|
||||||
<Box sx={{ mb: 4, p: 2.5, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
|
|
||||||
<Box>
|
<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">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Auto-pull metadata from file content.
|
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
) : (
|
||||||
variant="contained" onClick={handleMagicEnhance}
|
"Click to Select File"
|
||||||
disabled={!selectedFile || isExtracting}
|
)}
|
||||||
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
|
|
||||||
sx={{ borderRadius: 20, px: 3 }}
|
|
||||||
>
|
|
||||||
{isExtracting ? "Extracting..." : "Run"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* METADATA PREVIEW */}
|
{/* 3. Custom Attributes */}
|
||||||
<Box sx={{ mb: 4 }}>
|
<Box>
|
||||||
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
<AssignmentIcon color="primary" /> Metadata Fields
|
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<AssignmentIcon color="primary" /> 3. Custom Attributes
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Button startIcon={<AddCircleOutlineIcon />} size="small" onClick={addMetadataRow}>
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{rows.map((row, index) => (
|
{customMetadata.map((row, index) => (
|
||||||
<Grid container spacing={1} key={index} alignItems="center">
|
<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}>
|
<Grid item xs={5}>
|
||||||
<TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
|
<TextField
|
||||||
const updated = [...rows];
|
fullWidth size="small" placeholder="Key (e.g. Project-ID)"
|
||||||
updated[index].key = e.target.value;
|
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
|
||||||
setRows(updated);
|
/>
|
||||||
}} />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={5}>
|
<Grid item xs={6}>
|
||||||
<TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
|
<TextField
|
||||||
const updated = [...rows];
|
fullWidth size="small" placeholder="Value"
|
||||||
updated[index].value = e.target.value;
|
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)}
|
||||||
setRows(updated);
|
/>
|
||||||
}} />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={1}>
|
<Grid item xs={1}>
|
||||||
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
|
<IconButton color="error" onClick={() => removeMetadataRow(index)}>
|
||||||
<DeleteOutlineIcon />
|
<DeleteOutlineIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
|
|
||||||
Add Manual Field
|
<TextField
|
||||||
</Button>
|
label="General Description"
|
||||||
|
multiline rows={2} fullWidth
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* FINAL BUTTON */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained" size="large" fullWidth onClick={handleSave}
|
variant="contained" size="large" fullWidth
|
||||||
disabled={!canSubmit || saveStatus !== 'idle'}
|
disabled={!file || status === 'uploading'}
|
||||||
sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
|
onClick={handleUpload}
|
||||||
|
sx={{ py: 2, fontWeight: 'bold' }}
|
||||||
>
|
>
|
||||||
{saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
|
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'}
|
||||||
saveStatus === 'saving' ? "Uploading to OneDrive..." :
|
|
||||||
"Complete Upload & Save"}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</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 MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
||||||
import type { NextAuthConfig } from "next-auth";
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
|
|
@ -10,10 +9,20 @@ export default {
|
||||||
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
|
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
|
||||||
authorization: {
|
authorization: {
|
||||||
params: {
|
params: {
|
||||||
// offline_access is vital for getting the refresh_token
|
scope: "openid profile offline_access email Files.ReadWrite Files.Read",
|
||||||
scope: "openid profile email offline_access 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;
|
} 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({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
session: { strategy: "jwt" },
|
session: { strategy: "jwt" },
|
||||||
...authConfig, // This now spreads the default export from auth.config.ts
|
...authConfig,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account, user }) {
|
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) {
|
if (account) {
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
token.refreshToken = account.refresh_token;
|
token.refreshToken = account.refresh_token;
|
||||||
token.expiresAt = account.expires_at;
|
token.expiresAt = account.expires_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Attach User ID and Role to the token
|
||||||
|
// This runs when the user first logs in
|
||||||
if (user) {
|
if (user) {
|
||||||
token.sub = user.id;
|
token.sub = user.id;
|
||||||
// @ts-ignore
|
// @ts-ignore - 'role' is a custom field in your Postgres User table
|
||||||
token.role = user.role;
|
token.role = user.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,10 +30,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
},
|
},
|
||||||
|
|
||||||
async session({ session, token }) {
|
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) {
|
if (session?.user) {
|
||||||
session.user.id = token.sub as string;
|
session.user.id = token.sub as string;
|
||||||
// @ts-ignore
|
|
||||||
|
// @ts-ignore - Attaching the role for UI permissions
|
||||||
session.user.role = token.role as string;
|
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;
|
session.accessToken = token.accessToken as string;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
|
|
@ -40,7 +50,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
async linkAccount({ account, user }) {
|
async linkAccount({ account, user }) {
|
||||||
console.log("🔗 Account linked successfully for user:", user.id);
|
console.log("🔗 Account linked successfully for user:", user.id);
|
||||||
if (!account.refresh_token) {
|
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 MenuIcon from '@mui/icons-material/Menu';
|
||||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
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 DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
@ -45,7 +44,6 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
||||||
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
||||||
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
|
{ 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
|
// Only push Settings if the user has Admin rights
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
|
// ... other imports like ThemeProvider
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
|
||||||
import theme from '@/lib/theme'; // Import your custom theme here
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<AppRouterCacheProvider options={{ enableCssLayer: true }}>
|
<AppRouterCacheProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
{/* CssBaseline resets browser styles to match MUI and your theme */}
|
|
||||||
<CssBaseline />
|
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppRouterCacheProvider>
|
</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";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function getFreshAccessToken(userId: string) {
|
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)
|
// 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;
|
const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000;
|
||||||
|
|
||||||
if (!isExpired && account.access_token) {
|
if (!isExpired && account.access_token) {
|
||||||
|
|
@ -30,17 +30,12 @@ export async function getFreshAccessToken(userId: string) {
|
||||||
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
|
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: account.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();
|
const tokens = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) throw tokens;
|
||||||
console.error("❌ Microsoft Token Refresh Response Error:", tokens);
|
|
||||||
throw tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Update the Account table with the new tokens
|
// 4. Update the Account table with the new tokens
|
||||||
await prisma.account.update({
|
await prisma.account.update({
|
||||||
|
|
@ -48,7 +43,6 @@ export async function getFreshAccessToken(userId: string) {
|
||||||
data: {
|
data: {
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
|
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,
|
refresh_token: tokens.refresh_token ?? account.refresh_token,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -56,7 +50,6 @@ export async function getFreshAccessToken(userId: string) {
|
||||||
return tokens.access_token;
|
return tokens.access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Failed to refresh Microsoft token:", 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");
|
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 { createTheme } from '@mui/material/styles';
|
||||||
import { Roboto } from 'next/font/google';
|
import { Roboto } from 'next/font/google';
|
||||||
|
|
||||||
|
// Load the font optimized for Next.js
|
||||||
const roboto = Roboto({
|
const roboto = Roboto({
|
||||||
weight: ['300', '400', '500', '700'],
|
weight: ['300', '400', '500', '700'],
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
|
|
@ -8,36 +9,39 @@ const roboto = Roboto({
|
||||||
});
|
});
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
|
// 1. Color Palette (Clean & Professional for a Library App)
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
primary: {
|
primary: {
|
||||||
main: '#1976d2',
|
main: '#1976d2', // Professional Blue
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: '#9c27b0',
|
main: '#9c27b0', // Purple for accents
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: '#f4f6f8',
|
default: '#f4f6f8', // Light grey for the app background
|
||||||
paper: '#ffffff',
|
paper: '#ffffff',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 2. Typography
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: roboto.style.fontFamily,
|
fontFamily: roboto.style.fontFamily,
|
||||||
h6: {
|
h6: {
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 3. Component Defaults
|
||||||
components: {
|
components: {
|
||||||
// Keep your button styles
|
|
||||||
MuiButton: {
|
MuiButton: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
textTransform: 'none',
|
textTransform: 'none', // Prevents all-caps buttons
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Keep your paper styles
|
|
||||||
MuiPaper: {
|
MuiPaper: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
elevation: 2,
|
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