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 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

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.6", "@mui/material": "^7.3.7",
"@mui/material-nextjs": "^7.3.6", "@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/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.6", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz",
"integrity": "sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==", "integrity": "sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg==",
"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.6", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz",
"integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", "integrity": "sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.6", "@mui/core-downloads-tracker": "^7.3.7",
"@mui/system": "^7.3.6", "@mui/system": "^7.3.7",
"@mui/types": "^7.4.9", "@mui/types": "^7.4.10",
"@mui/utils": "^7.3.6", "@mui/utils": "^7.3.7",
"@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.1.3", "csstype": "^3.2.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.2.0", "react-is": "^19.2.3",
"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.6", "@mui/material-pigment-css": "^7.3.7",
"@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.6", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.6.tgz", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.7.tgz",
"integrity": "sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==", "integrity": "sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.6", "@mui/utils": "^7.3.7",
"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.6", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.6.tgz", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.7.tgz",
"integrity": "sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==", "integrity": "sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg==",
"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.1.3", "csstype": "^3.2.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.6", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz", "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.7.tgz",
"integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==", "integrity": "sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.6", "@mui/private-theming": "^7.3.7",
"@mui/styled-engine": "^7.3.6", "@mui/styled-engine": "^7.3.7",
"@mui/types": "^7.4.9", "@mui/types": "^7.4.10",
"@mui/utils": "^7.3.6", "@mui/utils": "^7.3.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csstype": "^3.1.3", "csstype": "^3.2.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.9", "version": "7.4.10",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.10.tgz",
"integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==", "integrity": "sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ==",
"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.6", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.7.tgz",
"integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==", "integrity": "sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.9", "@mui/types": "^7.4.10",
"@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.0" "react-is": "^19.2.3"
}, },
"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.23.0", "version": "8.24.0",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.23.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.24.0.tgz",
"integrity": "sha512-fv8AiibTgwPKJsXtYdc5bYDDW3EQp/ieQ0iUejvZ2JbYikMIPTYjI4pHy7zW3F3RDsi6DGQnw5AW6LeoDcT0fA==", "integrity": "sha512-OCTrskRlxwJZ+vYWbgQnQPXWNMA/nY/RIkoESjbCG38N9fJicVCdyjszjcQn9V1NQH4fwkMlIN+DVU6zzbqJxQ==",
"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.23.0", "@mui/x-internals": "8.24.0",
"@mui/x-virtualizer": "0.3.0", "@mui/x-virtualizer": "0.3.1",
"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.23.0", "version": "8.24.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.24.0.tgz",
"integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==", "integrity": "sha512-tHPk8eUcuwvhNsAc4zcia8y+issjC+bLGssrQQSdLLeLIf9ewFbcwAURNTSnNhreyj/4RJOemVrSxKXpjkdVvQ==",
"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.0", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.1.tgz",
"integrity": "sha512-ScQ3xullKQAtbIT9N7PVhgW9BDTo2Hu8fr/+N08TdRMJcq8bsVJB25mlcHk6zxjlgCuojqaeGfw71S1FOvyCXQ==", "integrity": "sha512-MhQ7U/zmSofBxeCRMiolhZWJ80RUJMFFMRqeehJu5GqscnudiVcplZb0eNsAkz9IjnJTyrjyLS0PY+kn78enOw==",
"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.23.0" "@mui/x-internals": "8.24.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.6", "@mui/material": "^7.3.7",
"@mui/material-nextjs": "^7.3.6", "@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/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

