fixed upload progress
This commit is contained in:
parent
6e8bf31900
commit
224b57262f
3 changed files with 164 additions and 123 deletions
78
src/app/api/upload/route.ts
Normal file
78
src/app/api/upload/route.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { addFile } from '@/lib/file-service';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { UPLOAD_LIMIT_MB } from '@/lib/config';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = UPLOAD_LIMIT_MB * 1024 * 1024;
|
||||
|
||||
// This schema can be shared or adapted from src/lib/actions/file.actions.ts
|
||||
const fileSchema = z.object({
|
||||
file: z
|
||||
.custom<File>((val) => val instanceof File, "Input is not a file")
|
||||
.refine((file) => file.size > 0, "File cannot be empty.")
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE_BYTES, `File size should be less than ${UPLOAD_LIMIT_MB}MB.`)
|
||||
// You might want to add more specific MIME type validation if needed
|
||||
// .refine((file) => ACCEPTED_FILE_TYPES_REGEX.test(file.type), "Unsupported file type.")
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const uploadedFile = formData.get('file') as File | null;
|
||||
|
||||
if (!uploadedFile || uploadedFile.name === 'undefined' || uploadedFile.size === 0) {
|
||||
return NextResponse.json({ message: 'No file selected or file is empty.', error: true }, { status: 400 });
|
||||
}
|
||||
|
||||
const validatedFields = fileSchema.safeParse({ file: uploadedFile });
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const errors = validatedFields.error.flatten().fieldErrors;
|
||||
return NextResponse.json({
|
||||
message: errors.file?.[0] || "Invalid file.",
|
||||
error: true,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const newFileEntry = await addFile({
|
||||
name: uploadedFile.name,
|
||||
type: uploadedFile.type || 'application/octet-stream',
|
||||
size: uploadedFile.size,
|
||||
});
|
||||
|
||||
const cdnFolderPath = path.join(process.cwd(), 'CDN');
|
||||
if (!fs.existsSync(cdnFolderPath)) {
|
||||
fs.mkdirSync(cdnFolderPath, { recursive: true });
|
||||
}
|
||||
|
||||
const filePathInCdn = path.join(cdnFolderPath, `${newFileEntry.id}.${newFileEntry.extension}`);
|
||||
const fileBuffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
fs.writeFileSync(filePathInCdn, fileBuffer);
|
||||
|
||||
// Revalidate paths that show file lists or file details
|
||||
revalidatePath('/browse', 'page');
|
||||
revalidatePath(`/files/${newFileEntry.id}`, 'page');
|
||||
// Revalidating the CDN path might be less critical if content-disposition/cache headers are set well client-side
|
||||
// but can be included for thoroughness.
|
||||
revalidatePath(`/cdn/${newFileEntry.id}`, 'page');
|
||||
|
||||
return NextResponse.json({
|
||||
message: `File "${newFileEntry.name}" uploaded and saved successfully!`,
|
||||
error: false,
|
||||
file: newFileEntry
|
||||
}, { status: 200 });
|
||||
|
||||
} catch (e) {
|
||||
console.error("Upload API error:", e);
|
||||
let errorMessage = 'Failed to upload file via API. Please try again.';
|
||||
if (e instanceof Error) {
|
||||
errorMessage = `Failed to upload file: ${e.message}`;
|
||||
}
|
||||
return NextResponse.json({ message: errorMessage, error: true }, { status: 500 });
|
||||
}
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useActionState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import { uploadFileAction } from '@/lib/actions/file.actions';
|
||||
import { useRef, useState, useEffect, FormEvent } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
@ -12,86 +10,126 @@ import { useToast } from '@/hooks/use-toast';
|
|||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { UploadCloud, CheckCircle, XCircle, Loader2, File as FileIconLucide } from "lucide-react";
|
||||
import { UPLOAD_LIMIT_MB } from '@/lib/config';
|
||||
import type { FileItem } from '@/types';
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
interface UploadMessage {
|
||||
text: string | null;
|
||||
type: 'success' | 'error' | null;
|
||||
uploadedFile?: FileItem | null;
|
||||
}
|
||||
|
||||
function SubmitButton({ isUploading }: { isUploading: boolean }) {
|
||||
return (
|
||||
<Button type="submit" className="w-full mt-4" disabled={pending}>
|
||||
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <UploadCloud className="mr-2 h-4 w-4" />}
|
||||
<Button type="submit" className="w-full mt-4" disabled={isUploading}>
|
||||
{isUploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <UploadCloud className="mr-2 h-4 w-4" />}
|
||||
Upload File
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileUploadForm() {
|
||||
const initialState = { message: null, error: false, file: null };
|
||||
const [state, dispatch, isPending] = useActionState(uploadFileAction, initialState);
|
||||
const { toast } = useToast();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [message, setMessage] = useState<UploadMessage>({ text: null, type: null, uploadedFile: null });
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > UPLOAD_LIMIT_MB * 1024 * 1024) {
|
||||
setMessage({ text: `File size exceeds ${UPLOAD_LIMIT_MB}MB limit.`, type: 'error' });
|
||||
setSelectedFile(null);
|
||||
if(fileInputRef.current) fileInputRef.current.value = ""; // Clear the input
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setUploadProgress(0);
|
||||
setMessage({ text: null, type: null });
|
||||
} else {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isPending && selectedFile && !state?.message) {
|
||||
let progress = 0;
|
||||
interval = setInterval(() => {
|
||||
progress += Math.random() * 15 + 5;
|
||||
if (progress >= 95 && !state?.message) {
|
||||
setUploadProgress(95);
|
||||
} else if (progress <= 100) {
|
||||
setUploadProgress(Math.min(progress, 100));
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!selectedFile) {
|
||||
setMessage({ text: 'Please select a file to upload.', type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (progress >= 100 || state?.message) {
|
||||
clearInterval(interval);
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
setMessage({ text: null, type: null });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percentComplete = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadProgress(percentComplete);
|
||||
}
|
||||
}, 150);
|
||||
} else if (state?.message && !state.error) {
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
setIsUploading(false);
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (xhr.status >= 200 && xhr.status < 300 && !response.error) {
|
||||
setMessage({ text: response.message || "File uploaded successfully!", type: 'success', uploadedFile: response.file });
|
||||
formRef.current?.reset();
|
||||
setSelectedFile(null);
|
||||
// Keep progress at 100 for a bit for visual feedback
|
||||
setUploadProgress(100);
|
||||
} else if (state?.message && state.error) {
|
||||
setTimeout(() => setUploadProgress(0), 2000);
|
||||
} else {
|
||||
setMessage({ text: response.message || `Upload failed with status: ${xhr.status}`, type: 'error' });
|
||||
setUploadProgress(0);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isPending, selectedFile, state]);
|
||||
} catch (e) {
|
||||
setMessage({ text: 'An unexpected error occurred during upload processing.', type: 'error' });
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
setIsUploading(false);
|
||||
setMessage({ text: 'Upload failed. Please check your network connection and try again.', type: 'error' });
|
||||
setUploadProgress(0);
|
||||
};
|
||||
|
||||
xhr.open('POST', '/api/upload', true);
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.message) {
|
||||
if (state.error) {
|
||||
if (message.text) {
|
||||
if (message.type === 'error') {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Upload Failed",
|
||||
description: state.message,
|
||||
description: message.text,
|
||||
icon: <XCircle className="h-5 w-5" />,
|
||||
});
|
||||
} else {
|
||||
} else if (message.type === 'success') {
|
||||
toast({
|
||||
title: "Upload Successful!",
|
||||
description: state.message,
|
||||
description: message.text,
|
||||
icon: <CheckCircle className="h-5 w-5 text-green-500" />,
|
||||
});
|
||||
formRef.current?.reset();
|
||||
setSelectedFile(null);
|
||||
setUploadProgress(100);
|
||||
setTimeout(() => setUploadProgress(0), 2000);
|
||||
}
|
||||
}
|
||||
}, [state, toast]);
|
||||
}, [message, toast]);
|
||||
|
||||
return (
|
||||
<form action={dispatch} ref={formRef} className="space-y-6">
|
||||
<form onSubmit={handleSubmit} ref={formRef} className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="file" className="sr-only">Choose file</Label>
|
||||
<Label htmlFor="file-upload" className="sr-only">Choose file</Label>
|
||||
<div className="mt-2 flex justify-center rounded-lg border border-dashed border-border px-6 py-10 hover:border-primary transition-colors duration-200">
|
||||
<div className="text-center">
|
||||
{selectedFile ? (
|
||||
|
@ -105,7 +143,7 @@ export default function FileUploadForm() {
|
|||
className="relative cursor-pointer rounded-md font-semibold text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2 hover:text-accent"
|
||||
>
|
||||
<span>{selectedFile ? "Change file" : "Upload a file"}</span>
|
||||
<Input id="file-upload" name="file" type="file" className="sr-only" onChange={handleFileChange} required />
|
||||
<Input ref={fileInputRef} id="file-upload" name="file" type="file" className="sr-only" onChange={handleFileChange} required />
|
||||
</label>
|
||||
<p className="pl-1">{selectedFile ? "" : "or drag and drop"}</p>
|
||||
</div>
|
||||
|
@ -118,24 +156,24 @@ export default function FileUploadForm() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFile && (isPending || uploadProgress > 0) && (
|
||||
{selectedFile && (isUploading || uploadProgress > 0) && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={uploadProgress} className="w-full h-3" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{isPending && uploadProgress < 100 ? `${uploadProgress}% uploading...` : state?.error ? 'Error' : uploadProgress === 100 && !isPending ? 'Complete' : `${uploadProgress}%`}
|
||||
{isUploading && uploadProgress < 100 ? `${uploadProgress}% uploading...` : message.type === 'error' ? 'Error' : uploadProgress === 100 && !isUploading && message.type === 'success' ? 'Complete' : `${uploadProgress}%`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state?.error && state.message && !isPending && (
|
||||
{message.type === 'error' && message.text && !isUploading && (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SubmitButton />
|
||||
<SubmitButton isUploading={isUploading} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
|
||||
"use server";
|
||||
|
||||
import { addFile } from '@/lib/file-service';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { UPLOAD_LIMIT_MB } from '@/lib/config';
|
||||
|
||||
const MAX_FILE_SIZE = UPLOAD_LIMIT_MB * 1024 * 1024; // 500MB
|
||||
// It's generally better to allow a wider range of types and let the application handle them,
|
||||
// or use more robust server-side type checking if strict limitations are needed.
|
||||
// For this prototype, we'll be more permissive on the client-side Zod schema for MIME types.
|
||||
// const ACCEPTED_FILE_TYPES_REGEX = /^(image\/[a-zA-Z0-9+.-]+|application\/pdf|video\/[a-zA-Z0-9+.-]+|audio\/[a-zA-Z0-9+.-]+|text\/[a-zA-Z0-9+.-]+|application\/(json|zip|x-rar-compressed|octet-stream))$/;
|
||||
|
||||
|
||||
const fileSchema = z.object({
|
||||
file: z
|
||||
.custom<File>((val) => val instanceof File, "Input is not a file")
|
||||
.refine((file) => file.size > 0, "File cannot be empty.")
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, `File size should be less than ${UPLOAD_LIMIT_MB}MB.`)
|
||||
// .refine((file) => ACCEPTED_FILE_TYPES_REGEX.test(file.type), "Unsupported file type.") // Relaxing this for broader prototype compatibility
|
||||
});
|
||||
|
||||
|
||||
export async function uploadFileAction(prevState: any, formData: FormData) {
|
||||
const uploadedFile = formData.get('file') as File;
|
||||
|
||||
if (!uploadedFile || uploadedFile.name === 'undefined' || uploadedFile.size === 0) { // Handle edge case where file might be 'undefined' string or empty
|
||||
return { message: 'No file selected or file is empty.', error: true, file: null };
|
||||
}
|
||||
|
||||
const validatedFields = fileSchema.safeParse({ file: uploadedFile });
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const errors = validatedFields.error.flatten().fieldErrors;
|
||||
return {
|
||||
message: errors.file?.[0] || "Invalid file.",
|
||||
error: true,
|
||||
file: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const newFileEntry = await addFile({
|
||||
name: uploadedFile.name,
|
||||
type: uploadedFile.type || 'application/octet-stream',
|
||||
size: uploadedFile.size,
|
||||
});
|
||||
|
||||
// Save the file to the CDN folder
|
||||
const cdnFolderPath = path.join(process.cwd(), 'CDN');
|
||||
if (!fs.existsSync(cdnFolderPath)) {
|
||||
fs.mkdirSync(cdnFolderPath, { recursive: true });
|
||||
}
|
||||
|
||||
const filePathInCdn = path.join(cdnFolderPath, `${newFileEntry.id}.${newFileEntry.extension}`);
|
||||
const fileBuffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
fs.writeFileSync(filePathInCdn, fileBuffer);
|
||||
|
||||
revalidatePath('/browse');
|
||||
revalidatePath(`/files/${newFileEntry.id}`);
|
||||
revalidatePath(`/cdn/${newFileEntry.id}`);
|
||||
|
||||
return { message: `File "${newFileEntry.name}" uploaded and saved successfully!`, error: false, file: newFileEntry };
|
||||
} catch (e) {
|
||||
console.error("Upload error:", e);
|
||||
let errorMessage = 'Failed to upload file. Please try again.';
|
||||
if (e instanceof Error) {
|
||||
errorMessage = `Failed to upload file: ${e.message}`;
|
||||
}
|
||||
return { message: errorMessage, error: true, file: null };
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue