metadata functionality is working

This commit is contained in:
stephen 2026-01-12 22:53:20 +11:00
parent c71e0bf602
commit 3a909e6265
7 changed files with 271 additions and 401 deletions

View file

@ -352,3 +352,7 @@ Project-1 ID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1 parentID 0371
AS-NZS3000-2018.pdf parentID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1
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

Binary file not shown.

102
package-lock.json generated
View file

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

View file

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

View file

@ -2,18 +2,69 @@
import { useState } from "react";
import {
Button, CircularProgress, Box, Chip, IconButton, Tooltip, Typography
Button, CircularProgress, Box, Chip, IconButton, Tooltip, Typography, Stack, TextField, InputAdornment
} from "@mui/material";
import {
DataGrid,
GridColDef,
Toolbar,
QuickFilter,
QuickFilterControl,
QuickFilterClear,
} from "@mui/x-data-grid";
import SyncIcon from "@mui/icons-material/Sync";
import RefreshIcon from "@mui/icons-material/Refresh";
import FolderIcon from "@mui/icons-material/Folder";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import DeleteIcon from "@mui/icons-material/Delete";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import SearchIcon from '@mui/icons-material/Search';
import CancelIcon from '@mui/icons-material/Cancel';
import { syncOneDrive } from "./sync-actions";
import { deleteFileAction } from "./actions"; // Un-commented and assumed active
import { deleteFileAction } from "./actions";
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 {
initialFiles: any[];
user?: {
@ -26,19 +77,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const [loading, setLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter();
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 => {
if (!parentId) return "WebCalibre";
const parent = initialFiles.find((f) => f.id === parentId);
if (!parent) return "WebCalibre";
// Recursive lookup for breadcrumbs
const prefix = parent.parentId ? `${getVirtualPath(parent.parentId)} / ` : "";
return `${prefix}${parent.name}`;
};
@ -50,7 +94,6 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
router.refresh();
} catch (error) {
console.error("Sync failed:", error);
alert("Failed to sync OneDrive");
} finally {
setLoading(false);
}
@ -59,13 +102,11 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const handleRefresh = () => {
setIsRefreshing(true);
router.refresh();
// Visual feedback for the refresh action
setTimeout(() => setIsRefreshing(false), 800);
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
try {
await deleteFileAction(id);
router.refresh();
@ -82,49 +123,28 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
minWidth: 250,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{params.row.isFolder ? (
<FolderIcon sx={{ color: '#FFB020' }} /> // Folder yellow
) : (
<InsertDriveFileIcon color="action" />
)}
<Typography variant="body2" sx={{ fontWeight: params.row.isFolder ? 600 : 400 }}>
{params.value}
</Typography>
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
<Typography variant="body2">{params.value}</Typography>
</Box>
)
},
{
field: "parentId",
headerName: "Location (Project)",
headerName: "Location",
flex: 1,
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>
);
}
renderCell: (params) => <Chip label={getVirtualPath(params.value)} size="small" variant="outlined" />
},
{ field: "description", headerName: "Description", flex: 1 },
{
field: "type",
headerName: "Type",
width: 100,
width: 120,
// Fixed: Pulling type from metadata since it's not a top-level property
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;
return row.metadata?.type || (row.isFolder ? "Folder" : "File");
},
renderCell: (params) => (
<Typography variant="caption" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}>
{params.value}
</Typography>
)
@ -133,40 +153,31 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
field: "size",
headerName: "Size",
width: 120,
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`;
}
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB`
},
{
field: "actions",
headerName: "Actions",
width: 100,
sortable: false,
align: 'right',
headerAlign: 'right',
renderCell: (params) => {
const isOwner = params.row.ownerId === user?.id;
if (isAdmin || isOwner) {
return (
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation();
handleDelete(params.row.id, params.row.name);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
);
}
return null;
renderCell: (params) => (
(isAdmin || params.row.ownerId === user?.id) && (
<IconButton size="small" color="error" onClick={() => handleDelete(params.row.id, params.row.name)}>
<DeleteIcon fontSize="small" />
</IconButton>
)
)
},
{
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(" ");
}
}
];
@ -174,53 +185,32 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
return (
<Box className="space-y-4">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
<Button
variant="outlined"
startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing..." : "Refresh List"}
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}>
Refresh List
</Button>
<Button
variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleSync}
disabled={loading}
>
{loading ? "Syncing..." : "Sync OneDrive"}
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}>
Sync OneDrive
</Button>
</Box>
<Box sx={{
height: 700,
width: "100%",
bgcolor: 'background.paper',
borderRadius: 3,
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
overflow: 'hidden'
}}>
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
<DataGrid
rows={initialFiles}
columns={columns}
pageSizeOptions={[10, 25, 50]}
slots={{ toolbar: CustomToolbar }}
showToolbar
disableRowSelectionOnClick
initialState={{
pagination: { paginationModel: { pageSize: 10 } },
sorting: {
sortModel: [{ field: 'name', sort: 'asc' }],
columns: {
columnVisibilityModel: {
metadata_search: false,
},
},
}}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-columnHeaders': {
bgcolor: '#f8f9fa',
borderBottom: '1px solid #eee',
},
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0',
},
sx={{
border: 'none',
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
}}
/>
</Box>

View file

@ -5,42 +5,8 @@ import { getFreshAccessToken } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
/**
* Creates a virtual folder in the database.
* We explicitly set the metadata type to "FOLDER" so the Dashboard icon
* and Location logic work immediately.
*/
export async function createFolderAction(name: string, parentId: string | null = null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// createFolderAction remains the same...
// We generate a manual UUID because the schema requires 'id' but has no default generator
const internalId = crypto.randomUUID();
await prisma.fileNode.create({
data: {
id: internalId,
name: name,
isFolder: true,
path: `/virtual/${name}`,
ownerId: session.user.id,
parentId: parentId || null,
metadata: {
type: "FOLDER",
mimeType: "inode/directory"
}
}
});
revalidatePath("/upload");
revalidatePath("/dashboard");
return { success: true };
}
/**
* Uploads a file to OneDrive into a unique UUID folder
* and links it to a virtual parent (Project/Folder) in the DB.
*/
export async function uploadFileAction(formData: FormData) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
@ -48,79 +14,58 @@ export async function uploadFileAction(formData: FormData) {
const file = formData.get("file") as File;
const description = formData.get("description") as string || "";
const parentId = formData.get("parentId") as string | null;
// Parse the dynamic metadata from the client
const customMetadataRaw = formData.get("customMetadata") as string;
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
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 rootFolder = "WebCalibre";
const internalId = crypto.randomUUID();
// 1. Create the unique UUID folder on OneDrive inside WebCalibre
// 1. Create OneDrive Storage Folder
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
name: internalId,
folder: {},
"@microsoft.graph.conflictBehavior": "fail"
})
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: internalId, folder: {} })
});
if (!createSubFolderRes.ok) {
const err = await createSubFolderRes.json();
console.error("OneDrive Folder Creation Error:", err);
throw new Error("Storage directory creation failed");
}
if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed");
const subFolderData = await createSubFolderRes.json();
// 2. Create Upload Session for the file
// 2. Upload Session & File Transfer (Existing logic is fine)
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
const sessionRes = await fetch(sessionUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
});
const { uploadUrl } = await sessionRes.json();
const buffer = Buffer.from(await file.arrayBuffer());
// 3. Perform the actual upload
const uploadRes = await fetch(uploadUrl, {
await fetch(uploadUrl, {
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
});
if (!uploadRes.ok) throw new Error("OneDrive stream failed");
const driveItem = await uploadRes.json();
// 4. Record in Database with full metadata and virtual hierarchy
// 3. Final Database Record with Merged Metadata
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
await prisma.fileNode.create({
data: {
id: internalId, // Matches the folder name on OneDrive
oneDriveId: driveItem.id,
id: internalId,
name: file.name,
description: description,
size: BigInt(file.size),
isFolder: false,
path: `/${rootFolder}/${internalId}/${file.name}`,
ownerId: session.user.id,
parentId: parentId || null, // Virtual link to the Project folder
parentId: parentId || null,
metadata: {
type: extension,
...customMetadata, // User's dynamic keys (Latitude, Author, etc.)
type: extension, // System keys (preserved for UI icons)
mimeType: file.type
}
}

View file

@ -3,19 +3,18 @@
import { useState } from "react";
import {
Box, Button, Typography, Paper, LinearProgress, Stack,
TextField, MenuItem, IconButton, Tooltip, Divider,
InputAdornment
TextField, MenuItem, IconButton, Tooltip, Divider,
Grid
} from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import FolderIcon from "@mui/icons-material/Folder";
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 { uploadFileAction, createFolderAction } from "./_actions";
/**
* Format bytes to human readable string (MiB/KiB)
*/
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
@ -24,61 +23,54 @@ const formatFileSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
interface Folder {
id: string;
name: string;
parentId: string | null;
interface MetadataPair {
key: string;
value: string;
}
interface UploadViewProps {
user: any;
folders?: Folder[];
}
export default function UploadView({ user, folders = [] }: UploadViewProps) {
export default function UploadView({ user, folders = [] }: any) {
const router = useRouter();
// Form State
const [file, setFile] = useState<File | null>(null);
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState("");
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
// Dynamic Metadata State
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
// Folder Creation State
const [showFolderInput, setShowFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB Limit
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
const removeMetadataRow = (index: number) => {
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
};
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
setIsCreatingFolder(true);
try {
// Creates folder nested under current selection if parentId exists
await createFolderAction(newFolderName, parentId || null);
setNewFolderName("");
setShowFolderInput(false);
router.refresh();
} catch (err) {
alert("Error creating folder");
} finally {
setIsCreatingFolder(false);
}
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
const updated = [...customMetadata];
updated[index][field] = val;
setCustomMetadata(updated);
};
const handleUpload = async () => {
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
alert(`File is too large! Max allowed: ${formatFileSize(MAX_FILE_SIZE)}.`);
return;
}
setStatus('uploading');
const formData = new FormData();
formData.append("file", file);
formData.append("description", description);
formData.append("parentId", parentId);
// Pass custom metadata as a JSON string
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 {
const result = await uploadFileAction(formData);
@ -86,11 +78,11 @@ export default function UploadView({ user, folders = [] }: UploadViewProps) {
setStatus('success');
setFile(null);
setDescription("");
setCustomMetadata([]);
router.refresh();
}
} catch (err) {
console.error(err);
alert("Upload failed. Check server logs.");
alert("Upload failed.");
setStatus('idle');
}
};
@ -102,156 +94,95 @@ export default function UploadView({ user, folders = [] }: UploadViewProps) {
</Typography>
<Stack spacing={4} sx={{ mt: 4 }}>
{/* Section 1: Destination Folder */}
{/* Section 1: Destination (Condensed for brevity, same as your original) */}
<Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon color="primary" /> 1. Destination
</Typography>
<Stack direction="row" spacing={1}>
<TextField
select
fullWidth
label="Target Project / Folder"
select fullWidth label="Target Project / Folder"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
disabled={status === 'uploading'}
helperText="Files will be virtually organized into this folder."
>
<MenuItem value=""><em>-- Root (Main Library) --</em></MenuItem>
{folders.map((f) => (
<MenuItem key={f.id} value={f.id} sx={{ pl: f.parentId ? 4 : 2 }}>
{f.parentId ? `${f.name}` : f.name}
</MenuItem>
<MenuItem value=""><em>-- Root --</em></MenuItem>
{folders.map((f: any) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<Tooltip title="Create New Folder Inside Selected">
<IconButton
color="primary"
onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, width: 56, height: 56 }}
>
<CreateNewFolderIcon />
</IconButton>
</Tooltip>
<IconButton color="primary" onClick={() => setShowFolderInput(!showFolderInput)} sx={{ border: '1px solid #ccc', borderRadius: 1 }}>
<CreateNewFolderIcon />
</IconButton>
</Stack>
{showFolderInput && (
<Stack direction="row" spacing={1} sx={{ mt: 2, p: 2, bgcolor: 'action.hover', borderRadius: 2 }}>
<TextField
size="small"
fullWidth
placeholder="New folder name..."
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
autoFocus
disabled={isCreatingFolder}
/>
<Button
variant="contained"
onClick={handleCreateFolder}
disabled={isCreatingFolder || !newFolderName.trim()}
>
{isCreatingFolder ? "..." : "Create"}
</Button>
</Stack>
)}
</Box>
<Divider />
{/* Section 2: File Upload Area */}
{/* Section 2: Upload Area */}
<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
</Typography>
<Box
sx={{
p: 5,
mt: 1,
border: '2px dashed',
borderColor: file ? 'primary.main' : 'divider',
borderRadius: 3,
textAlign: 'center',
bgcolor: file ? 'rgba(25, 118, 210, 0.04)' : '#fafafa',
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.02)' }
}}
>
<input
type="file" id="file-input" hidden
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">
{formatFileSize(file.size)}
</Typography>
</Box>
)}
</Box>
<input type="file" id="file-input" hidden onChange={(e) => setFile(e.target.files?.[0] || null)} />
<label htmlFor="file-input">
<Button variant="outlined" component="span" fullWidth sx={{ p: 4, borderStyle: 'dashed' }}>
{file ? file.name : "Select File"}
</Button>
</label>
</Box>
{/* Section 3: Notes */}
{/* Section 3: Dynamic Metadata Area */}
<Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> 3. Metadata
</Typography>
<TextField
label="Description / Notes"
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!
<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>
<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}>
{customMetadata.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={5}>
<TextField
fullWidth size="small" placeholder="Key (e.g. Latitude)"
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>
<Button
variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload}
>
{status === 'uploading' ? 'Processing...' : 'Add to Library'}
</Button>
</Stack>
</Paper>
);