@ -2,18 +2,69 @@
import { useState } from "react"; import { useState } from "react";
import { import {
Button, CircularProgress, Box, Chip, IconButton, Tooltip, Typography Button, CircularProgress, Box, Chip, IconButton, Tooltip, Typography, Stack, TextField, InputAdornment
} 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 { 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 { syncOneDrive } from "./sync-actions";
import { deleteFileAction } from "./actions"; // Un-commented and assumed active import { deleteFileAction } from "./actions";
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?: {
@ -26,19 +77,12 @@ 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}`;
}; };
@ -50,7 +94,6 @@ 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);
} }
@ -59,13 +102,11 @@ 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();
@ -82,49 +123,28 @@ 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 ? ( {params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
<FolderIcon sx={{ color: '#FFB020' }} /> // Folder yellow <Typography variant="body2">{params.value}</Typography>
) : (
<InsertDriveFileIcon color="action" />
)}
<Typography variant="body2" sx={{ fontWeight: params.row.isFolder ? 600 : 400 }}>
{params.value}
</Typography>
</Box> </Box>
) )
}, },
{ {
field: "parentId", field: "parentId",
headerName: "Location (Project)", headerName: "Location",
flex: 1, flex: 1,
minWidth: 200, renderCell: (params) => <Chip label={getVirtualPath(params.value)} size="small" variant="outlined" />
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: 100, width: 120,
// Fixed: Pulling type from metadata since it's not a top-level property
valueGetter: (value, row) => { valueGetter: (value, row) => {
// Priority: Metadata -> isFolder check -> fallback return row.metadata?.type || (row.isFolder ? "Folder" : "File");
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={{ fontWeight: 'bold', color: 'text.secondary' }}> <Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}>
{params.value} {params.value}
</Typography> </Typography>
) )
@ -133,40 +153,31 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
field: "size", field: "size",
headerName: "Size", headerName: "Size",
width: 120, width: 120,
valueGetter: (value, row) => { renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB`
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: 100, width: 100,
sortable: false,
align: 'right', align: 'right',
headerAlign: '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>
<IconButton )
size="small" )
color="error" },
onClick={(e) => { {
e.stopPropagation(); field: "metadata_search",
handleDelete(params.row.id, params.row.name); headerName: "Metadata Search",
}} width: 0,
> valueGetter: (value, row) => {
<DeleteIcon fontSize="small" /> if (!row.metadata) return "";
</IconButton> return Object.entries(row.metadata)
); .filter(([k]) => k !== 'type' && k !== 'mimeType')
} .map(([k, v]) => `${k}:${v}`)
return null; .join(" ");
} }
} }
]; ];
@ -174,53 +185,32 @@ 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 <Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}>
variant="outlined" Refresh List
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}>
<Button Sync OneDrive
variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleSync}
disabled={loading}
>
{loading ? "Syncing..." : "Sync OneDrive"}
</Button> </Button>
</Box> </Box>
<Box sx={{ <Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
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}
pageSizeOptions={[10, 25, 50]} slots={{ toolbar: CustomToolbar }}
showToolbar
disableRowSelectionOnClick
initialState={{ initialState={{
pagination: { paginationModel: { pageSize: 10 } }, columns: {
sorting: { columnVisibilityModel: {
sortModel: [{ field: 'name', sort: 'asc' }], metadata_search: false,
},
}, },
}} }}
disableRowSelectionOnClick sx={{
sx={{ border: 'none',
border: 'none', '& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
'& .MuiDataGrid-columnHeaders': { '& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
bgcolor: '#f8f9fa',
borderBottom: '1px solid #eee',
},
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0',
},
}} }}
/> />
</Box> </Box>

View file

