Update working but we are creating folder in Onedrive for virtual folders

This commit is contained in:
stephen 2026-01-14 00:17:40 +11:00
parent 3a909e6265
commit 86cdffdd1c
9 changed files with 550 additions and 205 deletions

View file

@ -24,42 +24,32 @@ export async function getFileNodes() {
/** /**
* 2. DELETE: Remove from OneDrive and Database * 2. DELETE: Remove from OneDrive and Database
* Improved to ensure DB removal even if OneDrive API returns errors.
*/ */
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, message: "Item already removed from database" }; return { success: true, message: "Item already removed from database" };
} }
// @ts-ignore - role added via auth callbacks // @ts-ignore
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: You do not have authority to delete this item."); throw new Error("Permission Denied.");
} }
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);
if (accessToken) { if (accessToken) {
const rootFolder = "WebCalibre"; const rootFolder = "WebCalibre";
// We target the folder on OneDrive by the ID stored in our DB
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}/${node.id}`; const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}/${node.id}`;
const onedriveRes = await fetch(onedrivePath, { const onedriveRes = await fetch(onedrivePath, {
@ -68,27 +58,19 @@ export async function deleteFileAction(fileId: string) {
}); });
if (!onedriveRes.ok && onedriveRes.status !== 404) { if (!onedriveRes.ok && onedriveRes.status !== 404) {
const errData = await onedriveRes.json().catch(() => ({})); console.warn("OneDrive Deletion Warning: Cloud record might still exist.");
console.warn("OneDrive Deletion Warning (Cloud record might still exist):", errData);
} }
} }
} catch (cloudError) { } catch (cloudError) {
// We catch cloud errors but DO NOT throw them, so we can proceed to delete the DB record console.error("Cloud cleanup failed:", cloudError);
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({ await prisma.fileNode.delete({ where: { id: fileId } });
where: { id: fileId }
});
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/upload"); revalidatePath("/upload");
return { success: true }; return { success: true };
} catch (dbError: any) { } catch (dbError) {
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.");
} }
} }
@ -99,7 +81,6 @@ export async function deleteFileAction(fileId: string) {
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 {
@ -107,10 +88,79 @@ 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.");
} }
} }
/**
* 4. UPDATE & REPLACE: Full update of metadata and OneDrive content
* Uses FormData to handle the binary file upload and text fields.
*/
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;
// Handle the "root" placeholder back to null for Prisma
const parentId = parentIdRaw === "root" ? null : parentIdRaw;
let metadata = JSON.parse(metadataStr);
try {
const accessToken = await getFreshAccessToken(session.user.id);
if (!accessToken) throw new Error("Access token expired.");
// 1. OneDrive Overwrite (if file is provided)
if (newFile && newFile.size > 0) {
const rootFolder = "WebCalibre";
// PUT request to the specific file ID path overwrites content
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}/${id}:/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) {
const err = await uploadRes.json();
throw new Error(`OneDrive error: ${err.error?.message}`);
}
// Update metadata with new file properties
metadata.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN';
metadata.mimeType = newFile.type;
}
// 2. Database Update
await prisma.fileNode.update({
where: { id },
data: {
name,
description,
parentId,
metadata,
// If file changed, update size; otherwise keep existing
size: newFile ? BigInt(newFile.size) : undefined,
updatedAt: new Date(),
}
});
revalidatePath("/dashboard");
return { success: true };
} catch (error: any) {
console.error("Full Update Action Failure:", error);
throw new Error(error.message || "Failed to update record.");
}
}

View file

@ -1,8 +1,18 @@
"use client"; 'use client';
//src/app/dashboard/dashboard-view.tsx
import { useState } from "react"; import { useState } from "react";
import { import {
Button, CircularProgress, Box, Chip, IconButton, Tooltip, Typography, Stack, TextField, InputAdornment Button,
CircularProgress,
Box,
Chip,
IconButton,
Typography,
Stack,
TextField,
InputAdornment
} from "@mui/material"; } from "@mui/material";
import { import {
DataGrid, DataGrid,
@ -17,9 +27,10 @@ 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 InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import EditIcon from "@mui/icons-material/Edit";
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { syncOneDrive } from "./sync-actions"; import { syncOneDrive } from "./sync-actions";
import { deleteFileAction } from "./actions"; import { deleteFileAction } from "./actions";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -30,7 +41,6 @@ function CustomToolbar() {
<Typography variant="h6" fontWeight="bold" color="primary"> <Typography variant="h6" fontWeight="bold" color="primary">
Library Library
</Typography> </Typography>
<QuickFilter sx={{ display: 'flex', alignItems: 'center' }}> <QuickFilter sx={{ display: 'flex', alignItems: 'center' }}>
<QuickFilterControl <QuickFilterControl
render={({ ref, ...controlProps }, state) => ( render={({ ref, ...controlProps }, state) => (
@ -139,10 +149,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
field: "type", field: "type",
headerName: "Type", headerName: "Type",
width: 120, width: 120,
// Fixed: Pulling type from metadata since it's not a top-level property valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"),
valueGetter: (value, row) => {
return 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', color: 'text.secondary' }}>
{params.value} {params.value}
@ -152,21 +159,40 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
{ {
field: "size", field: "size",
headerName: "Size", headerName: "Size",
width: 120, 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: "actions", field: "actions",
headerName: "Actions", headerName: "Actions",
width: 100, width: 120,
align: 'right', align: 'right',
renderCell: (params) => ( renderCell: (params) => {
(isAdmin || params.row.ownerId === user?.id) && ( const isOwner = params.row.ownerId === user?.id;
<IconButton size="small" color="error" onClick={() => handleDelete(params.row.id, params.row.name)}> if (isAdmin || isOwner) {
<DeleteIcon fontSize="small" /> return (
</IconButton> <Stack direction="row" spacing={0.5}>
) <IconButton
) size="small"
color="primary"
onClick={() => router.push(`/update/${params.row.id}`)}
title="Edit Details"
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => handleDelete(params.row.id, params.row.name)}
title="Delete"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
);
}
return null;
}
}, },
{ {
field: "metadata_search", field: "metadata_search",

View file

@ -1,5 +1,4 @@
'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";
@ -7,64 +6,37 @@ 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: { headers: { Authorization: `Bearer ${accessToken}` },
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
}); });
if (!response.ok) { if (!response.ok) return { success: true, count: 0 };
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)
* FILTER: If the item is a folder and the name is a UUID, skip it. // If a user named a file with a UUID, we still want 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 const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
? '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 }, where: { oneDriveId: item.id }, // Primary match
update: { update: {
name: item.name, name: item.name,
size: fileSize, size: BigInt(item.size || 0),
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(),
}, },
@ -72,14 +44,11 @@ export async function syncOneDrive() {
id: crypto.randomUUID(), id: crypto.randomUUID(),
oneDriveId: item.id, oneDriveId: item.id,
name: item.name, name: item.name,
size: fileSize, size: BigInt(item.size || 0),
isFolder: isFolder, isFolder: isFolder,
path: item.parentReference?.path + '/' + item.name, path: item.parentReference?.path + '/' + item.name,
ownerId: session.user.id, ownerId: session.user.id,
metadata: { metadata: { type: extension, mimeType: item.file?.mimeType || null },
type: extension,
mimeType: item.file?.mimeType || null
},
} }
}); });
syncedCount++; syncedCount++;
@ -87,9 +56,7 @@ export async function syncOneDrive() {
revalidatePath('/dashboard'); revalidatePath('/dashboard');
return { success: true, count: syncedCount }; return { success: true, count: syncedCount };
} catch (error: any) { } catch (error: any) {
console.error("Sync Process Failure:", error.message); throw new Error(error.message);
throw new Error(error.message || "An unexpected error occurred during sync.");
} }
} }

View file

@ -1,69 +0,0 @@
"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

@ -0,0 +1,49 @@
'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

@ -0,0 +1,34 @@
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

@ -0,0 +1,171 @@
'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

