Compare commits

..

No commits in common. "main" and "V7.0" have entirely different histories.
main ... V7.0

18 changed files with 526 additions and 3091 deletions

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -352,21 +352,3 @@ Project-1 ID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1 parentID 0371
AS-NZS3000-2018.pdf parentID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1 AS-NZS3000-2018.pdf parentID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1
Now we will go now and add metadata Now we will go now and add metadata
Now we have the metadata along with some great filters on the data board page
Now we are going to implement an an update of a file or folder stored. 12/1/2026
For download there 2 function we need to implement
1) down load the file to ~/user/Downloads, this should work for any file type including pdf
2) if the file is a pdf open the file in another tab in the browser as most browser support reading a pdf
For download there 2 function we need to implement
1) down load the file to ~/user/Downloads, this should work for any file type including pdf
2) if the file is a pdf open the file in another tab in the browser as most browser support reading a pdf
So we need to implement 2 new actions, downLoad file and open pdf to read. Does this make sense ?

Binary file not shown.

102
package-lock.json generated
View file

@ -13,9 +13,9 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6", "@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.7", "@mui/material": "^7.3.6",
"@mui/material-nextjs": "^7.3.6", "@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid": "^8.24.0", "@mui/x-data-grid": "^8.23.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"next": "16.1.1", "next": "16.1.1",
@ -1333,9 +1333,9 @@
} }
}, },
"node_modules/@mui/core-downloads-tracker": { "node_modules/@mui/core-downloads-tracker": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz",
"integrity": "sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg==", "integrity": "sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -1369,22 +1369,22 @@
} }
}, },
"node_modules/@mui/material": { "node_modules/@mui/material": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz",
"integrity": "sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A==", "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7", "@mui/core-downloads-tracker": "^7.3.6",
"@mui/system": "^7.3.7", "@mui/system": "^7.3.6",
"@mui/types": "^7.4.10", "@mui/types": "^7.4.9",
"@mui/utils": "^7.3.7", "@mui/utils": "^7.3.6",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csstype": "^3.2.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.2.3", "react-is": "^19.2.0",
"react-transition-group": "^4.4.5" "react-transition-group": "^4.4.5"
}, },
"engines": { "engines": {
@ -1397,7 +1397,7 @@
"peerDependencies": { "peerDependencies": {
"@emotion/react": "^11.5.0", "@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^7.3.7", "@mui/material-pigment-css": "^7.3.6",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@ -1459,13 +1459,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mui/private-theming": { "node_modules/@mui/private-theming": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.6.tgz",
"integrity": "sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw==", "integrity": "sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.7", "@mui/utils": "^7.3.6",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@ -1486,16 +1486,16 @@
} }
}, },
"node_modules/@mui/styled-engine": { "node_modules/@mui/styled-engine": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.6.tgz",
"integrity": "sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg==", "integrity": "sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3", "@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0", "@emotion/sheet": "^1.4.0",
"csstype": "^3.2.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@ -1520,18 +1520,18 @@
} }
}, },
"node_modules/@mui/system": { "node_modules/@mui/system": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz",
"integrity": "sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ==", "integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7", "@mui/private-theming": "^7.3.6",
"@mui/styled-engine": "^7.3.7", "@mui/styled-engine": "^7.3.6",
"@mui/types": "^7.4.10", "@mui/types": "^7.4.9",
"@mui/utils": "^7.3.7", "@mui/utils": "^7.3.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csstype": "^3.2.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@ -1560,9 +1560,9 @@
} }
}, },
"node_modules/@mui/types": { "node_modules/@mui/types": {
"version": "7.4.10", "version": "7.4.9",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.10.tgz", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz",
"integrity": "sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ==", "integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
@ -1577,17 +1577,17 @@
} }
}, },
"node_modules/@mui/utils": { "node_modules/@mui/utils": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz",
"integrity": "sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg==", "integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.10", "@mui/types": "^7.4.9",
"@types/prop-types": "^15.7.15", "@types/prop-types": "^15.7.15",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.2.3" "react-is": "^19.2.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@ -1613,15 +1613,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mui/x-data-grid": { "node_modules/@mui/x-data-grid": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.23.0.tgz",
"integrity": "sha512-OCTrskRlxwJZ+vYWbgQnQPXWNMA/nY/RIkoESjbCG38N9fJicVCdyjszjcQn9V1NQH4fwkMlIN+DVU6zzbqJxQ==", "integrity": "sha512-fv8AiibTgwPKJsXtYdc5bYDDW3EQp/ieQ0iUejvZ2JbYikMIPTYjI4pHy7zW3F3RDsi6DGQnw5AW6LeoDcT0fA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5", "@mui/utils": "^7.3.5",
"@mui/x-internals": "8.24.0", "@mui/x-internals": "8.23.0",
"@mui/x-virtualizer": "0.3.1", "@mui/x-virtualizer": "0.3.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
@ -1651,9 +1651,9 @@
} }
}, },
"node_modules/@mui/x-internals": { "node_modules/@mui/x-internals": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz",
"integrity": "sha512-tHPk8eUcuwvhNsAc4zcia8y+issjC+bLGssrQQSdLLeLIf9ewFbcwAURNTSnNhreyj/4RJOemVrSxKXpjkdVvQ==", "integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
@ -1673,14 +1673,14 @@
} }
}, },
"node_modules/@mui/x-virtualizer": { "node_modules/@mui/x-virtualizer": {
"version": "0.3.1", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.0.tgz",
"integrity": "sha512-MhQ7U/zmSofBxeCRMiolhZWJ80RUJMFFMRqeehJu5GqscnudiVcplZb0eNsAkz9IjnJTyrjyLS0PY+kn78enOw==", "integrity": "sha512-ScQ3xullKQAtbIT9N7PVhgW9BDTo2Hu8fr/+N08TdRMJcq8bsVJB25mlcHk6zxjlgCuojqaeGfw71S1FOvyCXQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5", "@mui/utils": "^7.3.5",
"@mui/x-internals": "8.24.0" "@mui/x-internals": "8.23.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View file

@ -17,9 +17,9 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6", "@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.7", "@mui/material": "^7.3.6",
"@mui/material-nextjs": "^7.3.6", "@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid": "^8.24.0", "@mui/x-data-grid": "^8.23.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"next": "16.1.1", "next": "16.1.1",

View file

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

View file

@ -6,7 +6,7 @@ import { revalidatePath } from "next/cache";
import { getFreshAccessToken } from "@/lib/auth-utils"; import { getFreshAccessToken } from "@/lib/auth-utils";
/** /**
* 1. FETCH: Get all file nodes for the Dashboard * 1. FETCH: Get all file nodes
*/ */
export async function getFileNodes() { export async function getFileNodes() {
try { try {
@ -23,94 +23,83 @@ export async function getFileNodes() {
} }
/** /**
* 2. DOWNLOAD: Generates the authenticated OneDrive URL * 2. DELETE: Remove from OneDrive and Database
*/ * Improved to ensure DB removal even if OneDrive API returns errors.
export async function getDownloadUrlAction(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const file = await prisma.fileNode.findUnique({ where: { id } });
if (!file || !file.oneDriveId) throw new Error("File not found or missing cloud ID");
const accessToken = await getFreshAccessToken(session.user.id);
const res = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${file.oneDriveId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) throw new Error("Failed to contact OneDrive");
const data = await res.json();
const downloadUrl = data["@microsoft.graph.downloadUrl"];
if (!downloadUrl) throw new Error("OneDrive did not provide a download link");
return { downloadUrl };
}
/**
* 3. DELETE: Remove from OneDrive (via ID) and Database
* Folders are virtual (DB only), so cloud deletion is skipped if oneDriveId is null.
*/ */
export async function deleteFileAction(fileId: string) { export async function deleteFileAction(fileId: string) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
// 1. Find the node to check permissions
const node = await prisma.fileNode.findUnique({ const node = await prisma.fileNode.findUnique({
where: { id: fileId }, where: { id: fileId },
}); });
if (!node) { if (!node) {
// If not in DB, trigger a refresh anyway to clear the UI
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true, message: "Item already removed from database" };
} }
// @ts-ignore // @ts-ignore - role added via auth callbacks
const isAdmin = session.user.role === "ADMIN"; const isAdmin = session.user.role === "ADMIN";
const isOwner = node.ownerId === session.user.id; const isOwner = node.ownerId === session.user.id;
if (!isAdmin && !isOwner) { if (!isAdmin && !isOwner) {
throw new Error("Permission Denied."); throw new Error("Permission Denied: You do not have authority to delete this item.");
} }
if (node.isFolder && !isAdmin) {
throw new Error("Security Restriction: Only Administrators can delete folders.");
}
// 2. OneDrive Deletion Attempt
try { try {
const accessToken = await getFreshAccessToken(session.user.id); const accessToken = await getFreshAccessToken(session.user.id);
// Only attempt cloud deletion if it's a file/storage with a oneDriveId. if (accessToken) {
// Virtual folders created in the DB have no oneDriveId and are skipped. const rootFolder = "WebCalibre";
if (accessToken && node.oneDriveId) { // We target the folder on OneDrive by the ID stored in our DB
const onedriveRes = await fetch( const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}/${node.id}`;
`https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}`,
{ const onedriveRes = await fetch(onedrivePath, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
} });
);
if (!onedriveRes.ok && onedriveRes.status !== 404) { if (!onedriveRes.ok && onedriveRes.status !== 404) {
console.warn("OneDrive Deletion Warning: Cloud record might still exist."); const errData = await onedriveRes.json().catch(() => ({}));
console.warn("OneDrive Deletion Warning (Cloud record might still exist):", errData);
} }
} }
} catch (cloudError) { } catch (cloudError) {
console.error("Cloud cleanup failed:", cloudError); // We catch cloud errors but DO NOT throw them, so we can proceed to delete the DB record
console.error("Cloud communication failed, proceeding with DB cleanup:", cloudError);
} }
// 3. Database Deletion (The Source of Truth for your UI)
try { try {
await prisma.fileNode.delete({ where: { id: fileId } }); await prisma.fileNode.delete({
where: { id: fileId }
});
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/upload"); revalidatePath("/upload");
return { success: true }; return { success: true };
} catch (dbError) { } catch (dbError: any) {
console.error("Database deletion failed:", dbError);
throw new Error("Failed to remove the record from the database."); throw new Error("Failed to remove the record from the database.");
} }
} }
/** /**
* 4. MOVE: Assign file to folder or folder to another folder (Virtual Move) * 3. MOVE: Assign file to folder or folder to another folder
*/ */
export async function moveNodeAction(nodeId: string, newParentId: string | null) { export async function moveNodeAction(nodeId: string, newParentId: string | null) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
if (nodeId === newParentId) throw new Error("Cannot move to self."); if (nodeId === newParentId) throw new Error("Cannot move to self.");
try { try {
@ -118,69 +107,10 @@ export async function moveNodeAction(nodeId: string, newParentId: string | null)
where: { id: nodeId }, where: { id: nodeId },
data: { parentId: newParentId } data: { parentId: newParentId }
}); });
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
throw new Error("Move failed."); throw new Error("Move failed.");
} }
}
/**
* 5. UPDATE & REPLACE: Full update of metadata and OneDrive content
*/
export async function updateFileFullAction(formData: FormData) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const parentIdRaw = formData.get("parentId") as string;
const metadataStr = formData.get("metadata") as string;
const newFile = formData.get("file") as File | null;
const parentId = parentIdRaw === "root" ? null : parentIdRaw;
let metadata = JSON.parse(metadataStr);
try {
const accessToken = await getFreshAccessToken(session.user.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 (newFile && newFile.size > 0 && node?.oneDriveId) {
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}/content`;
const uploadRes = await fetch(onedrivePath, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": newFile.type
},
body: Buffer.from(await newFile.arrayBuffer()),
});
if (!uploadRes.ok) throw new Error("OneDrive content update failed");
metadata.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN';
metadata.mimeType = newFile.type;
}
await prisma.fileNode.update({
where: { id },
data: {
name,
description,
parentId,
metadata,
size: newFile ? BigInt(newFile.size) : undefined,
updatedAt: new Date(),
}
});
revalidatePath("/dashboard");
return { success: true };
} catch (error: any) {
console.error("Full Update Failure:", error);
throw new Error(error.message || "Failed to update record.");
}
} }

View file

@ -1,83 +1,19 @@
'use client'; "use client";
// src/app/dashboard/dashboard-view.tsx
import { useState } from "react"; import { useState } from "react";
import { import {
Button, Button, CircularProgress, Box, Chip, IconButton, Tooltip, Typography
CircularProgress,
Box,
Chip,
IconButton,
Typography,
Stack,
TextField,
InputAdornment,
Tooltip
} from "@mui/material"; } from "@mui/material";
import {
DataGrid,
GridColDef,
Toolbar,
QuickFilter,
QuickFilterControl,
QuickFilterClear,
} 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";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import SearchIcon from '@mui/icons-material/Search';
import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { syncOneDrive } from "./sync-actions"; import { syncOneDrive } from "./sync-actions";
import { deleteFileAction } from "./actions"; import { deleteFileAction } from "./actions"; // Un-commented and assumed active
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
function CustomToolbar() {
return (
<Toolbar sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" fontWeight="bold" color="primary">
Library
</Typography>
<QuickFilter sx={{ display: 'flex', alignItems: 'center' }}>
<QuickFilterControl
render={({ ref, ...controlProps }, state) => (
<TextField
{...controlProps}
inputRef={ref}
variant="outlined"
size="small"
placeholder="Search files and metadata..."
sx={{ width: 350 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: state.value ? (
<InputAdornment position="end">
<QuickFilterClear size="small">
<CancelIcon fontSize="small" />
</QuickFilterClear>
</InputAdornment>
) : null,
},
}}
/>
)}
/>
</QuickFilter>
</Toolbar>
);
}
interface DashboardViewProps { interface DashboardViewProps {
initialFiles: any[]; initialFiles: any[];
user?: { user?: {
@ -90,12 +26,19 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
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";
/**
* Recursive function to build the virtual path for the "Location" column.
* Ensures we see "Projects / Project-1" instead of UUIDs or "ROOT".
*/
const getVirtualPath = (parentId: string | null): string => { const getVirtualPath = (parentId: string | null): string => {
if (!parentId) return "WebCalibre"; if (!parentId) return "WebCalibre";
const parent = initialFiles.find((f) => f.id === parentId); const parent = initialFiles.find((f) => f.id === parentId);
if (!parent) return "WebCalibre"; if (!parent) return "WebCalibre";
// Recursive lookup for breadcrumbs
const prefix = parent.parentId ? `${getVirtualPath(parent.parentId)} / ` : ""; const prefix = parent.parentId ? `${getVirtualPath(parent.parentId)} / ` : "";
return `${prefix}${parent.name}`; return `${prefix}${parent.name}`;
}; };
@ -107,6 +50,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
console.error("Sync failed:", error); console.error("Sync failed:", error);
alert("Failed to sync OneDrive");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -115,11 +59,13 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const handleRefresh = () => { const handleRefresh = () => {
setIsRefreshing(true); setIsRefreshing(true);
router.refresh(); router.refresh();
// Visual feedback for the refresh action
setTimeout(() => setIsRefreshing(false), 800); setTimeout(() => setIsRefreshing(false), 800);
}; };
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 deleteFileAction(id);
router.refresh(); router.refresh();
@ -128,17 +74,6 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
} }
}; };
// --- NEW DOWNLOAD FUNCTIONS ---
const handleDownload = (id: string) => {
// Triggers local folder download via Content-Disposition: attachment
window.location.href = `/api/download?id=${id}&mode=attachment`;
};
const handleViewInTab = (id: string) => {
// Opens in a new tab via Content-Disposition: inline
window.open(`/api/download?id=${id}&mode=inline`, '_blank');
};
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "name", field: "name",
@ -147,25 +82,49 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
minWidth: 250, minWidth: 250,
renderCell: (params) => ( renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />} {params.row.isFolder ? (
<Typography variant="body2">{params.value}</Typography> <FolderIcon sx={{ color: '#FFB020' }} /> // Folder yellow
) : (
<InsertDriveFileIcon color="action" />
)}
<Typography variant="body2" sx={{ fontWeight: params.row.isFolder ? 600 : 400 }}>
{params.value}
</Typography>
</Box> </Box>
) )
}, },
{ {
field: "parentId", field: "parentId",
headerName: "Location", headerName: "Location (Project)",
flex: 1, flex: 1,
renderCell: (params) => <Chip label={getVirtualPath(params.value)} size="small" variant="outlined" /> minWidth: 200,
renderCell: (params) => {
const path = getVirtualPath(params.value);
return (
<Tooltip title={path}>
<Chip
label={path}
size="small"
variant="outlined"
color={path === "WebCalibre" ? "default" : "primary"}
sx={{ maxWidth: '100%' }}
/>
</Tooltip>
);
}
}, },
{ field: "description", headerName: "Description", flex: 1 },
{ {
field: "type", field: "type",
headerName: "Type", headerName: "Type",
width: 120, width: 100,
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"), valueGetter: (value, row) => {
// Priority: Metadata -> isFolder check -> fallback
const type = row.metadata?.type || (row.isFolder ? "FOLDER" : "FILE");
// Ensure we don't display a UUID here if the sync was messy
return type.length > 10 && row.isFolder ? "FOLDER" : type;
},
renderCell: (params) => ( renderCell: (params) => (
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}> <Typography variant="caption" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
{params.value} {params.value}
</Typography> </Typography>
) )
@ -173,71 +132,41 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
{ {
field: "size", field: "size",
headerName: "Size", headerName: "Size",
width: 100, width: 120,
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB` valueGetter: (value, row) => {
if (row.isFolder) return null;
return value;
},
renderCell: (params) => {
if (params.value === null) return "--";
const mb = (Number(params.value) / 1024 / 1024).toFixed(2);
return `${mb} MB`;
}
}, },
{ {
field: "actions", field: "actions",
headerName: "Actions", headerName: "Actions",
width: 180, // Increased width to accommodate new buttons width: 100,
sortable: false,
align: 'right', align: 'right',
headerAlign: 'right',
renderCell: (params) => { renderCell: (params) => {
const isOwner = params.row.ownerId === user?.id; const isOwner = params.row.ownerId === user?.id;
const isFolder = params.row.isFolder; if (isAdmin || isOwner) {
return (
return ( <IconButton
<Stack direction="row" spacing={0.5} justifyContent="flex-end"> size="small"
{!isFolder && ( color="error"
<> onClick={(e) => {
<Tooltip title="View in Tab"> e.stopPropagation();
<IconButton size="small" color="info" onClick={() => handleViewInTab(params.row.id)}> handleDelete(params.row.id, params.row.name);
<OpenInNewIcon fontSize="small" /> }}
</IconButton> >
</Tooltip> <DeleteIcon fontSize="small" />
<Tooltip title="Download to Folder"> </IconButton>
<IconButton size="small" color="success" onClick={() => handleDownload(params.row.id)}> );
<DownloadIcon fontSize="small" /> }
</IconButton> return null;
</Tooltip>
</>
)}
{(isAdmin || isOwner) && (
<>
<Tooltip title="Edit Details">
<IconButton
size="small"
color="primary"
onClick={() => router.push(`/update/${params.row.id}`)}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
color="error"
onClick={() => handleDelete(params.row.id, params.row.name)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</>
)}
</Stack>
);
}
},
{
field: "metadata_search",
headerName: "Metadata Search",
width: 0,
valueGetter: (value, row) => {
if (!row.metadata) return "";
return Object.entries(row.metadata)
.filter(([k]) => k !== 'type' && k !== 'mimeType')
.map(([k, v]) => `${k}:${v}`)
.join(" ");
} }
} }
]; ];
@ -245,32 +174,53 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
return ( return (
<Box className="space-y-4"> <Box className="space-y-4">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}> <Button
Refresh List variant="outlined"
startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing..." : "Refresh List"}
</Button> </Button>
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}>
Sync OneDrive <Button
variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleSync}
disabled={loading}
>
{loading ? "Syncing..." : "Sync OneDrive"}
</Button> </Button>
</Box> </Box>
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}> <Box sx={{
height: 700,
width: "100%",
bgcolor: 'background.paper',
borderRadius: 3,
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
overflow: 'hidden'
}}>
<DataGrid <DataGrid
rows={initialFiles} rows={initialFiles}
columns={columns} columns={columns}
slots={{ toolbar: CustomToolbar }} pageSizeOptions={[10, 25, 50]}
showToolbar
disableRowSelectionOnClick
initialState={{ initialState={{
columns: { pagination: { paginationModel: { pageSize: 10 } },
columnVisibilityModel: { sorting: {
metadata_search: false, sortModel: [{ field: 'name', sort: 'asc' }],
},
}, },
}} }}
sx={{ disableRowSelectionOnClick
border: 'none', sx={{
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' }, border: 'none',
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' } '& .MuiDataGrid-columnHeaders': {
bgcolor: '#f8f9fa',
borderBottom: '1px solid #eee',
},
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0',
},
}} }}
/> />
</Box> </Box>

View file

@ -1,4 +1,5 @@
'use server'; 'use server';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -6,37 +7,64 @@ import { getFreshAccessToken } from "@/lib/auth-utils";
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 accessToken = await getFreshAccessToken(session.user.id);
if (!accessToken) {
throw new Error("Microsoft account not linked properly or session expired.");
}
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", { const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", {
headers: { Authorization: `Bearer ${accessToken}` }, headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
}); });
if (!response.ok) return { success: true, count: 0 }; if (!response.ok) {
const errorData = await response.json();
if (response.status === 404) {
return { success: true, count: 0 };
}
throw new Error(errorData.error?.message || "Failed to fetch data from OneDrive");
}
const data = await response.json(); const data = await response.json();
let syncedCount = 0; let syncedCount = 0;
// Regular expression to identify if a string is a UUID
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 data.value) {
const isFolder = !!item.folder; const isFolder = !!item.folder;
// Only skip if it's a folder AND it's a UUID (storage container) /**
// If a user named a file with a UUID, we still want it. * FILTER: If the item is a folder and the name is a UUID, skip it.
* These are storage containers created by the upload action, not user-facing folders.
*/
if (isFolder && uuidRegex.test(item.name)) { if (isFolder && uuidRegex.test(item.name)) {
continue; continue;
} }
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN'); const extension = isFolder
? 'FOLDER'
: (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
const fileSize = item.size ? BigInt(item.size) : BigInt(0);
await prisma.fileNode.upsert({ await prisma.fileNode.upsert({
where: { oneDriveId: item.id }, // Primary match where: { oneDriveId: item.id },
update: { update: {
name: item.name, name: item.name,
size: BigInt(item.size || 0), size: fileSize,
isFolder: isFolder, isFolder: isFolder,
metadata: {
type: extension,
mimeType: item.file?.mimeType || null
},
path: item.parentReference?.path + '/' + item.name, path: item.parentReference?.path + '/' + item.name,
updatedAt: new Date(), updatedAt: new Date(),
}, },
@ -44,11 +72,14 @@ export async function syncOneDrive() {
id: crypto.randomUUID(), id: crypto.randomUUID(),
oneDriveId: item.id, oneDriveId: item.id,
name: item.name, name: item.name,
size: BigInt(item.size || 0), size: fileSize,
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,
mimeType: item.file?.mimeType || null
},
} }
}); });
syncedCount++; syncedCount++;
@ -56,7 +87,9 @@ export async function syncOneDrive() {
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 Process Failure:", error.message);
throw new Error(error.message || "An unexpected error occurred during sync.");
} }
} }

View file

@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Box, Button, Typography, Paper, LinearProgress, Container } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { uploadFileAction } from "../upload-actions";
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const handleUpload = async () => {
if (!file) return;
setLoading(true);
const formData = new FormData();
formData.append("file", file);
try {
await uploadFileAction(formData);
alert("Book added to WebCalibre!");
setFile(null);
} catch (err) {
alert("Upload failed. Check console.");
} finally {
setLoading(false);
}
};
return (
<Container maxWidth="sm" sx={{ mt: 8 }}>
<Paper elevation={3} sx={{ p: 4, textAlign: 'center', borderRadius: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="bold">
Upload to Library
</Typography>
<Typography color="textSecondary" mb={4}>
Files will be saved in your OneDrive "WebCalibre" folder.
</Typography>
<Box sx={{ border: '2px dashed #ccc', p: 4, mb: 3, borderRadius: 2 }}>
<input
accept=".pdf,.epub,.mobi,.txt"
style={{ display: 'none' }}
id="file-input"
type="file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<label htmlFor="file-input">
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
{file ? file.name : "Choose Book File"}
</Button>
</label>
</Box>
{loading && <LinearProgress sx={{ mb: 2 }} />}
<Button
variant="contained"
fullWidth
size="large"
disabled={!file || loading}
onClick={handleUpload}
>
{loading ? "Uploading..." : "Start Upload"}
</Button>
</Paper>
</Container>
);
}

View file

@ -1,49 +0,0 @@
'use server';
//src/app/update/[id]/_actions.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function updateFileAction(formData: FormData) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const parentIdRaw = formData.get("parentId") as string;
const customMetadataRaw = formData.get("customMetadata") as string;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
try {
// 1. Get existing record to preserve system metadata (like mimeType)
const existing = await prisma.fileNode.findUnique({ where: { id } });
const existingMetadata = (existing?.metadata as Record<string, any>) || {};
// 2. Update the record
await prisma.fileNode.update({
where: { id },
data: {
name,
description,
parentId,
metadata: {
...customMetadata, // User's new keys
type: name.split('.').pop()?.toUpperCase() || existingMetadata.type || "FILE",
mimeType: existingMetadata.mimeType // Preserve the original mimeType
}
}
});
revalidatePath("/dashboard");
revalidatePath(`/update/${id}`);
return { success: true };
} catch (error: any) {
console.error("Update error:", error);
return { success: false, message: error.message };
}
}

View file

@ -1,34 +0,0 @@
import { auth } from "@/auth";
import { redirect, notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { Container } from "@mui/material";
import UpdateView from "./update-view";
// Note: params is now handled as a Promise
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) redirect("/");
// 1. Await the params to get the actual ID
const { id } = await params;
// 2. Fetch the specific file using the awaited ID
const fileNode = await prisma.fileNode.findUnique({
where: { id: id }
});
if (!fileNode) notFound();
// Fetch folders for the destination dropdown
const folders = await prisma.fileNode.findMany({
where: { isFolder: true },
orderBy: { name: 'asc' },
select: { id: true, name: true }
});
return (
<Container maxWidth="md" sx={{ py: 8 }}>
<UpdateView fileNode={fileNode} folders={folders} />
</Container>
);
}

View file

@ -1,171 +0,0 @@
'use client';
import { useState } from "react";
import {
Box, Button, Typography, Paper, Stack,
TextField, MenuItem, IconButton, Grid, Divider
} from "@mui/material";
import SaveIcon from "@mui/icons-material/Save";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import AssignmentIcon from '@mui/icons-material/Assignment';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FolderIcon from "@mui/icons-material/Folder";
import { useRouter } from "next/navigation";
import { updateFileAction } from "./_actions";
interface MetadataPair {
key: string;
value: string;
}
export default function UpdateView({ fileNode, folders }: any) {
const router = useRouter();
const [loading, setLoading] = useState(false);
// 1. Initialize Basic Info
const [name, setName] = useState(fileNode.name);
const [description, setDescription] = useState(fileNode.description || "");
const [parentId, setParentId] = useState(fileNode.parentId || "");
// 2. Parse existing JSON metadata into Key/Value array for the UI
// We filter out 'type' and 'mimeType' as they are system-managed
const initialMetadata = Object.entries(fileNode.metadata || {})
.filter(([key]) => !['type', 'mimeType'].includes(key))
.map(([key, value]) => ({ key, value: String(value) }));
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
const handleUpdate = async () => {
setLoading(true);
const formData = new FormData();
formData.append("id", fileNode.id);
formData.append("name", name);
formData.append("description", description);
formData.append("parentId", parentId);
// Convert array back to object for storage
const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
return acc;
}, {} as Record<string, string>);
formData.append("customMetadata", JSON.stringify(metadataObj));
const res = await updateFileAction(formData);
if (res.success) {
router.push("/dashboard");
router.refresh();
} else {
alert("Update failed");
setLoading(false);
}
};
return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
Back
</Button>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
Edit File Details
</Typography>
<Stack spacing={4} sx={{ mt: 2 }}>
{/* Name Field */}
<TextField
label="File Name"
fullWidth value={name}
onChange={(e) => setName(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
{/* Folder Select */}
<TextField
id="update-dest-select"
select fullWidth label="Destination Folder"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true }
}}
>
<MenuItem value=""><em>-- Root --</em></MenuItem>
{folders.map((f: any) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
{/* Custom Metadata Section */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Custom Attributes
</Typography>
<Button
startIcon={<AddCircleOutlineIcon />}
size="small"
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])}
>
Add Field
</Button>
</Box>
<Stack spacing={2}>
{customMetadata.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={5}>
<TextField
fullWidth size="small" placeholder="Key"
value={row.key}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].key = e.target.value;
setCustomMetadata(updated);
}}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth size="small" placeholder="Value"
value={row.value}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].value = e.target.value;
setCustomMetadata(updated);
}}
/>
</Grid>
<Grid item xs={1}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))}
</Stack>
</Box>
<TextField
label="Description"
multiline rows={4} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<Button
variant="contained" size="large" fullWidth
startIcon={<SaveIcon />}
onClick={handleUpdate}
disabled={loading}
sx={{ py: 1.5, fontWeight: 'bold' }}
>
{loading ? "Saving..." : "Save Changes"}
</Button>
</Stack>
</Paper>
);
}

View file

@ -6,44 +6,40 @@ import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
/** /**
* 1. CREATE FOLDER: Virtual Only * Creates a virtual folder in the database.
* Logic: User-created organizational folders exist ONLY in the database. * We explicitly set the metadata type to "FOLDER" so the Dashboard icon
* No call to OneDrive is made here. * and Location logic work immediately.
*/ */
export async function createFolderAction(name: string, parentId?: string | null) { export async function createFolderAction(name: string, parentId: string | null = null) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
try { // We generate a manual UUID because the schema requires 'id' but has no default generator
const internalId = crypto.randomUUID(); const internalId = crypto.randomUUID();
const newNode = await prisma.fileNode.create({ await prisma.fileNode.create({
data: { data: {
id: internalId, id: internalId,
oneDriveId: null, // Virtual folders do not have a cloud ID name: 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: {
metadata: { type: "FOLDER" } type: "FOLDER",
mimeType: "inode/directory"
} }
}); }
});
revalidatePath("/upload"); revalidatePath("/upload");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true };
return { success: true, node: newNode };
} catch (error: any) {
console.error("Folder creation error:", error);
throw new Error(error.message || "Failed to create virtual folder");
}
} }
/** /**
* 2. UPLOAD FILE: Physical Container * Uploads a file to OneDrive into a unique UUID folder
* Logic: Creates a physical folder (UUID) on OneDrive to hold the file. * and links it to a virtual parent (Project/Folder) in the DB.
* 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();
@ -51,19 +47,16 @@ export async function uploadFileAction(formData: FormData) {
const file = formData.get("file") as File; const file = formData.get("file") as File;
const description = formData.get("description") as string || ""; const description = formData.get("description") as string || "";
const parentIdRaw = formData.get("parentId") as string | null; const parentId = formData.get("parentId") as string | null;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
const customMetadataRaw = formData.get("customMetadata") as string;
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
if (!file) throw new Error("No file selected"); if (!file) throw new Error("No file selected");
// Use the utility to ensure we have a valid JWT (fixing the "no dots" error)
const accessToken = await getFreshAccessToken(session.user.id); 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();
// 1. Create the Physical Storage Folder on OneDrive // 1. Create the unique UUID folder on OneDrive inside WebCalibre
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, { const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
method: "POST", method: "POST",
headers: { headers: {
@ -72,29 +65,34 @@ export async function uploadFileAction(formData: FormData) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: internalId, name: internalId,
folder: {}, folder: {},
"@microsoft.graph.conflictBehavior": "fail" "@microsoft.graph.conflictBehavior": "fail"
}) })
}); });
if (!createSubFolderRes.ok) { if (!createSubFolderRes.ok) {
const errorData = await createSubFolderRes.json(); const err = await createSubFolderRes.json();
throw new Error(errorData.error?.message || "Storage directory creation failed"); console.error("OneDrive Folder Creation Error:", err);
throw new Error("Storage directory creation failed");
} }
const subFolderData = await createSubFolderRes.json(); const subFolderData = await createSubFolderRes.json();
// 2. Create Upload Session inside the new Physical Folder // 2. Create Upload Session for the file
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`; const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
const sessionRes = await fetch(sessionUrl, { const sessionRes = await fetch(sessionUrl, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } }) body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
}); });
const { uploadUrl } = await sessionRes.json(); const { uploadUrl } = await sessionRes.json();
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
// 3. PUT the file binary // 3. Perform the actual upload
const uploadRes = await fetch(uploadUrl, { const uploadRes = await fetch(uploadUrl, {
method: "PUT", method: "PUT",
headers: { headers: {
@ -104,25 +102,24 @@ export async function uploadFileAction(formData: FormData) {
body: buffer body: buffer
}); });
const uploadedFileData = await uploadRes.json(); if (!uploadRes.ok) throw new Error("OneDrive stream failed");
const oneDriveId = uploadedFileData.id; const driveItem = await uploadRes.json();
// 4. Record in Database with full metadata and virtual hierarchy
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN"; 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({ await prisma.fileNode.create({
data: { data: {
id: internalId, id: internalId, // Matches the folder name on OneDrive
oneDriveId: oneDriveId, oneDriveId: driveItem.id,
name: file.name, name: file.name,
description: description, description: description,
size: BigInt(file.size), size: BigInt(file.size),
isFolder: false, isFolder: false,
path: `/${rootFolder}/${internalId}/${file.name}`, path: `/${rootFolder}/${internalId}/${file.name}`,
ownerId: session.user.id, ownerId: session.user.id,
parentId: parentId, parentId: parentId || null, // Virtual link to the Project folder
metadata: { metadata: {
...customMetadata,
type: extension, type: extension,
mimeType: file.type mimeType: file.type
} }
@ -130,6 +127,5 @@ export async function uploadFileAction(formData: FormData) {
}); });
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/upload");
return { success: true }; return { success: true };
} }

View file

@ -1,85 +1,84 @@
'use client'; 'use client';
import { useState, useRef } from "react"; import { useState } from "react";
import { import {
Box, Button, Typography, Paper, Stack, Box, Button, Typography, Paper, LinearProgress, Stack,
TextField, MenuItem, IconButton, Divider, TextField, MenuItem, IconButton, Tooltip, Divider,
Grid, CircularProgress InputAdornment
} from "@mui/material"; } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import FolderIcon from "@mui/icons-material/Folder";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import FolderIcon from "@mui/icons-material/Folder";
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { uploadFileAction, createFolderAction } from "./_actions"; import { uploadFileAction, createFolderAction } from "./_actions";
interface MetadataPair { /**
key: string; * Format bytes to human readable string (MiB/KiB)
value: string; */
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
interface Folder {
id: string;
name: string;
parentId: string | null;
} }
export default function UploadView({ user, folders = [] }: any) { interface UploadViewProps {
user: any;
folders?: Folder[];
}
export default function UploadView({ user, folders = [] }: UploadViewProps) {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
// Form State
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [parentId, setParentId] = useState(""); const [parentId, setParentId] = useState("");
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle'); const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
// Folder Creation State // Folder Creation State
const [showFolderInput, setShowFolderInput] = useState(false); const [showFolderInput, setShowFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState(""); const [newFolderName, setNewFolderName] = useState("");
const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB Limit
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!newFolderName.trim()) return; if (!newFolderName.trim()) return;
setIsCreatingFolder(true); setIsCreatingFolder(true);
try { try {
// FIX: Pass the current parentId to the action so it nests correctly // Creates folder nested under current selection if parentId exists
const result = await createFolderAction(newFolderName, parentId); await createFolderAction(newFolderName, parentId || null);
if (result.success) { setNewFolderName("");
setNewFolderName(""); setShowFolderInput(false);
setShowFolderInput(false); router.refresh();
router.refresh(); } catch (err) {
} alert("Error creating folder");
} catch (err: any) {
alert(err.message || "Failed to create folder");
} finally { } finally {
setIsCreatingFolder(false); setIsCreatingFolder(false);
} }
}; };
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
const removeMetadataRow = (index: number) => {
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
};
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
const updated = [...customMetadata];
updated[index][field] = val;
setCustomMetadata(updated);
};
const handleUpload = async () => { const handleUpload = async () => {
if (!file) return; if (!file) return;
setStatus('uploading'); if (file.size > MAX_FILE_SIZE) {
alert(`File is too large! Max allowed: ${formatFileSize(MAX_FILE_SIZE)}.`);
return;
}
setStatus('uploading');
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("description", description); formData.append("description", description);
formData.append("parentId", parentId); formData.append("parentId", parentId);
const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
return acc;
}, {} as Record<string, string>);
formData.append("customMetadata", JSON.stringify(metadataObj));
try { try {
const result = await uploadFileAction(formData); const result = await uploadFileAction(formData);
@ -87,12 +86,11 @@ export default function UploadView({ user, folders = [] }: any) {
setStatus('success'); setStatus('success');
setFile(null); setFile(null);
setDescription(""); setDescription("");
setCustomMetadata([]);
router.push("/dashboard");
router.refresh(); router.refresh();
} }
} catch (err) { } catch (err) {
alert("Upload failed."); console.error(err);
alert("Upload failed. Check server logs.");
setStatus('idle'); setStatus('idle');
} }
}; };
@ -104,7 +102,8 @@ export default function UploadView({ user, folders = [] }: any) {
</Typography> </Typography>
<Stack spacing={4} sx={{ mt: 4 }}> <Stack spacing={4} sx={{ mt: 4 }}>
{/* 1. Destination */}
{/* Section 1: Destination Folder */}
<Box> <Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon color="primary" /> 1. Destination <FolderIcon color="primary" /> 1. Destination
@ -112,139 +111,147 @@ export default function UploadView({ user, folders = [] }: any) {
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
id="project-destination-select"
select select
fullWidth fullWidth
label="Target Project / Folder" label="Target Project / Folder"
value={parentId} value={parentId}
onChange={(e) => setParentId(e.target.value)} onChange={(e) => setParentId(e.target.value)}
size="small" disabled={status === 'uploading'}
slotProps={{ helperText="Files will be virtually organized into this folder."
select: { displayEmpty: true },
inputLabel: { shrink: true },
}}
> >
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem> <MenuItem value=""><em>-- Root (Main Library) --</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} sx={{ pl: f.parentId ? 4 : 2 }}>
{f.parentId ? `${f.name}` : f.name}
</MenuItem>
))} ))}
</TextField> </TextField>
<IconButton <Tooltip title="Create New Folder Inside Selected">
color="primary" <IconButton
onClick={() => setShowFolderInput(!showFolderInput)} color="primary"
sx={{ border: '1px solid #ccc', borderRadius: 1 }} onClick={() => setShowFolderInput(!showFolderInput)}
> sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, width: 56, height: 56 }}
<CreateNewFolderIcon /> >
</IconButton> <CreateNewFolderIcon />
</IconButton>
</Tooltip>
</Stack> </Stack>
{showFolderInput && ( {showFolderInput && (
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}> <Stack direction="row" spacing={1} sx={{ mt: 2, p: 2, bgcolor: 'action.hover', borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom> <TextField
{parentId ? `Create inside current selection` : `Create at Root`} size="small"
</Typography> fullWidth
<Stack direction="row" spacing={1}> placeholder="New folder name..."
<TextField value={newFolderName}
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)" onChange={(e) => setNewFolderName(e.target.value)}
value={newFolderName} autoFocus
onChange={(e) => setNewFolderName(e.target.value)} disabled={isCreatingFolder}
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()} />
/> <Button
<Button variant="contained"
variant="contained" onClick={handleCreateFolder}
onClick={handleCreateFolder} disabled={isCreatingFolder || !newFolderName.trim()}
disabled={isCreatingFolder || !newFolderName} >
> {isCreatingFolder ? "..." : "Create"}
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"} </Button>
</Button> </Stack>
</Stack>
</Box>
)} )}
</Box> </Box>
{/* 2. Upload Area */} <Divider />
{/* Section 2: File Upload Area */}
<Box> <Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CloudUploadIcon color="primary" /> 2. Upload File <CloudUploadIcon color="primary" /> 2. Upload File
</Typography> </Typography>
<input <Box
type="file" sx={{
ref={fileInputRef} p: 5,
style={{ display: 'none' }} mt: 1,
onChange={(e) => setFile(e.target.files?.[0] || null)} border: '2px dashed',
/> borderColor: file ? 'primary.main' : 'divider',
borderRadius: 3,
<Button textAlign: 'center',
variant="outlined" bgcolor: file ? 'rgba(25, 118, 210, 0.04)' : '#fafafa',
fullWidth '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.02)' }
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }} }}
onClick={() => fileInputRef.current?.click()}
> >
{file ? ( <input
<Box> type="file" id="file-input" hidden
<Typography color="success.main" fontWeight="bold"> {file.name}</Typography> onChange={(e) => {
setFile(e.target.files?.[0] || null);
setStatus('idle');
}}
/>
<label htmlFor="file-input">
<Button
variant={file ? "outlined" : "contained"}
component="span"
startIcon={<CloudUploadIcon />}
size="large"
disabled={status === 'uploading'}
>
{file ? "Change File" : "Choose Document"}
</Button>
</label>
{file && (
<Box mt={2}>
<Typography variant="subtitle2" color="primary.main" fontWeight="bold">
{file.name}
</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB) {formatFileSize(file.size)}
</Typography> </Typography>
</Box> </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> </Box>
<Stack spacing={2}>
{customMetadata.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={5}>
<TextField
fullWidth size="small" placeholder="Key (e.g. Project-ID)"
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth size="small" placeholder="Value"
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)}
/>
</Grid>
<Grid item xs={1}>
<IconButton color="error" onClick={() => removeMetadataRow(index)}>
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))}
<TextField
label="General Description"
multiline rows={2} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Stack>
</Box> </Box>
<Button {/* Section 3: Notes */}
variant="contained" size="large" fullWidth <Box>
disabled={!file || status === 'uploading'} <Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
onClick={handleUpload} <AssignmentIcon color="primary" /> 3. Metadata
sx={{ py: 2, fontWeight: 'bold' }} </Typography>
> <TextField
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'} label="Description / Notes"
</Button> multiline rows={3} fullWidth
placeholder="Add keywords or a brief summary..."
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={status === 'uploading'}
/>
</Box>
{/* Progress & Actions */}
<Box>
{status === 'uploading' && (
<Box mb={2}>
<LinearProgress sx={{ borderRadius: 5, height: 10 }} />
<Typography variant="caption" color="primary" sx={{ mt: 1, display: 'block', textAlign: 'center' }}>
Transferring to OneDrive and updating Library...
</Typography>
</Box>
)}
<Button
variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload}
sx={{ py: 2, fontWeight: 'bold' }}
>
{status === 'uploading' ? 'Uploading...' : 'Add to Library'}
</Button>
{status === 'success' && (
<Typography align="center" color="success.main" fontWeight="bold" sx={{ mt: 2 }}>
File processed and assigned to project!
</Typography>
)}
</Box>
</Stack> </Stack>
</Paper> </Paper>
); );

View file

@ -1,4 +1,3 @@
// src/auth.ts
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@ -11,7 +10,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
callbacks: { callbacks: {
async jwt({ token, account, user }) { async jwt({ token, account, user }) {
// 1. Handle OAuth tokens (from first sign-in) // 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;
@ -19,10 +17,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
} }
// 2. Attach User ID and Role to the token // 2. Attach User ID and Role to the token
// This runs when the user first logs in // When 'user' exists, it's the first time we've fetched this user from the DB during login
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 - 'role' exists on our custom User model
token.role = user.role; token.role = user.role;
} }
@ -31,16 +29,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 // 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 && token.sub) {
if (session?.user) { session.user.id = token.sub;
session.user.id = token.sub as string; // @ts-ignore - Attaching the role so the Navbar and Settings page can see it
session.user.role = token.role;
// @ts-ignore - Attaching the role for UI permissions
session.user.role = token.role as string;
// IMPORTANT: We must attach the accessToken here so the
// /api/download route can use it to fetch from MS Graph
session.accessToken = token.accessToken as string;
} }
return session; return session;
}, },

View file

@ -1,24 +0,0 @@
// src/types/next-auth.d.ts
import NextAuth, { DefaultSession } from "next-auth"
import { JWT } from "next-auth/jwt"
declare module "next-auth" {
/**
* Returned by `useSession`, `auth`, and received as a prop on the `SessionProvider` React Context
*/
interface Session {
accessToken?: string;
user: {
id: string;
role?: string;
} & DefaultSession["user"]
}
}
declare module "next-auth/jwt" {
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
interface JWT {
accessToken?: string;
role?: string;
}
}