Create Folder in FileNode needs more work

This commit is contained in:
stephen 2026-01-09 17:58:44 +11:00
parent 59444e9c7c
commit 6feb97b7ae
5 changed files with 225 additions and 133 deletions

View file

@ -2,6 +2,12 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
experimental: {
serverActions: {
// Set this higher than your MAX_FILE_SIZE in upload-view.tsx
bodySizeLimit: '150mb',
},
},
}; };
export default nextConfig; export default nextConfig;

View file

@ -50,7 +50,7 @@ model Session {
} }
model FileNode { model FileNode {
id String @id @default(uuid()) id String @id // Removed @default(uuid()) to allow manual assignment
name String name String
size BigInt? size BigInt?
isFolder Boolean @default(false) isFolder Boolean @default(false)

View file

@ -5,99 +5,94 @@ 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";
/**
* Creates a virtual folder in the database.
* No physical folder is created on OneDrive to keep storage flat and fast.
*/
export async function createFolderAction(name: string, parentId: string | null = null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
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,
}
});
revalidatePath("/upload");
revalidatePath("/dashboard");
return { success: true };
}
/**
* Uploads a file to a unique UUID folder on OneDrive
* and links it to a virtual parent 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");
const file = formData.get("file") as File; const file = formData.get("file") as File;
const description = formData.get("description") as string || "";
const parentId = formData.get("parentId") as string | null;
if (!file) throw new Error("No file selected"); if (!file) throw new Error("No file selected");
const accessToken = await getFreshAccessToken(session.user.id); const accessToken = await getFreshAccessToken(session.user.id);
const folderName = "WebCalibre"; const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID();
// --- 1. CHECK/CREATE THE WEBCALIBRE FOLDER --- // 1. Ensure WebCalibre exists (Simplified for brevity)
// We check if the folder exists at the root of the user's OneDrive // ... (Keep the root folder check logic from your previous version)
const folderCheckUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
const folderCheck = await fetch(folderCheckUrl, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (folderCheck.status === 404) { // 2. Create the unique UUID folder on OneDrive
console.log(`📂 Folder '${folderName}' not found. Creating it...`); const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
const createFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, {
method: "POST", method: "POST",
headers: { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
Authorization: `Bearer ${accessToken}`, body: JSON.stringify({ name: internalId, folder: {}, "@microsoft.graph.conflictBehavior": "fail" })
"Content-Type": "application/json"
},
body: JSON.stringify({
name: folderName,
folder: {}, // Empty object tells Graph to create a folder
"@microsoft.graph.conflictBehavior": "fail"
})
}); });
if (!createFolderRes.ok) { if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed");
const errorData = await createFolderRes.json(); const subFolderData = await createSubFolderRes.json();
console.error("❌ Folder Creation Error:", errorData);
throw new Error("Could not create WebCalibre folder on OneDrive.");
}
}
// --- 2. CREATE UPLOAD SESSION ---
// encodeURIComponent is vital for filenames with spaces or special characters
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${encodeURIComponent(file.name)}:/createUploadSession`;
// 3. Create Upload Session & Upload
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}`, body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
"Content-Type": "application/json"
},
body: JSON.stringify({
item: {
"@microsoft.graph.conflictBehavior": "rename", // If file exists, name it "Book 1.pdf"
name: file.name
}
})
}); });
const sessionData = await sessionRes.json(); const { uploadUrl } = await sessionRes.json();
if (!sessionRes.ok) {
console.error("❌ Session Error:", sessionData);
throw new Error(sessionData.error?.message || "OneDrive session failed");
}
const { uploadUrl } = sessionData;
// --- 3. UPLOAD THE DATA BYTES ---
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
const uploadRes = await fetch(uploadUrl, { 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) { if (!uploadRes.ok) throw new Error("OneDrive stream failed");
const uploadError = await uploadRes.json();
console.error("❌ Upload Error:", uploadError);
throw new Error("Chunk upload failed");
}
const driveItem = await uploadRes.json(); const driveItem = await uploadRes.json();
// --- 4. RECORD IN POSTGRESQL (PRISMA) --- // 4. Record in DB
await prisma.fileNode.create({ await prisma.fileNode.create({
data: { data: {
id: internalId,
oneDriveId: driveItem.id, oneDriveId: driveItem.id,
name: file.name, name: file.name,
description: description,
size: BigInt(file.size), size: BigInt(file.size),
isFolder: false, isFolder: false,
path: `/${folderName}/${file.name}`, path: `/${rootFolder}/${internalId}/${file.name}`,
ownerId: session.user.id, ownerId: session.user.id,
parentId: parentId || null, // VIRTUAL HIERARCHY
metadata: { metadata: {
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN", type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
mimeType: file.type mimeType: file.type
@ -105,8 +100,6 @@ export async function uploadFileAction(formData: FormData) {
} }
}); });
// Revalidate ensures the dashboard list updates immediately
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} }

View file

@ -1,119 +1,195 @@
'use client'; 'use client';
import { useState } from "react"; import { useState } from "react";
import { Box, Button, Typography, Paper, LinearProgress, Stack } from "@mui/material"; import {
Box, Button, Typography, Paper, LinearProgress, Stack,
TextField, MenuItem, IconButton, Tooltip, Divider
} from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { uploadFileAction } from "./_actions"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import { uploadFileAction, createFolderAction } from "./_actions";
/** /**
* Helper function to convert raw bytes into a human-readable string. * Format bytes to human readable string (MiB/KiB)
* This helps the user understand exactly how large their e-book is.
*/ */
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;
const sizes = ['Bytes', 'KB', 'MB', 'GB']; const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
// Returns something like "1.45 MB" or "850 KB"
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
export default function UploadView({ user }: { user: any }) { export default function UploadView({ user, folders = [] }: { user: any, folders?: any[] }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState("");
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle'); const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Limit // Folder Creation State
const [showFolderInput, setShowFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
// --- RESTORED FILE SIZE LIMITS ---
const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MiB Limit
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
try {
await createFolderAction(newFolderName);
setNewFolderName("");
setShowFolderInput(false);
} catch (err) {
alert("Error creating folder");
}
};
const handleUpload = async () => { const handleUpload = async () => {
if (!file) return; if (!file) return;
// RESTORED: Client-side size validation
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`); alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`);
return; 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("parentId", parentId);
try { try {
await uploadFileAction(formData); await uploadFileAction(formData);
setStatus('success'); setStatus('success');
setFile(null); setFile(null);
setDescription("");
} catch (err) { } catch (err) {
// If the server action fails, it usually prints details in the terminal console.error(err);
alert("Upload failed. Ensure the 'WebCalibre' folder can be created and OneDrive has space."); alert("Upload failed. Ensure file size is within limits and check server logs.");
setStatus('idle'); setStatus('idle');
} }
}; };
return ( return (
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }} elevation={3}> <Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom> <Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
Upload to WebCalibre Add to Library
</Typography>
<Typography variant="body1" color="text.secondary" mb={4}>
Adding books as <strong>{user.name}</strong>
</Typography> </Typography>
<Stack spacing={3} alignItems="center"> <Stack spacing={4} sx={{ mt: 4 }}>
{/* Dropzone/Selection Area */}
<Box sx={{ width: '100%', p: 5, border: '2px dashed #ccc', borderRadius: 2, bgcolor: '#f9f9f9' }}> {/* Section 1: Folder Selection */}
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
1. Select Target Project / Folder
</Typography>
<Stack direction="row" spacing={1}>
<TextField
select
fullWidth
label="Choose Folder"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
disabled={status === 'uploading'}
>
<MenuItem value=""><em>None (Root)</em></MenuItem>
{folders.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<Tooltip title="Create New Folder">
<IconButton
color="primary"
onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}
>
<CreateNewFolderIcon />
</IconButton>
</Tooltip>
</Stack>
{showFolderInput && (
<Stack direction="row" spacing={1} sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<TextField
size="small"
fullWidth
placeholder="New folder name..."
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
autoFocus
/>
<Button variant="contained" onClick={handleCreateFolder}>Create</Button>
</Stack>
)}
</Box>
<Divider />
{/* Section 2: File Selection */}
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
2. Upload File
</Typography>
<Box sx={{ p: 4, border: '2px dashed #ccc', borderRadius: 2, textAlign: 'center', bgcolor: '#fcfcfc' }}>
<input <input
type="file" type="file" id="file-input" hidden
id="book-upload" accept=".pdf,.epub,.mobi,.azw3,.txt,.png,.jpeg"
hidden
// Only allow common book formats
accept=".pdf,.epub,.mobi,.azw3,.txt"
onChange={(e) => { onChange={(e) => {
setFile(e.target.files?.[0] || null); setFile(e.target.files?.[0] || null);
setStatus('idle'); setStatus('idle');
}} }}
/> />
<label htmlFor="book-upload"> <label htmlFor="file-input">
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}> <Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
{file ? "Select Different File" : "Choose File (PDF, EPUB, MOBI)"} {file ? "Change File" : "Choose Book/Image"}
</Button> </Button>
</label> </label>
{/* New: Enhanced File Info Display */}
{file && ( {file && (
<Box mt={3}> <Box mt={2}>
<Typography variant="subtitle2" color="primary.main" fontWeight="bold"> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
Selected: {file.name} Selected: <strong>{file.name}</strong>
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}> <Typography variant="caption" sx={{ color: file.size > MAX_FILE_SIZE ? 'error.main' : 'text.disabled' }}>
File Size: {formatFileSize(file.size)} Size: {formatFileSize(file.size)} {file.size > MAX_FILE_SIZE && "(Too Large)"}
</Typography> </Typography>
</Box> </Box>
)} )}
</Box> </Box>
</Box>
{/* Progress Indicator */} {/* Section 3: Description */}
<TextField
label="Notes / Description"
multiline rows={2} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={status === 'uploading'}
/>
{/* Status & Action */}
{status === 'uploading' && ( {status === 'uploading' && (
<Box sx={{ width: '100%' }}> <Box>
<Typography variant="caption" display="block" gutterBottom sx={{ color: 'primary.main', fontWeight: 600 }}> <LinearProgress sx={{ borderRadius: 5, height: 10 }} />
Connecting to OneDrive & Uploading... <Typography variant="caption" color="primary" sx={{ mt: 1, display: 'block', textAlign: 'center' }}>
Streaming to OneDrive storage...
</Typography> </Typography>
<LinearProgress />
</Box> </Box>
)} )}
{/* Action Button */}
<Button <Button
variant="contained" variant="contained" size="large" fullWidth
size="large" disabled={!file || status === 'uploading' || file.size > MAX_FILE_SIZE}
fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload} onClick={handleUpload}
sx={{ py: 1.5, fontSize: '1.1rem', fontWeight: 700 }} sx={{ py: 2, fontWeight: 'bold' }}
> >
{status === 'uploading' ? 'Please Wait...' : 'Confirm Upload'} {status === 'uploading' ? 'Uploading...' : 'Confirm Upload'}
</Button> </Button>
{/* Success Feedback */}
{status === 'success' && ( {status === 'success' && (
<Typography color="success.main" fontWeight="bold" sx={{ mt: 2 }}> <Typography align="center" color="success.main" fontWeight="bold">
Successfully uploaded to your library! Successfully Added!
</Typography> </Typography>
)} )}
</Stack> </Stack>