@ -1,11 +1,59 @@
'use server'; 'use server';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getFreshAccessToken } from "@/lib/auth-utils"; import { getFreshAccessToken } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
// createFolderAction remains the same... export async function createFolderAction(name: string, parentId?: string | null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const accessToken = await getFreshAccessToken(session.user.id);
const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID();
const onedriveRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
name: internalId,
folder: {},
"@microsoft.graph.conflictBehavior": "rename"
})
});
if (!onedriveRes.ok) {
const errorData = await onedriveRes.json();
throw new Error(errorData.error?.message || "OneDrive folder creation failed");
}
// CAPTURE Microsoft's ID
const onedriveData = await onedriveRes.json();
const newNode = await prisma.fileNode.create({
data: {
id: internalId,
oneDriveId: onedriveData.id, // SAVED HERE
name: name,
isFolder: true,
path: `/${rootFolder}/${internalId}`,
ownerId: session.user.id,
parentId: parentId || null,
metadata: { type: "FOLDER" }
}
});
revalidatePath("/upload");
revalidatePath("/dashboard");
return { success: true, node: newNode };
} catch (error: any) {
throw new Error(error.message || "Failed to create folder");
}
}
export async function uploadFileAction(formData: FormData) { export async function uploadFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
@ -13,9 +61,8 @@ 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 parentId = formData.get("parentId") as string | null; const parentIdRaw = formData.get("parentId") as string | null;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
// Parse the dynamic metadata from the client
const customMetadataRaw = formData.get("customMetadata") as string; const customMetadataRaw = formData.get("customMetadata") as string;
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {}; const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
@ -25,52 +72,60 @@ export async function uploadFileAction(formData: FormData) {
const rootFolder = "WebCalibre"; const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID(); const internalId = crypto.randomUUID();
// 1. Create OneDrive Storage Folder // 1. Create Storage Container
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: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: internalId, folder: {} }) body: JSON.stringify({ name: internalId, folder: {} })
}); });
if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed");
const subFolderData = await createSubFolderRes.json(); const subFolderData = await createSubFolderRes.json();
// 2. Upload Session & File Transfer (Existing logic is fine) // 2. Upload 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());
await fetch(uploadUrl, {
const uploadRes = await fetch(uploadUrl, {
method: "PUT", method: "PUT",
headers: { "Content-Length": `${file.size}`, "Content-Range": `bytes 0-${file.size - 1}/${file.size}` }, headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer body: buffer
}); });
// 3. Final Database Record with Merged Metadata // 3. GET THE FINAL FILE ID FROM MICROSOFT
const uploadedFileData = await uploadRes.json();
const oneDriveId = uploadedFileData.id;
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN"; const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
await prisma.fileNode.create({ await prisma.fileNode.create({
data: { data: {
id: internalId, id: internalId,
oneDriveId: oneDriveId, // SAVED HERE
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 || null, parentId: parentId,
metadata: { metadata: {
...customMetadata, // User's dynamic keys (Latitude, Author, etc.) ...customMetadata,
type: extension, // System keys (preserved for UI icons) type: extension,
mimeType: file.type mimeType: file.type
} }
} }
}); });
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/upload");
return { success: true }; return { success: true };
} }

View file

@ -1,28 +1,20 @@
'use client'; 'use client';
import { useState } from "react"; import { useState, useRef } from "react";
import { import {
Box, Button, Typography, Paper, LinearProgress, Stack, Box, Button, Typography, Paper, Stack,
TextField, MenuItem, IconButton, Tooltip, Divider, TextField, MenuItem, IconButton, Divider,
Grid Grid, CircularProgress
} from "@mui/material"; } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
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 { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { uploadFileAction, createFolderAction } from "./_actions"; import { uploadFileAction, createFolderAction } from "./_actions";
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 MetadataPair { interface MetadataPair {
key: string; key: string;
value: string; value: string;
@ -30,19 +22,37 @@ interface MetadataPair {
export default function UploadView({ user, folders = [] }: any) { export default function UploadView({ user, folders = [] }: any) {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
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');
// Dynamic Metadata State
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]); const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
// 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 handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
setIsCreatingFolder(true);
try {
// FIX: Pass the current parentId to the action so it nests correctly
const result = await createFolderAction(newFolderName, parentId);
if (result.success) {
setNewFolderName("");
setShowFolderInput(false);
router.refresh();
}
} catch (err: any) {
alert(err.message || "Failed to create folder");
} finally {
setIsCreatingFolder(false);
}
};
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]); const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
const removeMetadataRow = (index: number) => { const removeMetadataRow = (index: number) => {
@ -64,7 +74,6 @@ export default function UploadView({ user, folders = [] }: any) {
formData.append("description", description); formData.append("description", description);
formData.append("parentId", parentId); formData.append("parentId", parentId);
// Pass custom metadata as a JSON string
const metadataObj = customMetadata.reduce((acc, curr) => { const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.key.trim()) acc[curr.key.trim()] = curr.value; if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
return acc; return acc;
@ -79,6 +88,7 @@ export default function UploadView({ user, folders = [] }: any) {
setFile(null); setFile(null);
setDescription(""); setDescription("");
setCustomMetadata([]); setCustomMetadata([]);
router.push("/dashboard");
router.refresh(); router.refresh();
} }
} catch (err) { } catch (err) {
@ -94,42 +104,97 @@ export default function UploadView({ user, folders = [] }: any) {
</Typography> </Typography>
<Stack spacing={4} sx={{ mt: 4 }}> <Stack spacing={4} sx={{ mt: 4 }}>
{/* Section 1: Destination (Condensed for brevity, same as your original) */} {/* 1. Destination */}
<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
</Typography> </Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
select fullWidth label="Target Project / Folder" id="project-destination-select"
select
fullWidth
label="Target Project / Folder"
value={parentId} value={parentId}
onChange={(e) => setParentId(e.target.value)} onChange={(e) => setParentId(e.target.value)}
size="small"
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true },
}}
> >
<MenuItem value=""><em>-- Root --</em></MenuItem> <MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
{folders.map((f: any) => ( {folders.map((f: any) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem> <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))} ))}
</TextField> </TextField>
<IconButton color="primary" onClick={() => setShowFolderInput(!showFolderInput)} sx={{ border: '1px solid #ccc', borderRadius: 1 }}> <IconButton
color="primary"
onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
>
<CreateNewFolderIcon /> <CreateNewFolderIcon />
</IconButton> </IconButton>
</Stack> </Stack>
{showFolderInput && (
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{parentId ? `Create inside current selection` : `Create at Root`}
</Typography>
<Stack direction="row" spacing={1}>
<TextField
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()}
/>
<Button
variant="contained"
onClick={handleCreateFolder}
disabled={isCreatingFolder || !newFolderName}
>
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
</Button>
</Stack>
</Box>
)}
</Box> </Box>
{/* Section 2: Upload Area */} {/* 2. 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 type="file" id="file-input" hidden onChange={(e) => setFile(e.target.files?.[0] || null)} />
<label htmlFor="file-input"> <input
<Button variant="outlined" component="span" fullWidth sx={{ p: 4, borderStyle: 'dashed' }}> type="file"
{file ? file.name : "Select File"} ref={fileInputRef}
</Button> style={{ display: 'none' }}
</label> 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> </Box>
{/* Section 3: Dynamic Metadata Area */} {/* 3. Custom Attributes */}
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@ -140,16 +205,12 @@ export default function UploadView({ user, folders = [] }: any) {
</Button> </Button>
</Box> </Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
Add specific details like Author, Latitude, Aperture, or Part Number.
</Typography>
<Stack spacing={2}> <Stack spacing={2}>
{customMetadata.map((row, index) => ( {customMetadata.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center"> <Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={5}> <Grid item xs={5}>
<TextField <TextField
fullWidth size="small" placeholder="Key (e.g. Latitude)" fullWidth size="small" placeholder="Key (e.g. Project-ID)"
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)} value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
/> />
</Grid> </Grid>
@ -180,8 +241,9 @@ export default function UploadView({ user, folders = [] }: any) {
variant="contained" size="large" fullWidth variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'} disabled={!file || status === 'uploading'}
onClick={handleUpload} onClick={handleUpload}
sx={{ py: 2, fontWeight: 'bold' }}
> >
{status === 'uploading' ? 'Processing...' : 'Add to Library'} {status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'}
</Button> </Button>
</Stack> </Stack>
</Paper> </Paper>