@ -5,42 +5,8 @@ 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...
* 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");
// 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) { export async function uploadFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
@ -48,79 +14,58 @@ 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 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"); 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(); 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`, { const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
method: "POST", method: "POST",
headers: { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
Authorization: `Bearer ${accessToken}`, body: JSON.stringify({ name: internalId, folder: {} })
"Content-Type": "application/json"
},
body: JSON.stringify({
name: internalId,
folder: {},
"@microsoft.graph.conflictBehavior": "fail"
})
}); });
if (!createSubFolderRes.ok) { if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed");
const err = await createSubFolderRes.json();
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 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 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: { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
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, {
// 3. Perform the actual upload
const uploadRes = await fetch(uploadUrl, {
method: "PUT", method: "PUT",
headers: { headers: { "Content-Length": `${file.size}`, "Content-Range": `bytes 0-${file.size - 1}/${file.size}` },
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer body: buffer
}); });
if (!uploadRes.ok) throw new Error("OneDrive stream failed"); // 3. Final Database Record with Merged Metadata
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";
await prisma.fileNode.create({ await prisma.fileNode.create({
data: { data: {
id: internalId, // Matches the folder name on OneDrive id: internalId,
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 || null, // Virtual link to the Project folder parentId: parentId || null,
metadata: { metadata: {
type: extension, ...customMetadata, // User's dynamic keys (Latitude, Author, etc.)
type: extension, // System keys (preserved for UI icons)
mimeType: file.type mimeType: file.type
} }
} }

View file

@ -3,19 +3,18 @@
import { useState } from "react"; import { useState } from "react";
import { import {
Box, Button, Typography, Paper, LinearProgress, Stack, Box, Button, Typography, Paper, LinearProgress, Stack,
TextField, MenuItem, IconButton, Tooltip, Divider, TextField, MenuItem, IconButton, Tooltip, Divider,
InputAdornment Grid
} 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 CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import FolderIcon from "@mui/icons-material/Folder"; 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";
/**
* Format bytes to human readable string (MiB/KiB)
*/
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
@ -24,61 +23,54 @@ const formatFileSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
interface Folder { interface MetadataPair {
id: string; key: string;
name: string; value: string;
parentId: string | null;
} }
interface UploadViewProps { export default function UploadView({ user, folders = [] }: any) {
user: any;
folders?: Folder[];
}
export default function UploadView({ user, folders = [] }: UploadViewProps) {
const router = useRouter(); const router = useRouter();
// 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');
// Dynamic Metadata State
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 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 () => { const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
if (!newFolderName.trim()) return; const updated = [...customMetadata];
setIsCreatingFolder(true); updated[index][field] = val;
try { setCustomMetadata(updated);
// 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 handleUpload = async () => { const handleUpload = async () => {
if (!file) return; if (!file) return;
if (file.size > MAX_FILE_SIZE) {
alert(`File is too large! Max allowed: ${formatFileSize(MAX_FILE_SIZE)}.`);
return;
}
setStatus('uploading'); 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);
// 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 { try {
const result = await uploadFileAction(formData); const result = await uploadFileAction(formData);
@ -86,11 +78,11 @@ export default function UploadView({ user, folders = [] }: UploadViewProps) {
setStatus('success'); setStatus('success');
setFile(null); setFile(null);
setDescription(""); setDescription("");
setCustomMetadata([]);
router.refresh(); router.refresh();
} }
} catch (err) { } catch (err) {
console.error(err); alert("Upload failed.");
alert("Upload failed. Check server logs.");
setStatus('idle'); setStatus('idle');
} }
}; };
@ -102,156 +94,95 @@ export default function UploadView({ user, folders = [] }: UploadViewProps) {
</Typography> </Typography>
<Stack spacing={4} sx={{ mt: 4 }}> <Stack spacing={4} sx={{ mt: 4 }}>
{/* Section 1: Destination (Condensed for brevity, same as your original) */}
{/* 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
</Typography> </Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
select select fullWidth label="Target Project / Folder"
fullWidth
label="Target Project / Folder"
value={parentId} value={parentId}
onChange={(e) => setParentId(e.target.value)} 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> <MenuItem value=""><em>-- Root --</em></MenuItem>
{folders.map((f) => ( {folders.map((f: any) => (
<MenuItem key={f.id} value={f.id} sx={{ pl: f.parentId ? 4 : 2 }}> <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
{f.parentId ? `${f.name}` : f.name}
</MenuItem>
))} ))}
</TextField> </TextField>
<Tooltip title="Create New Folder Inside Selected"> <IconButton color="primary" onClick={() => setShowFolderInput(!showFolderInput)} sx={{ border: '1px solid #ccc', borderRadius: 1 }}>
<IconButton <CreateNewFolderIcon />
color="primary" </IconButton>
onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, width: 56, height: 56 }}
>
<CreateNewFolderIcon />
</IconButton>
</Tooltip>
</Stack> </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> </Box>
<Divider /> {/* Section 2: Upload Area */}
{/* 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 type="file" id="file-input" hidden onChange={(e) => setFile(e.target.files?.[0] || null)} />
<Box <label htmlFor="file-input">
sx={{ <Button variant="outlined" component="span" fullWidth sx={{ p: 4, borderStyle: 'dashed' }}>
p: 5, {file ? file.name : "Select File"}
mt: 1, </Button>
border: '2px dashed', </label>
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>
</Box> </Box>
{/* Section 3: Notes */} {/* Section 3: Dynamic Metadata Area */}
<Box> <Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<AssignmentIcon color="primary" /> 3. Metadata <Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
</Typography> <AssignmentIcon color="primary" /> 3. Custom Attributes
<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!
</Typography> </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> </Box>
<Button
variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload}
>
{status === 'uploading' ? 'Processing...' : 'Add to Library'}
</Button>
</Stack> </Stack>
</Paper> </Paper>
); );