efore work on second problem for hash
This commit is contained in:
parent
f37b1ee731
commit
abc0e052ed
9 changed files with 5070 additions and 26 deletions
4452
docs/html-discussions-with-AI/WebCalibre-(15_02_2026 23:03:17).html
Normal file
4452
docs/html-discussions-with-AI/WebCalibre-(15_02_2026 23:03:17).html
Normal file
File diff suppressed because one or more lines are too long
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -28,6 +28,7 @@
|
|||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
|
|
@ -3715,6 +3716,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
|
|
@ -5341,6 +5351,18 @@
|
|||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -7975,6 +7997,23 @@
|
|||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz",
|
||||
"integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { auth } from "@/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createFileNode } from "@/data-access/file-nodes";
|
||||
import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
|
||||
/**
|
||||
|
|
@ -107,4 +107,92 @@ await createFileNode({
|
|||
console.error("Upload refactor error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* NEW: FETCH ALL VIRTUAL FOLDERS
|
||||
* This is the exact export the compiler is looking for.
|
||||
*/
|
||||
export async function getFoldersAction() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
try {
|
||||
// This calls the helper in your data-access/file-nodes.ts
|
||||
const folders = await getAllFolders();
|
||||
return folders;
|
||||
} catch (error) {
|
||||
console.error("Error in getFoldersAction:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* EXECUTE BULK ITEM
|
||||
* Orchestrates the physical upload to OneDrive and the database record
|
||||
* creation/update. This is the heart of the Bulk Upload and Restore system.
|
||||
*/
|
||||
export async function executeBulkItemAction(formData: FormData) {
|
||||
const session = await auth();
|
||||
|
||||
// 1. Security check
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Unauthorized: You must be logged in to perform bulk actions.");
|
||||
}
|
||||
|
||||
const file = formData.get("file") as File;
|
||||
const hash = formData.get("hash") as string;
|
||||
const targetFolderIdRaw = formData.get("targetFolderId") as string | null;
|
||||
|
||||
// Standardize the target folder ID
|
||||
const targetFolderId = (targetFolderIdRaw === "" || targetFolderIdRaw === "root")
|
||||
? null
|
||||
: targetFolderIdRaw;
|
||||
|
||||
if (!file || !hash) {
|
||||
throw new Error("Missing required file or hash data for bulk operation.");
|
||||
}
|
||||
|
||||
try {
|
||||
const rootFolder = "WebCalibre";
|
||||
// We generate a unique internal ID to serve as the physical folder name on OneDrive
|
||||
const internalId = crypto.randomUUID();
|
||||
|
||||
// 2. Physical Storage (OneDrive)
|
||||
// Ensure the root app folder exists first
|
||||
await ensureOneDriveFolder(session.user.id, rootFolder);
|
||||
|
||||
// Create the unique subfolder for this file (to avoid collisions and match single-upload logic)
|
||||
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
|
||||
const subFolderData = await subFolderRes.json();
|
||||
|
||||
// Perform the actual binary upload
|
||||
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
|
||||
|
||||
// 3. Database Logic (Data Access Layer)
|
||||
// We use upsertFileNodeByHash to handle the Disaster Recovery case:
|
||||
// If the hash matches an existing record, it updates the OneDrive link.
|
||||
// If not, it creates a brand new record.
|
||||
const result = await upsertFileNodeByHash({
|
||||
name: file.name,
|
||||
hash: hash,
|
||||
oneDriveId: uploadedFileData.id,
|
||||
parentId: targetFolderId,
|
||||
size: BigInt(file.size),
|
||||
ownerId: session.user.id,
|
||||
});
|
||||
|
||||
// 4. Refresh UI
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: result.id,
|
||||
mode: result.createdAt === result.updatedAt ? 'created' : 'restored'
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Bulk Item Execution Failure:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "An unexpected error occurred during upload."
|
||||
};
|
||||
}
|
||||
}
|
||||
243
src/app/upload/bulk/page.tsx
Normal file
243
src/app/upload/bulk/page.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import {
|
||||
Box, Button, Typography, Paper, Table, TableBody,
|
||||
TableCell, TableContainer, TableHead, TableRow, Chip,
|
||||
Stack, MenuItem, Select, IconButton, Tooltip, LinearProgress
|
||||
} from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
import { calculateFileHash } from '@/lib/hashing-client';
|
||||
import { checkDuplicateAction, getFoldersAction, executeBulkItemAction } from '../_actions';
|
||||
|
||||
interface UploadQueueItem {
|
||||
id: string;
|
||||
file: File;
|
||||
path: string;
|
||||
hash: string | null;
|
||||
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
|
||||
targetFolderId: string;
|
||||
}
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [dbFolders, setDbFolders] = useState<{id: string, name: string}[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFolders() {
|
||||
try {
|
||||
const folders = await getFoldersAction();
|
||||
setDbFolders(folders);
|
||||
} catch (err) {
|
||||
console.error("Failed to load folders:", err);
|
||||
}
|
||||
}
|
||||
loadFolders();
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const newItems = acceptedFiles.map(file => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
path: (file as any).path || file.name,
|
||||
hash: null,
|
||||
status: 'queued' as const,
|
||||
targetFolderId: ""
|
||||
}));
|
||||
setQueue(prev => [...prev, ...newItems]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
||||
|
||||
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
|
||||
setQueue(curr => curr.map(item => item.id === id ? { ...item, ...updates } : item));
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setQueue(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const copyFirstRowDestination = () => {
|
||||
if (queue.length < 2) return;
|
||||
const firstFolderId = queue[0].targetFolderId;
|
||||
setQueue(current => current.map(item => ({ ...item, targetFolderId: firstFolderId })));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProcessing) return;
|
||||
const runAnalysis = async () => {
|
||||
const nextIndex = queue.findIndex(item => item.status === 'queued');
|
||||
if (nextIndex === -1) { setIsProcessing(false); return; }
|
||||
|
||||
const item = queue[nextIndex];
|
||||
try {
|
||||
updateItem(item.id, { status: 'hashing' });
|
||||
const hash = await calculateFileHash(item.file);
|
||||
updateItem(item.id, { status: 'checking', hash });
|
||||
const existing = await checkDuplicateAction(hash);
|
||||
|
||||
updateItem(item.id, {
|
||||
status: existing ? 'duplicate' : 'ready',
|
||||
targetFolderId: existing?.parentId || item.targetFolderId
|
||||
});
|
||||
} catch (err) {
|
||||
updateItem(item.id, { status: 'error' });
|
||||
}
|
||||
};
|
||||
runAnalysis();
|
||||
}, [queue, isProcessing]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
setIsExecuting(true);
|
||||
const itemsToProcess = queue.filter(i => i.status === 'ready' || i.status === 'duplicate');
|
||||
|
||||
const BATCH_SIZE = 3;
|
||||
for (let i = 0; i < itemsToProcess.length; i += BATCH_SIZE) {
|
||||
const batch = itemsToProcess.slice(i, i + BATCH_SIZE);
|
||||
|
||||
await Promise.all(batch.map(async (item) => {
|
||||
updateItem(item.id, { status: 'uploading' });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", item.file);
|
||||
formData.append("hash", item.hash || "");
|
||||
formData.append("targetFolderId", item.targetFolderId);
|
||||
|
||||
const result = await executeBulkItemAction(formData);
|
||||
if (result.success) {
|
||||
updateItem(item.id, { status: 'success' });
|
||||
} else {
|
||||
console.error(`Execution failed for ${item.path}:`, result.error);
|
||||
updateItem(item.id, { status: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Network error for ${item.path}:`, error);
|
||||
updateItem(item.id, { status: 'error' });
|
||||
}
|
||||
}));
|
||||
}
|
||||
setIsExecuting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4, maxWidth: 1400, mx: 'auto' }}>
|
||||
<Typography variant="h4" fontWeight={900} gutterBottom color="primary">
|
||||
Bulk Upload & Recovery
|
||||
</Typography>
|
||||
|
||||
{isExecuting && <LinearProgress sx={{ mb: 2 }} />}
|
||||
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
p: 4, mb: 4, textAlign: 'center', cursor: 'pointer',
|
||||
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
|
||||
bgcolor: isDragActive ? 'primary.50' : 'grey.50'
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
|
||||
<FolderIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Typography>Drag Folders or Files Here</Typography>
|
||||
</Paper>
|
||||
|
||||
{queue.length > 0 && (
|
||||
<>
|
||||
<TableContainer component={Paper} sx={{ mb: 3, maxHeight: 600 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 50 }} />
|
||||
<TableCell><strong>File Path</strong></TableCell>
|
||||
<TableCell><strong>Status</strong></TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<strong>Destination</strong>
|
||||
<IconButton size="small" onClick={copyFirstRowDestination} color="primary" disabled={isExecuting}>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell align="right"><strong>Size</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{queue.map((item) => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(item.id)}
|
||||
disabled={isExecuting || item.status === 'uploading'}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem', maxWidth: 300, overflow: 'hidden' }}>
|
||||
{item.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.status.toUpperCase()}
|
||||
size="small"
|
||||
color={
|
||||
item.status === 'success' ? 'success' :
|
||||
item.status === 'duplicate' ? 'warning' :
|
||||
item.status === 'error' ? 'error' : 'default'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.targetFolderId}
|
||||
onChange={(e) => updateItem(item.id, { targetFolderId: e.target.value })}
|
||||
size="small" fullWidth displayEmpty
|
||||
disabled={item.status === 'success' || isExecuting}
|
||||
sx={{ fontSize: '0.8rem' }}
|
||||
>
|
||||
<MenuItem value="">Root Directory</MenuItem>
|
||||
{dbFolders.map(f => <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>)}
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{(item.file.size / 1024 / 1024).toFixed(2)}MB
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button variant="outlined" onClick={() => setQueue([])} disabled={isExecuting}>Clear Queue</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={() => setIsProcessing(true)}
|
||||
disabled={isProcessing || isExecuting}
|
||||
>
|
||||
Analyze
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<CloudUploadIcon />}
|
||||
onClick={handleExecute}
|
||||
disabled={isProcessing || isExecuting || !queue.some(i => i.status === 'ready' || i.status === 'duplicate')}
|
||||
>
|
||||
Execute Upload/Restore
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +1,181 @@
|
|||
// src/app/upload/page.tsx
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import UploadView from "./upload-view"; // This is the Client Component
|
||||
import { Container } from "@mui/material";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
// src/app/upload/bulk/page.tsx
|
||||
|
||||
// 1. Rename to UploadPage to avoid conflict with the 'UploadView' import
|
||||
// 2. Add 'async' so you can use 'await' inside
|
||||
export default async function UploadPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) redirect("/");
|
||||
"use client";
|
||||
|
||||
// 3. Fetch folders. Renamed variable to 'allFolders' to avoid any confusion
|
||||
const allFolders = await prisma.fileNode.findMany({
|
||||
where: {
|
||||
isFolder: true,
|
||||
ownerId: session.user.id // Good practice: only show user's own folders
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, parentId: true }
|
||||
});
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import {
|
||||
Box, Button, Typography, Paper, Table, TableBody,
|
||||
TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
|
||||
Alert, Stack
|
||||
} from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
|
||||
// --- OUR UTILITIES ---
|
||||
import { calculateFileHash } from '@/lib/hashing-client';
|
||||
import { checkDuplicateAction } from '../_actions';
|
||||
|
||||
interface UploadQueueItem {
|
||||
id: string;
|
||||
file: File;
|
||||
path: string;
|
||||
hash: string | null;
|
||||
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// 1. Handle File & Folder Drops
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const newItems = acceptedFiles.map(file => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
path: (file as any).path || file.name, // Captures subfolder structure
|
||||
hash: null,
|
||||
status: 'queued' as const,
|
||||
}));
|
||||
setQueue(prev => [...prev, ...newItems]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
||||
|
||||
// 2. The "Processing Engine"
|
||||
// This effect runs whenever the queue changes or isProcessing toggles
|
||||
useEffect(() => {
|
||||
if (!isProcessing) return;
|
||||
|
||||
const runQueue = async () => {
|
||||
// Find the next file that hasn't been hashed/checked yet
|
||||
const nextIndex = queue.findIndex(item => item.status === 'queued');
|
||||
|
||||
if (nextIndex === -1) {
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = queue[nextIndex];
|
||||
|
||||
try {
|
||||
// Step A: Hashing
|
||||
updateItem(item.id, { status: 'hashing' });
|
||||
const hash = await calculateFileHash(item.file);
|
||||
|
||||
// Step B: Duplicate Check
|
||||
updateItem(item.id, { status: 'checking', hash });
|
||||
const existing = await checkDuplicateAction(hash);
|
||||
|
||||
// Step C: Mark Results
|
||||
updateItem(item.id, {
|
||||
status: existing ? 'duplicate' : 'ready'
|
||||
});
|
||||
} catch (err) {
|
||||
updateItem(item.id, { status: 'error', error: 'Process failed' });
|
||||
}
|
||||
};
|
||||
|
||||
runQueue();
|
||||
}, [queue, isProcessing]);
|
||||
|
||||
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
|
||||
setQueue(current => current.map(item => item.id === id ? { ...item, ...updates } : item));
|
||||
};
|
||||
|
||||
const duplicateCount = queue.filter(i => i.status === 'duplicate').length;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||
{/* 4. Render the Client Component and pass the data */}
|
||||
<UploadView user={session.user} folders={allFolders} />
|
||||
</Container>
|
||||
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
|
||||
<Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
|
||||
Bulk Uploads & Restore
|
||||
</Typography>
|
||||
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
p: 6, mb: 4, textAlign: 'center', cursor: 'pointer',
|
||||
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
|
||||
bgcolor: isDragActive ? 'primary.50' : 'background.paper',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
|
||||
<FolderIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6">Drop Folders or Files Here</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Perfect for camera imports or full system restores
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{duplicateCount > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{duplicateCount} duplicate(s) found. These files already exist in your library.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{queue.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ maxHeight: 500, borderRadius: 2 }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Location / Path</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>SHA-256 Hash</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{queue.map((item) => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{item.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(item.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.status.toUpperCase()}
|
||||
size="small"
|
||||
color={
|
||||
item.status === 'duplicate' ? 'warning' :
|
||||
item.status === 'ready' ? 'info' :
|
||||
item.status === 'success' ? 'success' : 'default'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
|
||||
{item.hash ? `${item.hash.substring(0, 16)}...` : '---'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={isProcessing ? <LinearProgress sx={{ width: 20 }} /> : <CloudUploadIcon />}
|
||||
disabled={isProcessing || queue.length === 0}
|
||||
onClick={() => setIsProcessing(true)}
|
||||
>
|
||||
{isProcessing ? 'Analyzing...' : `Analyze ${queue.length} Files`}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
disabled={isProcessing}
|
||||
onClick={() => setQueue([])}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CloudSyncIcon from '@mui/icons-material/CloudSync'; // Great icon for Bulk/Restore
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import Link from 'next/link';
|
||||
|
|
@ -44,6 +45,7 @@ export default function Navbar({ user }: NavbarProps) {
|
|||
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
||||
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
||||
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
|
||||
{ text: 'Bulk Upload / Restore', icon: <CloudSyncIcon />, href: '/upload/bulk' },
|
||||
];
|
||||
|
||||
// Only push Settings if the user has Admin rights
|
||||
|
|
|
|||
|
|
@ -118,5 +118,73 @@ export async function upsertFileNode(oneDriveId: string, data: {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FETCH: Get all virtual folders for selection in dropdowns.
|
||||
* Used by the BulkUpload page to set destinations.
|
||||
*/
|
||||
export async function getAllFolders() {
|
||||
return await prisma.fileNode.findMany({
|
||||
where: {
|
||||
isFolder: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT BY HASH: The core of the Disaster Recovery process.
|
||||
* If a hash exists, we update the OneDrive ID (Restoring the link).
|
||||
* If not, we create a new entry.
|
||||
*/
|
||||
export async function upsertFileNodeByHash(data: {
|
||||
name: string;
|
||||
hash: string;
|
||||
oneDriveId: string;
|
||||
parentId?: string | null;
|
||||
size: bigint;
|
||||
ownerId: string;
|
||||
mimeType?: string;
|
||||
}) {
|
||||
// First, check if we have a record with this hash
|
||||
const existing = await prisma.fileNode.findFirst({
|
||||
where: { hash: data.hash }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 🛡️ DISASTER RECOVERY MODE
|
||||
// The database knows about this file, but the OneDrive link is old.
|
||||
// We update the existing record with the NEW cloud ID.
|
||||
return await prisma.fileNode.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
oneDriveId: data.oneDriveId,
|
||||
// Optional: Update parent if the user chose a new folder during restore
|
||||
parentId: data.parentId || existing.parentId,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ✨ NEW UPLOAD MODE
|
||||
return await prisma.fileNode.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
oneDriveId: data.oneDriveId,
|
||||
name: data.name,
|
||||
hash: data.hash,
|
||||
size: data.size,
|
||||
isFolder: false,
|
||||
ownerId: data.ownerId,
|
||||
parentId: data.parentId || null,
|
||||
path: data.name, // Simplified for now
|
||||
metadata: {}, // Placeholder for extractMetadata logic
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// src/lib/hashing.ts
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue