Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b94846b21 | |||
| f37b1ee731 | |||
| 5dd5ec2792 | |||
| 6a3470229e | |||
| 96eda53f31 | |||
| 222dcda775 | |||
| 672408081d | |||
| 552662cf16 | |||
| 71b567ad04 | |||
| 9552846a0f | |||
| 85c068399e | |||
| fd837fe22f | |||
| c1a9bb6465 | |||
| e15373c169 |
52 changed files with 36136 additions and 1061 deletions
1
.env
1
.env
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
|
||||||
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,4 +1,5 @@
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
369
backup/db-backup-26-02-14
Normal file
369
backup/db-backup-26-02-14
Normal file
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.
59
docs/Context-1.md
Normal file
59
docs/Context-1.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# 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.
|
||||||
BIN
docs/Context-1.pdf
Normal file
BIN
docs/Context-1.pdf
Normal file
Binary file not shown.
4452
docs/html-discussions-with-AI/WebCalibre-(15_02_2026 23:03:17).html
Normal file
4452
docs/html-discussions-with-AI/WebCalibre-(15_02_2026 23:03:17).html
Normal file
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.
1996
package-lock.json
generated
1996
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -4,6 +4,7 @@
|
||||||
"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",
|
||||||
|
|
@ -21,12 +22,20 @@
|
||||||
"@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.2.0",
|
"@prisma/client": "7.4.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",
|
||||||
|
|
@ -36,7 +45,8 @@
|
||||||
"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.2.0",
|
"prisma": "7.4.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
// prisma.config.ts
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
config({ path: ".env.local" });
|
config({ path: ".env.local" });
|
||||||
|
|
||||||
import { defineConfig } from "prisma/config";
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
datasource: {
|
datasource: {
|
||||||
// This is now the ONLY place where the DB connection string is defined
|
// This is where Prisma 7 looks for the connection string
|
||||||
url: process.env.DATABASE_URL,
|
url: env("DATABASE_URL"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1,20 +1,58 @@
|
||||||
|
-- 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,
|
||||||
"azureAdUserId" TEXT NOT NULL,
|
"name" TEXT,
|
||||||
"email" TEXT NOT NULL,
|
"email" TEXT NOT NULL,
|
||||||
"displayName" TEXT,
|
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||||
|
"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,
|
||||||
|
|
@ -29,11 +67,17 @@ 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 "User_email_key" ON "User"("email");
|
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||||
|
|
||||||
|
-- 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");
|
||||||
|
|
@ -47,8 +91,15 @@ 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 SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/*
|
|
||||||
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;
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
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");
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "FileNode_hash_key";
|
||||||
|
|
@ -60,6 +60,7 @@ 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
|
||||||
|
|
|
||||||
59
scripts/backfill-hashes.ts
Normal file
59
scripts/backfill-hashes.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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,66 +1,57 @@
|
||||||
// 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 the auth function from your central config
|
import { auth } from "@/auth";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getFileNodeById } from "@/data-access/file-nodes";
|
||||||
|
import { getOneDriveContentStream } from "@/services/onedrive";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// 1. Check Authentication using the V5 auth() helper
|
// 1. Authenticate the user session
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
// In V5, tokens are usually handled in the session callback
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
if (!session || !session.accessToken) {
|
|
||||||
return new NextResponse("Unauthorized - No Access Token found", { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get parameters from URL
|
// 2. Extract parameters from URL
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const fileNodeId = searchParams.get('id');
|
const fileId = searchParams.get('id');
|
||||||
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
|
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
|
||||||
|
|
||||||
if (!fileNodeId) {
|
if (!fileId) {
|
||||||
return new NextResponse("File ID is required", { status: 400 });
|
return new NextResponse("File ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Find the file in your Postgres FileNode table
|
// 3. DAL: Fetch file metadata from local database
|
||||||
const fileNode = await prisma.fileNode.findUnique({
|
const fileNode = await getFileNodeById(fileId);
|
||||||
where: { id: fileNodeId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fileNode || !fileNode.oneDriveId) {
|
if (!fileNode || !fileNode.oneDriveId) {
|
||||||
return new NextResponse("File not found in database", { status: 404 });
|
return new NextResponse("File not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fetch the file stream from Microsoft Graph
|
// 4. SERVICE: Get the binary stream from Microsoft Graph
|
||||||
const graphResponse = await fetch(
|
// The service layer automatically handles the 'getFreshAccessToken' logic
|
||||||
`https://graph.microsoft.com/v1.0/me/drive/items/${fileNode.oneDriveId}/content`,
|
const graphResponse = await getOneDriveContentStream(session.user.id, fileNode.oneDriveId);
|
||||||
{
|
|
||||||
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
|
||||||
return new NextResponse(graphResponse.body, {
|
// We pass the graphResponse.body (ReadableStream) directly to NextResponse
|
||||||
|
// src/app/api/download/route.ts
|
||||||
|
const metadata = fileNode.metadata as any;
|
||||||
|
const contentType = metadata?.mimeType || 'application/octet-stream';
|
||||||
|
|
||||||
|
return new NextResponse(graphResponse.body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': fileNode.mimeType || 'application/octet-stream',
|
'Content-Type': contentType,
|
||||||
// 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) {
|
} catch (error: any) {
|
||||||
console.error('Download error:', error);
|
console.error('Download Route Error:', error);
|
||||||
return new NextResponse("Internal Server Error", { status: 500 });
|
return new NextResponse(
|
||||||
|
JSON.stringify({ error: "Internal Server Error", message: error.message }),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,138 +1,90 @@
|
||||||
|
// 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 for the Dashboard
|
* 1. FETCH: Get all file nodes
|
||||||
|
* Now simply calls the DAL. Error handling is left to the caller (the UI).
|
||||||
*/
|
*/
|
||||||
export async function getFileNodes() {
|
export async function getFileNodes() {
|
||||||
try {
|
return await getAllFileNodes();
|
||||||
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 prisma.fileNode.findUnique({ where: { id } });
|
const file = await getFileNodeById(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");
|
||||||
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
// Service handles token refresh and graph request internally
|
||||||
const res = await fetch(
|
const data = await getOneDriveItem(session.user.id, file.oneDriveId);
|
||||||
`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 link");
|
if (!downloadUrl) throw new Error("OneDrive did not provide a download URL");
|
||||||
|
return downloadUrl;
|
||||||
return { downloadUrl };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3. DELETE: Remove from OneDrive (via ID) and Database
|
* 3. DELETE: Removes from both Cloud and Database
|
||||||
* Folders are virtual (DB only), so cloud deletion is skipped if oneDriveId is null.
|
|
||||||
*/
|
*/
|
||||||
export async function deleteFileAction(fileId: string) {
|
export async function deleteFileNodeAction(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 node = await prisma.fileNode.findUnique({
|
|
||||||
where: { id: fileId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const isAdmin = session.user.role === "ADMIN";
|
|
||||||
const isOwner = node.ownerId === session.user.id;
|
|
||||||
|
|
||||||
if (!isAdmin && !isOwner) {
|
|
||||||
throw new Error("Permission Denied.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
const file = await getFileNodeById(id);
|
||||||
|
if (!file) throw new Error("File record not found");
|
||||||
|
|
||||||
// Only attempt cloud deletion if it's a file/storage with a oneDriveId.
|
// Phase 1: Cloud Deletion
|
||||||
// Virtual folders created in the DB have no oneDriveId and are skipped.
|
if (file.oneDriveId) {
|
||||||
if (accessToken && node.oneDriveId) {
|
await deleteFromOneDrive(session.user.id, file.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 {
|
// Phase 2: Database Deletion
|
||||||
await prisma.fileNode.delete({ where: { id: fileId } });
|
await deleteFileNode(id);
|
||||||
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) {
|
||||||
throw new Error("Move failed.");
|
console.error("Delete Error:", error);
|
||||||
|
return { success: false, error: "Failed to delete file" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 5. UPDATE & REPLACE: Full update of metadata and OneDrive content
|
* 4. UPDATE: Modify record and optionally sync new content to OneDrive
|
||||||
*/
|
*/
|
||||||
export async function updateFileFullAction(formData: FormData) {
|
export async function updateFileNodeAction(id: string, 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;
|
||||||
|
|
@ -143,44 +95,62 @@ export async function updateFileFullAction(formData: FormData) {
|
||||||
let metadata = JSON.parse(metadataStr);
|
let metadata = JSON.parse(metadataStr);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
const node = await getFileNodeById(id);
|
||||||
const node = await prisma.fileNode.findUnique({ where: { id } });
|
|
||||||
|
|
||||||
// Update physical file content only if a new file is uploaded and we have a target oneDriveId
|
// If a new file is uploaded, push it to OneDrive first
|
||||||
if (newFile && newFile.size > 0 && node?.oneDriveId) {
|
if (newFile && newFile.size > 0 && node?.oneDriveId) {
|
||||||
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}/content`;
|
await uploadToOneDrive(session.user.id, newFile, node.oneDriveId);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.fileNode.update({
|
// Update the database via DAL
|
||||||
where: { id },
|
await updateFileNode(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: any) {
|
} catch (error) {
|
||||||
console.error("Full Update Failure:", error);
|
console.error("Update Error:", error);
|
||||||
throw new Error(error.message || "Failed to update record.");
|
return { success: false, error: "Failed to update record" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export async function getMetadataPreviewAction(fileId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const node = await getFileNodeById(fileId);
|
||||||
|
if (!node || !node.oneDriveId) throw new Error("No OneDrive ID found");
|
||||||
|
|
||||||
|
console.log(`📡 Attempting fetch via Service for: ${node.name}`);
|
||||||
|
|
||||||
|
const token = await getFreshAccessToken(session.user.id);
|
||||||
|
|
||||||
|
if (!token) throw new Error("Could not retrieve access token");
|
||||||
|
// Call your existing service
|
||||||
|
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
|
||||||
|
|
||||||
|
console.log(`📦 Buffer received: ${buffer.length} bytes`);
|
||||||
|
|
||||||
|
const extractedData = await extractMetadata(buffer, node.name);
|
||||||
|
|
||||||
|
console.log("✅ Extracted:", extractedData);
|
||||||
|
return { success: true, data: extractedData };
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ Service Fetch Error:", error.message);
|
||||||
|
// If this still says ENOTFOUND, the code is fine, but the terminal is blocked.
|
||||||
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
'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,
|
||||||
|
|
@ -13,7 +12,7 @@ import {
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
Tooltip
|
Tooltip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
DataGrid,
|
DataGrid,
|
||||||
|
|
@ -22,6 +21,7 @@ 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,25 +35,42 @@ 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 { deleteFileAction } from "./actions";
|
import { deleteFileNodeAction } 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 sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Toolbar >
|
||||||
|
<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, ...controlProps }, state) => (
|
render={({ ref, ...other }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...controlProps}
|
{...other}
|
||||||
|
sx={{ width: 300 }}
|
||||||
inputRef={ref}
|
inputRef={ref}
|
||||||
variant="outlined"
|
placeholder="Search library..."
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Search files and metadata..."
|
|
||||||
sx={{ width: 350 }}
|
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
|
|
@ -61,23 +78,33 @@ function CustomToolbar() {
|
||||||
<SearchIcon fontSize="small" />
|
<SearchIcon fontSize="small" />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
endAdornment: state.value ? (
|
endAdornment: other.value ? (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<QuickFilterClear size="small">
|
<QuickFilterClear
|
||||||
|
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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</QuickFilter>
|
</StyledQuickFilter>
|
||||||
|
</Box>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 3. Main Dashboard View ---
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
initialFiles: any[];
|
initialFiles: any[];
|
||||||
user?: {
|
user?: {
|
||||||
|
|
@ -91,6 +118,7 @@ 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";
|
||||||
|
|
@ -104,6 +132,7 @@ 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);
|
||||||
|
|
@ -121,22 +150,19 @@ 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 deleteFileAction(id);
|
await deleteFileNodeAction(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 DOWNLOAD FUNCTIONS ---
|
// --- NEW: Double Click Handler ---
|
||||||
const handleDownload = (id: string) => {
|
const handleRowDoubleClick: GridEventListener<'rowDoubleClick'> = (params) => {
|
||||||
// Triggers local folder download via Content-Disposition: attachment
|
// Only navigate if it's a file. If it's a folder, we could eventually navigate into it.
|
||||||
window.location.href = `/api/download?id=${id}&mode=attachment`;
|
if (!params.row.isFolder) {
|
||||||
};
|
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[] = [
|
||||||
|
|
@ -146,10 +172,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
flex: 1.5,
|
flex: 1.5,
|
||||||
minWidth: 250,
|
minWidth: 250,
|
||||||
renderCell: (params) => (
|
renderCell: (params) => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Tooltip title={params.row.isFolder ? "" : "Double-click to view deep metadata"} arrow>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%', cursor: 'pointer' }}>
|
||||||
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
|
{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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -165,7 +193,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', color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold' }}>
|
||||||
{params.value}
|
{params.value}
|
||||||
</Typography>
|
</Typography>
|
||||||
)
|
)
|
||||||
|
|
@ -176,90 +204,87 @@ 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, // Increased width to accommodate new buttons
|
width: 180,
|
||||||
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">
|
<Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" sx={{ height: '100%' }}>
|
||||||
{!isFolder && (
|
{!isFolder && (
|
||||||
<>
|
<>
|
||||||
<Tooltip title="View in Tab">
|
<IconButton size="small" color="info" onClick={(e) => { e.stopPropagation(); window.open(`/api/download?id=${params.row.id}&mode=inline`, '_blank'); }}>
|
||||||
<IconButton size="small" color="info" onClick={() => handleViewInTab(params.row.id)}>
|
|
||||||
<OpenInNewIcon fontSize="small" />
|
<OpenInNewIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
<IconButton size="small" color="success" onClick={(e) => { e.stopPropagation(); window.location.href = `/api/download?id=${params.row.id}&mode=attachment`; }}>
|
||||||
<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) && (
|
||||||
<>
|
<>
|
||||||
<Tooltip title="Edit Details">
|
<IconButton size="small" color="primary" onClick={(e) => { e.stopPropagation(); router.push(`/update/${params.row.id}`); }}>
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => router.push(`/update/${params.row.id}`)}
|
|
||||||
>
|
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDelete(params.row.id, params.row.name); }}>
|
||||||
<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 className="space-y-4">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}>
|
||||||
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}>
|
{lastSynced && (
|
||||||
Refresh List
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontStyle: 'italic' }}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
Last synced: {lastSynced.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}>
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
Sync OneDrive
|
Sync OneDrive
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
|
<Box sx={{ height: 750, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={initialFiles}
|
rows={initialFiles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
slots={{ toolbar: CustomToolbar }}
|
// --- THE FIX: showToolbar must be true, and toolbar slot must be assigned ---
|
||||||
showToolbar
|
showToolbar
|
||||||
|
slots={{ toolbar: CustomToolbar }}
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
|
onRowDoubleClick={handleRowDoubleClick} // ADDED THIS HANDLER
|
||||||
initialState={{
|
initialState={{
|
||||||
columns: {
|
columns: {
|
||||||
columnVisibilityModel: {
|
columnVisibilityModel: {
|
||||||
|
|
@ -269,8 +294,9 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
|
'& .MuiDataGrid-row:hover': {
|
||||||
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
132
src/app/dashboard/files/[id]/page.tsx
Normal file
132
src/app/dashboard/files/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// 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,62 +1,74 @@
|
||||||
|
// 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 { getFreshAccessToken } from "@/lib/auth-utils";
|
import { upsertFileNode } from "@/data-access/file-nodes";
|
||||||
|
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 accessToken = await getFreshAccessToken(session.user.id);
|
const items = await getWebCalibreChildren(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 data.value) {
|
for (const item of items) {
|
||||||
const isFolder = !!item.folder;
|
const isFolder = !!item.folder;
|
||||||
|
|
||||||
// Only skip if it's a folder AND it's a UUID (storage container)
|
if (isFolder && uuidRegex.test(item.name)) continue;
|
||||||
// If a user named a file with a UUID, we still want it.
|
|
||||||
if (isFolder && uuidRegex.test(item.name)) {
|
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toLowerCase() || 'unknown');
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
|
// 2. Save to database with combined metadata
|
||||||
|
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: { type: extension, mimeType: item.file?.mimeType || null },
|
metadata: {
|
||||||
}
|
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) {
|
||||||
throw new Error(error.message);
|
console.error("Sync 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,58 +11,32 @@ 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);
|
|
||||||
|
|
||||||
// 1. Create/Check WebCalibre Folder
|
try {
|
||||||
const folderPath = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
|
// 1. Logic: Ensure destination exists
|
||||||
const folderCheck = await fetch(folderPath, {
|
await ensureOneDriveFolder(session.user.id, folderName);
|
||||||
headers: { Authorization: `Bearer ${accessToken}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (folderCheck.status === 404) {
|
// 2. Logic: Perform the cloud upload
|
||||||
await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, {
|
const driveItem = await uploadLargeFile(session.user.id, file, folderName);
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: folderName, folder: {} })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Create Upload Session (Supports files > 4MB)
|
// 3. Logic: Save the result to our DB
|
||||||
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${file.name}:/createUploadSession`;
|
await createFileNode({
|
||||||
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: { type: file.name.split('.').pop()?.toUpperCase() }
|
metadata: {
|
||||||
|
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 { uploadFileToOneDrive } from "./upload-actions";
|
import { uploadFileAction } 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 uploadFileToOneDrive(formData);
|
await uploadFileAction(formData);
|
||||||
alert("Uploaded successfully!");
|
alert("Uploaded successfully!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
'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'.
|
* Toggles a user's role between 'ADMIN' and 'USER' using the DAL pattern.
|
||||||
* * 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();
|
||||||
|
|
@ -19,46 +17,34 @@ export async function toggleUserRoleAction(targetUserId: string) {
|
||||||
throw new Error("Unauthorized: No session found.");
|
throw new Error("Unauthorized: No session found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Authorization: Who is trying to change the role?
|
// 1. Authorization: Verify caller's permissions via DAL
|
||||||
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 the target user to be modified
|
// 2. Fetch target user via DAL
|
||||||
const targetUser = await prisma.user.findUnique({
|
const targetUser = await getUserById(targetUserId);
|
||||||
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. Determine new role
|
// 4. Logic: Determine new role
|
||||||
const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN";
|
const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN";
|
||||||
|
|
||||||
// 5. Execute Update
|
// 5. Execute Update via DAL
|
||||||
await prisma.user.update({
|
await updateUserRole(targetUserId, newRole);
|
||||||
where: { id: targetUserId },
|
|
||||||
data: { role: newRole }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Refresh the data on the Settings page
|
// 6. UI Invalidation
|
||||||
revalidatePath("/settings");
|
revalidatePath("/settings");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
'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;
|
||||||
|
|
@ -17,33 +25,44 @@ 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 {
|
||||||
// 1. Get existing record to preserve system metadata (like mimeType)
|
// 3. Parse the incoming metadata from the UI
|
||||||
const existing = await prisma.fileNode.findUnique({ where: { id } });
|
const newMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
||||||
const existingMetadata = (existing?.metadata as Record<string, any>) || {};
|
|
||||||
|
|
||||||
// 2. Update the record
|
// 4. DAL: Fetch existing record to safely merge system fields
|
||||||
await prisma.fileNode.update({
|
const existing = await getFileNodeById(id);
|
||||||
where: { id },
|
if (!existing) throw new Error("File record not found");
|
||||||
data: {
|
if (existing.ownerId !== session.user.id) throw new Error("Permission denied");
|
||||||
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: {
|
metadata: updatedMetadata,
|
||||||
...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 error:", error);
|
console.error("Update action error:", error);
|
||||||
return { success: false, message: error.message };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "An unexpected error occurred during update"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,31 +4,53 @@ 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("/");
|
|
||||||
|
|
||||||
// 1. Await the params to get the actual ID
|
// Security: Ensure the user is logged in
|
||||||
|
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 using the awaited ID
|
// 2. Fetch the specific file.
|
||||||
|
// 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: { id: id }
|
where: {
|
||||||
|
id: id,
|
||||||
|
ownerId: session.user.id
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fileNode) notFound();
|
if (!fileNode) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch folders for the destination dropdown
|
// 3. Fetch folders for the destination dropdown (if you decide to allow moving files)
|
||||||
const folders = await prisma.fileNode.findMany({
|
const folders = await prisma.fileNode.findMany({
|
||||||
where: { isFolder: true },
|
where: {
|
||||||
|
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 fileNode={fileNode} folders={folders} />
|
<UpdateView
|
||||||
|
fileNode={serializedFileNode}
|
||||||
|
folders={folders}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,41 +1,96 @@
|
||||||
'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, Divider
|
TextField, MenuItem, IconButton, Grid,
|
||||||
|
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 FolderIcon from "@mui/icons-material/Folder";
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpdateView({ fileNode, folders }: any) {
|
/** * Utility to turn nested objects into flat key-value pairs for the UI
|
||||||
|
*/
|
||||||
|
const flattenObject = (obj: any, prefix = ''): Record<string, string> => {
|
||||||
|
let results: Record<string, string> = {};
|
||||||
|
for (const key in obj) {
|
||||||
|
const value = obj[key];
|
||||||
|
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
Object.assign(results, flattenObject(value, newKey));
|
||||||
|
} else {
|
||||||
|
results[newKey] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UpdateView({ fileNode, folders: availablefolders }: { fileNode: any; folders: any[]; }) {
|
||||||
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 || "");
|
||||||
|
|
||||||
// 2. Parse existing JSON metadata into Key/Value array for the UI
|
const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
|
||||||
// We filter out 'type' and 'mimeType' as they are system-managed
|
.filter(([key]) => !['type', 'mimeType', 'magicFilled', 'details'].includes(key))
|
||||||
const initialMetadata = Object.entries(fileNode.metadata || {})
|
.map(([key, value]) => ({
|
||||||
.filter(([key]) => !['type', 'mimeType'].includes(key))
|
key,
|
||||||
.map(([key, value]) => ({ key, value: String(value) }));
|
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();
|
||||||
|
|
@ -44,9 +99,10 @@ export default function UpdateView({ fileNode, folders }: any) {
|
||||||
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.key.trim()) acc[curr.key.trim()] = curr.value;
|
if (curr.selected && curr.key.trim()) {
|
||||||
|
acc[curr.key.trim()] = curr.value;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string>);
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
|
@ -64,105 +120,88 @@ export default function UpdateView({ fileNode, folders }: any) {
|
||||||
|
|
||||||
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 }}>
|
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>Back</Button>
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
|
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>Edit File Details</Typography>
|
||||||
Edit File Details
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack spacing={4} sx={{ mt: 2 }}>
|
<Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
|
||||||
{/* Name Field */}
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
<TextField
|
<Box>
|
||||||
label="File Name"
|
<Typography variant="subtitle1" fontWeight="bold">Enrich Metadata</Typography>
|
||||||
fullWidth value={name}
|
<Typography variant="caption" color="text.secondary">Extract GPS, Camera Specs, and Dimensions.</Typography>
|
||||||
onChange={(e) => setName(e.target.value)}
|
</Box>
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
<Button
|
||||||
/>
|
variant="contained" color="secondary"
|
||||||
|
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
|
||||||
{/* Folder Select */}
|
onClick={handleMagicEnhance} disabled={isExtracting}
|
||||||
<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 }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{isExtracting ? 'Extracting...' : 'Magic Fill'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<TextField label="File Name" fullWidth value={name} onChange={(e) => setName(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
|
||||||
|
|
||||||
|
<TextField select fullWidth label="Destination" value={parentId} onChange={(e) => setParentId(e.target.value)}>
|
||||||
<MenuItem value=""><em>-- Root --</em></MenuItem>
|
<MenuItem value=""><em>-- Root --</em></MenuItem>
|
||||||
{folders.map((f: any) => (
|
{availablefolders?.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>
|
||||||
|
|
||||||
{/* Custom Metadata Section */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Stack direction="row" justifyContent="space-between" mb={2}>
|
||||||
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="h6" fontWeight="700"><AssignmentIcon /> Attributes</Typography>
|
||||||
<AssignmentIcon color="primary" /> Custom Attributes
|
<Button startIcon={<AddCircleOutlineIcon />} onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}>Add Field</Button>
|
||||||
</Typography>
|
</Stack>
|
||||||
<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) => (
|
||||||
<Grid container spacing={1} key={index} alignItems="center">
|
<Box key={index}>
|
||||||
<Grid item xs={5}>
|
<Grid container spacing={1} alignItems="center">
|
||||||
<TextField
|
<Grid item xs={1}>
|
||||||
fullWidth size="small" placeholder="Key"
|
<Checkbox checked={row.selected} onChange={(e) => {
|
||||||
value={row.key}
|
const updated = [...customMetadata];
|
||||||
|
updated[index].selected = e.target.checked;
|
||||||
|
setCustomMetadata(updated);
|
||||||
|
}} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<TextField fullWidth size="small" value={row.key} disabled={row.isPending} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||||
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
|
<TextField fullWidth size="small" value={row.value} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
|
||||||
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))}>
|
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}><DeleteOutlineIcon /></IconButton>
|
||||||
<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
|
<TextField label="Description" multiline rows={3} fullWidth value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
label="Description"
|
|
||||||
multiline rows={4} fullWidth
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button variant="contained" size="large" fullWidth startIcon={<SaveIcon />} onClick={handleUpdate} disabled={loading}>
|
||||||
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,14 +1,24 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
// src/app/upload/_actions.ts
|
||||||
import { getFreshAccessToken } from "@/lib/auth-utils";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
|
||||||
/**
|
/**
|
||||||
* 1. CREATE FOLDER: Virtual Only
|
* NEW: CHECK FOR DUPLICATE HASH
|
||||||
* 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.findUnique({
|
||||||
|
where: { hash },
|
||||||
|
select: { name: true }
|
||||||
|
});
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 1. CREATE VIRTUAL FOLDER
|
||||||
*/
|
*/
|
||||||
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();
|
||||||
|
|
@ -16,40 +26,33 @@ export async function createFolderAction(name: string, parentId?: string | null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const internalId = crypto.randomUUID();
|
const internalId = crypto.randomUUID();
|
||||||
|
// Swapped createNode for createFileNode
|
||||||
const newNode = await prisma.fileNode.create({
|
const newNode = await createFileNode({
|
||||||
data: {
|
|
||||||
id: internalId,
|
id: internalId,
|
||||||
oneDriveId: null, // Virtual folders do not have a cloud ID
|
oneDriveId: null,
|
||||||
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
|
* 2. UPLOAD FILE (Physical UUID Folder)
|
||||||
* 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;
|
||||||
|
|
@ -59,62 +62,31 @@ 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(); // This UUID will be the OneDrive folder name
|
const internalId = crypto.randomUUID(); // Used for both DB ID and OneDrive Folder Name
|
||||||
|
|
||||||
// 1. Create the Physical Storage Folder on OneDrive
|
try {
|
||||||
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
|
// A. Ensure root exists
|
||||||
method: "POST",
|
await ensureOneDriveFolder(session.user.id, rootFolder);
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: internalId,
|
|
||||||
folder: {},
|
|
||||||
"@microsoft.graph.conflictBehavior": "fail"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createSubFolderRes.ok) {
|
// B. Create the physical UUID folder on OneDrive
|
||||||
const errorData = await createSubFolderRes.json();
|
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
|
||||||
throw new Error(errorData.error?.message || "Storage directory creation failed");
|
const subFolderData = await subFolderRes.json();
|
||||||
}
|
|
||||||
const subFolderData = await createSubFolderRes.json();
|
|
||||||
|
|
||||||
// 2. Create Upload Session inside the new Physical Folder
|
// C. Upload the file binary into that specific folder
|
||||||
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
|
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
|
||||||
const sessionRes = await fetch(sessionUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
|
|
||||||
});
|
|
||||||
|
|
||||||
const { uploadUrl } = await sessionRes.json();
|
// D. Create record in Database
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
// src/app/upload/_actions.ts
|
||||||
|
|
||||||
// 3. PUT the file binary
|
// ... inside uploadFileAction or createFolderAction ...
|
||||||
const uploadRes = await fetch(uploadUrl, {
|
// ... inside uploadFileAction after OneDrive work is done ...
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Length": `${file.size}`,
|
|
||||||
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
|
|
||||||
},
|
|
||||||
body: buffer
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadedFileData = await uploadRes.json();
|
await createFileNode({
|
||||||
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: oneDriveId,
|
oneDriveId: uploadedFileData.id,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
hash:hash,
|
||||||
description: description,
|
description: description,
|
||||||
size: BigInt(file.size),
|
size: BigInt(file.size),
|
||||||
isFolder: false,
|
isFolder: false,
|
||||||
|
|
@ -122,14 +94,105 @@ export async function uploadFileAction(formData: FormData) {
|
||||||
ownerId: session.user.id,
|
ownerId: session.user.id,
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
metadata: {
|
metadata: {
|
||||||
...customMetadata,
|
...customMetadata, // User's custom keys from the form
|
||||||
type: extension,
|
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
|
||||||
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* NEW: FETCH ALL VIRTUAL FOLDERS
|
||||||
|
* This is the exact export the compiler is looking for.
|
||||||
|
*/
|
||||||
|
export async function getFoldersAction() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This calls the helper in your data-access/file-nodes.ts
|
||||||
|
const folders = await getAllFolders();
|
||||||
|
return folders;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in getFoldersAction:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* EXECUTE BULK ITEM
|
||||||
|
* Orchestrates the physical upload to OneDrive and the database record
|
||||||
|
* creation/update. This is the heart of the Bulk Upload and Restore system.
|
||||||
|
*/
|
||||||
|
export async function executeBulkItemAction(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
// 1. Security check
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Standardize the target folder ID
|
||||||
|
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";
|
||||||
|
// We generate a unique internal ID to serve as the physical folder name on OneDrive
|
||||||
|
const internalId = crypto.randomUUID();
|
||||||
|
|
||||||
|
// 2. Physical Storage (OneDrive)
|
||||||
|
// Ensure the root app folder exists first
|
||||||
|
await ensureOneDriveFolder(session.user.id, rootFolder);
|
||||||
|
|
||||||
|
// Create the unique subfolder for this file (to avoid collisions and match single-upload logic)
|
||||||
|
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
|
||||||
|
const subFolderData = await subFolderRes.json();
|
||||||
|
|
||||||
|
// Perform the actual binary upload
|
||||||
|
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
|
||||||
|
|
||||||
|
// 3. Database Logic (Data Access Layer)
|
||||||
|
// We use upsertFileNodeByHash to handle the Disaster Recovery case:
|
||||||
|
// If the hash matches an existing record, it updates the OneDrive link.
|
||||||
|
// If not, it creates a brand new record.
|
||||||
|
const result = await upsertFileNodeByHash({
|
||||||
|
name: file.name,
|
||||||
|
hash: hash,
|
||||||
|
oneDriveId: uploadedFileData.id,
|
||||||
|
parentId: targetFolderId,
|
||||||
|
size: BigInt(file.size),
|
||||||
|
ownerId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Refresh UI
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
id: result.id,
|
||||||
|
mode: result.createdAt === result.updatedAt ? 'created' : 'restored'
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Bulk Item Execution Failure:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message || "An unexpected error occurred during upload."
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
243
src/app/upload/bulk/page.tsx
Normal file
243
src/app/upload/bulk/page.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"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,25 +1,181 @@
|
||||||
import { auth } from "@/auth";
|
// src/app/upload/bulk/page.tsx
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import UploadView from "./upload-view";
|
|
||||||
import { Container } from "@mui/material";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export default async function UploadPage() {
|
"use client";
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session) redirect("/");
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import {
|
||||||
|
Box, Button, Typography, Paper, Table, TableBody,
|
||||||
|
TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
|
||||||
|
Alert, Stack
|
||||||
|
} from '@mui/material';
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
|
||||||
// Fetch only folders so the user can select a destination
|
// --- OUR UTILITIES ---
|
||||||
const folders = await prisma.fileNode.findMany({
|
import { calculateFileHash } from '@/lib/hashing-client';
|
||||||
where: { isFolder: true },
|
import { checkDuplicateAction } from '../_actions';
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
select: { id: true, name: true, parentId: true }
|
interface UploadQueueItem {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
path: string;
|
||||||
|
hash: string | null;
|
||||||
|
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkUploadPage() {
|
||||||
|
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
// 1. Handle File & Folder Drops
|
||||||
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
const newItems = acceptedFiles.map(file => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
path: (file as any).path || file.name, // Captures subfolder structure
|
||||||
|
hash: null,
|
||||||
|
status: 'queued' as const,
|
||||||
|
}));
|
||||||
|
setQueue(prev => [...prev, ...newItems]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
||||||
|
|
||||||
|
// 2. The "Processing Engine"
|
||||||
|
// This effect runs whenever the queue changes or isProcessing toggles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isProcessing) return;
|
||||||
|
|
||||||
|
const runQueue = async () => {
|
||||||
|
// Find the next file that hasn't been hashed/checked yet
|
||||||
|
const nextIndex = queue.findIndex(item => item.status === 'queued');
|
||||||
|
|
||||||
|
if (nextIndex === -1) {
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = queue[nextIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step A: Hashing
|
||||||
|
updateItem(item.id, { status: 'hashing' });
|
||||||
|
const hash = await calculateFileHash(item.file);
|
||||||
|
|
||||||
|
// Step B: Duplicate Check
|
||||||
|
updateItem(item.id, { status: 'checking', hash });
|
||||||
|
const existing = await checkDuplicateAction(hash);
|
||||||
|
|
||||||
|
// Step C: Mark Results
|
||||||
|
updateItem(item.id, {
|
||||||
|
status: existing ? 'duplicate' : 'ready'
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
updateItem(item.id, { status: 'error', error: 'Process failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runQueue();
|
||||||
|
}, [queue, isProcessing]);
|
||||||
|
|
||||||
|
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
|
||||||
|
setQueue(current => current.map(item => item.id === id ? { ...item, ...updates } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateCount = queue.filter(i => i.status === 'duplicate').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
|
||||||
{/* Pass folders to the view */}
|
<Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
|
||||||
<UploadView user={session.user} folders={folders} />
|
Bulk Uploads & Restore
|
||||||
</Container>
|
</Typography>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
p: 6, mb: 4, textAlign: 'center', cursor: 'pointer',
|
||||||
|
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
|
||||||
|
bgcolor: isDragActive ? 'primary.50' : 'background.paper',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
|
||||||
|
<FolderIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||||
|
<Typography variant="h6">Drop Folders or Files Here</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Perfect for camera imports or full system restores
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{duplicateCount > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||||
|
{duplicateCount} duplicate(s) found. These files already exist in your library.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<TableContainer component={Paper} sx={{ maxHeight: 500, borderRadius: 2 }}>
|
||||||
|
<Table stickyHeader size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Location / Path</TableCell>
|
||||||
|
<TableCell>Size</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>SHA-256 Hash</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{queue.map((item) => (
|
||||||
|
<TableRow key={item.id} hover>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{item.path}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{(item.file.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={item.status.toUpperCase()}
|
||||||
|
size="small"
|
||||||
|
color={
|
||||||
|
item.status === 'duplicate' ? 'warning' :
|
||||||
|
item.status === 'ready' ? 'info' :
|
||||||
|
item.status === 'success' ? 'success' : 'default'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
|
||||||
|
{item.hash ? `${item.hash.substring(0, 16)}...` : '---'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mt: 4 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={isProcessing ? <LinearProgress sx={{ width: 20 }} /> : <CloudUploadIcon />}
|
||||||
|
disabled={isProcessing || queue.length === 0}
|
||||||
|
onClick={() => setIsProcessing(true)}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Analyzing...' : `Analyze ${queue.length} Files`}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => setQueue([])}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,251 +1,345 @@
|
||||||
'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, MenuItem, IconButton, Divider,
|
TextField, IconButton, Divider,
|
||||||
Grid, CircularProgress
|
Grid,
|
||||||
|
CircularProgress, Checkbox, MenuItem,
|
||||||
|
Collapse,
|
||||||
|
Dialog, DialogTitle, DialogContent,
|
||||||
|
DialogContentText, DialogActions
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||||
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 { useRouter } from "next/navigation";
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
import { uploadFileAction, createFolderAction } from "./_actions";
|
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';
|
||||||
|
|
||||||
interface MetadataPair {
|
import { useRouter } from "next/navigation";
|
||||||
|
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
|
||||||
|
import { calculateFileHash } from "@/lib/hashing-client";
|
||||||
|
import { uploadFileAction, createFolderAction, checkDuplicateAction } from "./_actions";
|
||||||
|
|
||||||
|
interface MetadataRow {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
isPending?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UploadView({ user, folders = [] }: any) {
|
export default function UploadView({ folders }: { user: any; folders: any[] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
// Form State
|
||||||
const [description, setDescription] = useState("");
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [parentId, setParentId] = useState("");
|
const [targetFolderId, setTargetFolderId] = useState<string>("");
|
||||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
|
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||||
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
|
|
||||||
|
|
||||||
// Folder Creation State
|
|
||||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
const [rows, setRows] = useState<MetadataRow[]>([]);
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
// UI Status State
|
||||||
if (!newFolderName.trim()) return;
|
const [isExtracting, setIsExtracting] = useState(false);
|
||||||
setIsCreatingFolder(true);
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'hashing' | 'saving'>('idle');
|
||||||
|
|
||||||
|
// Duplicate Dialog State
|
||||||
|
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
const [duplicateInfo, setDuplicateInfo] = useState<{ name: string; hash: string } | null>(null);
|
||||||
|
|
||||||
|
const canSubmit = selectedFile !== null || newFolderName.trim().length > 0;
|
||||||
|
|
||||||
|
// --- 1. MAGIC EXTRACTION ---
|
||||||
|
const handleMagicEnhance = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
setIsExtracting(true);
|
||||||
try {
|
try {
|
||||||
// FIX: Pass the current parentId to the action so it nests correctly
|
const result = await getMetadataPreviewAction(selectedFile.name);
|
||||||
const result = await createFolderAction(newFolderName, parentId);
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setNewFolderName("");
|
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
|
||||||
setShowFolderInput(false);
|
key: k,
|
||||||
router.refresh();
|
value: typeof v === 'object' ? JSON.stringify(v) : String(v),
|
||||||
|
isPending: true,
|
||||||
|
selected: true
|
||||||
|
}));
|
||||||
|
setRows(prev => {
|
||||||
|
const existingKeys = new Set(prev.map(r => r.key));
|
||||||
|
const newUniqueRows = extractedRows.filter(r => !existingKeys.has(r.key));
|
||||||
|
return [...prev, ...newUniqueRows];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
alert(err.message || "Failed to create folder");
|
console.error("Extraction failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingFolder(false);
|
setIsExtracting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
const removeMetadataRow = (index: number) => {
|
if (file) setSelectedFile(file);
|
||||||
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
|
// --- 2. UPLOAD EXECUTION ---
|
||||||
const updated = [...customMetadata];
|
const executeUpload = async (preCalculatedHash?: string) => {
|
||||||
updated[index][field] = val;
|
setSaveStatus('saving');
|
||||||
setCustomMetadata(updated);
|
try {
|
||||||
};
|
let currentParentId = targetFolderId;
|
||||||
|
|
||||||
const handleUpload = async () => {
|
// STEP A: Handle New Folder Creation
|
||||||
if (!file) return;
|
if (newFolderName.trim()) {
|
||||||
setStatus('uploading');
|
const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
|
||||||
|
if (folderResult.success) {
|
||||||
|
currentParentId = folderResult.node.id;
|
||||||
|
} else {
|
||||||
|
throw new Error(folderResult.error || "Failed to create folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP B: Handle File Upload
|
||||||
|
if (selectedFile) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", selectedFile);
|
||||||
formData.append("description", description);
|
formData.append("hash", preCalculatedHash || "");
|
||||||
formData.append("parentId", parentId);
|
formData.append("parentId", currentParentId || "root");
|
||||||
|
|
||||||
const metadataObj = customMetadata.reduce((acc, curr) => {
|
const metadataObject = rows
|
||||||
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
.filter(r => r.selected && r.key.trim() !== "")
|
||||||
|
.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(metadataObj));
|
formData.append("customMetadata", JSON.stringify(metadataObject));
|
||||||
|
|
||||||
|
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.");
|
|
||||||
setStatus('idle');
|
// --- 3. SAVE HANDLER (With Hash Intercept) ---
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
if (selectedFile) {
|
||||||
|
setSaveStatus('hashing');
|
||||||
|
// Calculate local SHA-256
|
||||||
|
const fileHash = await calculateFileHash(selectedFile);
|
||||||
|
// Check database via Server Action
|
||||||
|
const duplicate = await checkDuplicateAction(fileHash);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
setDuplicateInfo({ name: duplicate.name, hash: fileHash });
|
||||||
|
setDuplicateDialogOpen(true);
|
||||||
|
return; // Dialog takes over from here
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeUpload(fileHash);
|
||||||
|
} else {
|
||||||
|
await executeUpload(); // Folder only
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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">
|
||||||
Add to Library
|
Upload & Enrich
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={4} sx={{ mt: 4 }}>
|
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
|
||||||
{/* 1. Destination */}
|
{/* FOLDER SELECTION */}
|
||||||
<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
|
||||||
id="project-destination-select"
|
select fullWidth label="Parent Destination"
|
||||||
select
|
value={targetFolderId}
|
||||||
fullWidth
|
onChange={(e) => setTargetFolderId(e.target.value)}
|
||||||
label="Target Project / Folder"
|
helperText="Choose where your file will live"
|
||||||
value={parentId}
|
|
||||||
onChange={(e) => setParentId(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
slotProps={{
|
|
||||||
select: { displayEmpty: true },
|
|
||||||
inputLabel: { shrink: true },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
|
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
|
||||||
{folders.map((f: any) => (
|
{folders?.map((f) => (
|
||||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<IconButton
|
<Button
|
||||||
color="primary"
|
variant={showNewFolderInput ? "contained" : "outlined"}
|
||||||
onClick={() => setShowFolderInput(!showFolderInput)}
|
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
||||||
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
|
sx={{ height: 56, minWidth: 56 }}
|
||||||
>
|
>
|
||||||
<CreateNewFolderIcon />
|
<CreateNewFolderIcon />
|
||||||
</IconButton>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{showFolderInput && (
|
<Collapse in={showNewFolderInput}>
|
||||||
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}>
|
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
{parentId ? `Create inside current selection` : `Create at Root`}
|
NEW SUB-FOLDER NAME
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)"
|
fullWidth size="small" placeholder="e.g. Finance 2026"
|
||||||
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="contained"
|
variant="outlined" fullWidth startIcon={<CloudUploadIcon />}
|
||||||
onClick={handleCreateFolder}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isCreatingFolder || !newFolderName}
|
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
|
||||||
>
|
>
|
||||||
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
|
Select File to Upload
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50' }}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<CloudUploadIcon color="primary" />
|
||||||
|
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
{/* MAGIC EXTRACT */}
|
||||||
|
<Box sx={{ mb: 4, p: 2.5, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold" color="primary.main">Magic Extract</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Auto-pull metadata from file content.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained" onClick={handleMagicEnhance}
|
||||||
|
disabled={!selectedFile || isExtracting}
|
||||||
|
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
|
||||||
|
sx={{ borderRadius: 20, px: 3 }}
|
||||||
|
>
|
||||||
|
{isExtracting ? "Extracting..." : "Run"}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 2. Upload Area */}
|
{/* METADATA PREVIEW */}
|
||||||
<Box>
|
<Box sx={{ mb: 4 }}>
|
||||||
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CloudUploadIcon color="primary" /> 2. Upload File
|
<AssignmentIcon color="primary" /> Metadata Fields
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
{file ? (
|
|
||||||
<Box>
|
|
||||||
<Typography color="success.main" fontWeight="bold">✓ {file.name}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
"Click to Select File"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 3. Custom Attributes */}
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
|
||||||
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<AssignmentIcon color="primary" /> 3. Custom Attributes
|
|
||||||
</Typography>
|
|
||||||
<Button startIcon={<AddCircleOutlineIcon />} size="small" onClick={addMetadataRow}>
|
|
||||||
Add Field
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{customMetadata.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<Grid container spacing={1} key={index} alignItems="center">
|
<Grid container spacing={1} key={index} alignItems="center">
|
||||||
<Grid item xs={5}>
|
<Grid item xs={1}>
|
||||||
<TextField
|
<Checkbox checked={row.selected} size="small" onChange={(e) => {
|
||||||
fullWidth size="small" placeholder="Key (e.g. Project-ID)"
|
const updated = [...rows];
|
||||||
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
|
updated[index].selected = e.target.checked;
|
||||||
/>
|
setRows(updated);
|
||||||
|
}} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6}>
|
<Grid item xs={5}>
|
||||||
<TextField
|
<TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
|
||||||
fullWidth size="small" placeholder="Value"
|
const updated = [...rows];
|
||||||
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)}
|
updated[index].key = e.target.value;
|
||||||
/>
|
setRows(updated);
|
||||||
|
}} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={5}>
|
||||||
|
<TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
|
||||||
|
const updated = [...rows];
|
||||||
|
updated[index].value = e.target.value;
|
||||||
|
setRows(updated);
|
||||||
|
}} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={1}>
|
<Grid item xs={1}>
|
||||||
<IconButton color="error" onClick={() => removeMetadataRow(index)}>
|
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
|
||||||
<DeleteOutlineIcon />
|
<DeleteOutlineIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
|
||||||
<TextField
|
Add Manual Field
|
||||||
label="General Description"
|
</Button>
|
||||||
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
|
variant="contained" size="large" fullWidth onClick={handleSave}
|
||||||
disabled={!file || status === 'uploading'}
|
disabled={!canSubmit || saveStatus !== 'idle'}
|
||||||
onClick={handleUpload}
|
sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
|
||||||
sx={{ py: 2, fontWeight: 'bold' }}
|
|
||||||
>
|
>
|
||||||
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'}
|
{saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
|
||||||
|
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,3 +1,4 @@
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
|
@ -9,20 +10,10 @@ export default {
|
||||||
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
|
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
|
||||||
authorization: {
|
authorization: {
|
||||||
params: {
|
params: {
|
||||||
scope: "openid profile offline_access email Files.ReadWrite Files.Read",
|
// offline_access is vital for getting the refresh_token
|
||||||
prompt: "consent", // Forces Microsoft to show the permission screen
|
scope: "openid profile email offline_access Files.ReadWrite Files.Read",
|
||||||
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,22 +7,18 @@ 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,
|
...authConfig, // This now spreads the default export from auth.config.ts
|
||||||
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 - 'role' is a custom field in your Postgres User table
|
// @ts-ignore
|
||||||
token.role = user.role;
|
token.role = user.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,16 +26,10 @@ 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;
|
||||||
|
|
@ -50,7 +40,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 in linkAccount event!");
|
console.warn("⚠️ WARNING: No refresh_token received!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/components/LogoutButton.tsx
Normal file
18
src/components/LogoutButton.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
'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,6 +9,7 @@ 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';
|
||||||
|
|
@ -44,6 +45,7 @@ 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,12 +1,19 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
// ... other imports like ThemeProvider
|
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import theme from '@/lib/theme'; // Import your custom theme here
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<AppRouterCacheProvider>
|
<AppRouterCacheProvider options={{ enableCssLayer: true }}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
|
{/* CssBaseline resets browser styles to match MUI and your theme */}
|
||||||
|
<CssBaseline />
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppRouterCacheProvider>
|
</AppRouterCacheProvider>
|
||||||
|
|
|
||||||
190
src/data-access/file-nodes.ts
Normal file
190
src/data-access/file-nodes.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FETCH: Retrieve all nodes for the dashboard.
|
||||||
|
* Centralizing this here allows us to change sort order or filters
|
||||||
|
* in one place for the entire application.
|
||||||
|
*/
|
||||||
|
export async function getAllFileNodes() {
|
||||||
|
return await prisma.fileNode.findMany({
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FETCH: Get a single node by ID.
|
||||||
|
* Used by the Download route and Update pages to verify a file exists.
|
||||||
|
*/
|
||||||
|
export async function getFileNodeById(id: string) {
|
||||||
|
return await prisma.fileNode.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPDATE: Modify metadata, name, or virtual location.
|
||||||
|
* This function accepts the data object to keep the DAL flexible.
|
||||||
|
*/
|
||||||
|
export async function updateFileNode(id: string, data: any) {
|
||||||
|
return await prisma.fileNode.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: Remove the record from the database.
|
||||||
|
* Cloud deletion should be handled by the Service Layer before calling this.
|
||||||
|
*/
|
||||||
|
export async function deleteFileNode(id: string) {
|
||||||
|
return await prisma.fileNode.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MASTER CREATE: Handles both standard uploads and virtual folders.
|
||||||
|
* If no ID is provided, it generates a fresh UUID.
|
||||||
|
*/
|
||||||
|
export async function createFileNode(data: {
|
||||||
|
id?: string; // Optional: used for virtual folders/UUID storage
|
||||||
|
oneDriveId: string | null;
|
||||||
|
name: string;
|
||||||
|
hash?: string | null; // ✅ ADDED: For SHA-256 duplicate prevention
|
||||||
|
description?: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
path: string;
|
||||||
|
ownerId: string;
|
||||||
|
parentId?: string | null; // Optional: for nested structures
|
||||||
|
size?: bigint;
|
||||||
|
metadata: any;
|
||||||
|
}) {
|
||||||
|
return await prisma.fileNode.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
id: data.id ?? crypto.randomUUID(), // Use provided ID or generate new one
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ... other functions (getAllFileNodes, etc)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPSERT: Create or Update a file node based on OneDrive ID
|
||||||
|
* Moved here because it interacts with the Database.
|
||||||
|
*/
|
||||||
|
export async function upsertFileNode(oneDriveId: string, data: {
|
||||||
|
name: string;
|
||||||
|
size: bigint;
|
||||||
|
isFolder: boolean;
|
||||||
|
path: string;
|
||||||
|
ownerId: string;
|
||||||
|
metadata: any;
|
||||||
|
hash?: string | null; // ✅ ADDED: Keep hash in sync during upserts
|
||||||
|
}) {
|
||||||
|
return await prisma.fileNode.upsert({
|
||||||
|
where: { oneDriveId },
|
||||||
|
update: {
|
||||||
|
name: data.name,
|
||||||
|
size: data.size,
|
||||||
|
isFolder: data.isFolder,
|
||||||
|
path: data.path,
|
||||||
|
hash: data.hash, // ✅ ADDED
|
||||||
|
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, // ✅ ADDED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FETCH: Get all virtual folders for selection in dropdowns.
|
||||||
|
* Used by the BulkUpload page to set destinations.
|
||||||
|
*/
|
||||||
|
export async function getAllFolders() {
|
||||||
|
return await prisma.fileNode.findMany({
|
||||||
|
where: {
|
||||||
|
isFolder: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPSERT BY HASH: The core of the Disaster Recovery process.
|
||||||
|
* If a hash exists, we update the OneDrive ID (Restoring the link).
|
||||||
|
* If not, we create a new entry.
|
||||||
|
*/
|
||||||
|
export async function upsertFileNodeByHash(data: {
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
oneDriveId: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
size: bigint;
|
||||||
|
ownerId: string;
|
||||||
|
mimeType?: string;
|
||||||
|
}) {
|
||||||
|
// First, check if we have a record with this hash
|
||||||
|
const existing = await prisma.fileNode.findFirst({
|
||||||
|
where: { hash: data.hash }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// 🛡️ DISASTER RECOVERY MODE
|
||||||
|
// The database knows about this file, but the OneDrive link is old.
|
||||||
|
// We update the existing record with the NEW cloud ID.
|
||||||
|
return await prisma.fileNode.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
oneDriveId: data.oneDriveId,
|
||||||
|
// Optional: Update parent if the user chose a new folder during restore
|
||||||
|
parentId: data.parentId || existing.parentId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✨ NEW UPLOAD MODE
|
||||||
|
return 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: data.name, // Simplified for now
|
||||||
|
metadata: {}, // Placeholder for extractMetadata logic
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
42
src/data-access/users.ts
Normal file
42
src/data-access/users.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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,3 +1,4 @@
|
||||||
|
// 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) {
|
||||||
|
|
@ -11,7 +12,6 @@ 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,12 +30,17 @@ 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) throw tokens;
|
if (!response.ok) {
|
||||||
|
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({
|
||||||
|
|
@ -43,6 +48,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -50,6 +56,7 @@ 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
src/lib/hashing-client.ts
Normal file
9
src/lib/hashing-client.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// 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('');
|
||||||
|
}
|
||||||
9
src/lib/hashing.ts
Normal file
9
src/lib/hashing.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
83
src/lib/metadata-extractor-old-2.ts
Normal file
83
src/lib/metadata-extractor-old-2.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/lib/metadata-extractor-old.ts
Normal file
145
src/lib/metadata-extractor-old.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/lib/metadata-extractor.ts
Normal file
117
src/lib/metadata-extractor.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// 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,7 +1,6 @@
|
||||||
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'],
|
||||||
|
|
@ -9,39 +8,36 @@ 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', // Professional Blue
|
main: '#1976d2',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: '#9c27b0', // Purple for accents
|
main: '#9c27b0',
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: '#f4f6f8', // Light grey for the app background
|
default: '#f4f6f8',
|
||||||
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', // Prevents all-caps buttons
|
textTransform: 'none',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Keep your paper styles
|
||||||
MuiPaper: {
|
MuiPaper: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
|
|
@ -52,6 +48,12 @@ const theme = createTheme({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ADD THIS: Ensures the theme is compatible with MUI v7's Grid logic
|
||||||
|
MuiStack: {
|
||||||
|
defaultProps: {
|
||||||
|
useFlexGap: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
111
src/lib/transformers.ts
Normal file
111
src/lib/transformers.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
};
|
||||||
25
src/services/metadata-service.ts
Normal file
25
src/services/metadata-service.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
224
src/services/onedrive.ts
Normal file
224
src/services/onedrive.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
// 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