View file

@ -6,18 +6,30 @@ const apiAuthPrefix = "/api/auth";
export const proxy = auth((req) => { export const proxy = auth((req) => {
const { nextUrl } = req; const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const path = nextUrl.pathname; const path = nextUrl.pathname;
/**
* 1. IMMEDIATE BYPASS FOR UPLOADS
* We check this first. If the user is hitting the upload route,
* we let the request pass through directly to the page/action.
* This prevents the middleware from trying to parse the 100MB body.
*/
if (path.startsWith('/upload')) {
return NextResponse.next();
}
const isLoggedIn = !!req.auth;
const isApiAuthRoute = path.startsWith(apiAuthPrefix); const isApiAuthRoute = path.startsWith(apiAuthPrefix);
// Check if the current path is in our protected list
const isProtectedRoute = protectedRoutes.includes(path); const isProtectedRoute = protectedRoutes.includes(path);
// 1. Allow API Auth calls (Login/Logout/Callback) // 2. Allow API Auth calls (Login/Logout/Callback)
if (isApiAuthRoute) { if (isApiAuthRoute) {
return NextResponse.next(); return NextResponse.next();
} }
// 2. CHANGED: Redirect to HOME (/) instead of /login if logged out // 3. Redirect to HOME (/) if trying to access a protected route while logged out
if (isProtectedRoute && !isLoggedIn) { if (isProtectedRoute && !isLoggedIn) {
return NextResponse.redirect(new URL("/", nextUrl)); return NextResponse.redirect(new URL("/", nextUrl));
} }
@ -25,6 +37,11 @@ export const proxy = auth((req) => {
return NextResponse.next(); return NextResponse.next();
}); });
/**
* The Matcher tells Next.js which routes this proxy should run on.
* By adding '|upload' to the negative lookahead (?!...), we tell
* Next.js to ignore the /upload route entirely at the engine level.
*/
export const config = { export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], matcher: ["/((?!api|_next/static|_next/image|favicon.ico|upload).*)"],
}; };