fixed upload progress

This commit is contained in:
WeeXnes 2025-06-12 15:58:24 +02:00
parent 6e8bf31900
commit 224b57262f
3 changed files with 164 additions and 123 deletions

View 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 });
}
}

View file

@ -1,9 +1,7 @@
"use client"; "use client";
import { useRef, useState, useEffect, useActionState } from 'react'; import { useRef, useState, useEffect, FormEvent } from 'react';
import { useFormStatus } from 'react-dom';
import { uploadFileAction } from '@/lib/actions/file.actions';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { UploadCloud, CheckCircle, XCircle, Loader2, File as FileIconLucide } from "lucide-react"; import { UploadCloud, CheckCircle, XCircle, Loader2, File as FileIconLucide } from "lucide-react";
import { UPLOAD_LIMIT_MB } from '@/lib/config'; import { UPLOAD_LIMIT_MB } from '@/lib/config';
import type { FileItem } from '@/types';
function SubmitButton() { interface UploadMessage {
const { pending } = useFormStatus(); text: string | null;
type: 'success' | 'error' | null;
uploadedFile?: FileItem | null;
}
function SubmitButton({ isUploading }: { isUploading: boolean }) {
return ( return (
<Button type="submit" className="w-full mt-4" disabled={pending}> <Button type="submit" className="w-full mt-4" disabled={isUploading}>
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <UploadCloud className="mr-2 h-4 w-4" />} {isUploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <UploadCloud className="mr-2 h-4 w-4" />}
Upload File Upload File
</Button> </Button>
); );
} }
export default function FileUploadForm() { export default function FileUploadForm() {
const initialState = { message: null, error: false, file: null };
const [state, dispatch, isPending] = useActionState(uploadFileAction, initialState);
const { toast } = useToast(); const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0); 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 formRef = useRef<HTMLFormElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { 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); setSelectedFile(file);
setUploadProgress(0); setUploadProgress(0);
setMessage({ text: null, type: null });
} else { } else {
setSelectedFile(null); setSelectedFile(null);
} }
}; };
useEffect(() => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
let interval: NodeJS.Timeout; event.preventDefault();
if (isPending && selectedFile && !state?.message) { if (!selectedFile) {
let progress = 0; setMessage({ text: 'Please select a file to upload.', type: 'error' });
interval = setInterval(() => { return;
progress += Math.random() * 15 + 5;
if (progress >= 95 && !state?.message) {
setUploadProgress(95);
} else if (progress <= 100) {
setUploadProgress(Math.min(progress, 100));
} }
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); 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); setUploadProgress(0);
} }
return () => clearInterval(interval); } catch (e) {
}, [isPending, selectedFile, state]); 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(() => { useEffect(() => {
if (state?.message) { if (message.text) {
if (state.error) { if (message.type === 'error') {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Upload Failed", title: "Upload Failed",
description: state.message, description: message.text,
icon: <XCircle className="h-5 w-5" />, icon: <XCircle className="h-5 w-5" />,
}); });
} else { } else if (message.type === 'success') {
toast({ toast({
title: "Upload Successful!", title: "Upload Successful!",
description: state.message, description: message.text,
icon: <CheckCircle className="h-5 w-5 text-green-500" />, 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 ( return (
<form action={dispatch} ref={formRef} className="space-y-6"> <form onSubmit={handleSubmit} ref={formRef} className="space-y-6">
<div> <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="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"> <div className="text-center">
{selectedFile ? ( {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" 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> <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> </label>
<p className="pl-1">{selectedFile ? "" : "or drag and drop"}</p> <p className="pl-1">{selectedFile ? "" : "or drag and drop"}</p>
</div> </div>
@ -118,24 +156,24 @@ export default function FileUploadForm() {
</div> </div>
</div> </div>
{selectedFile && (isPending || uploadProgress > 0) && ( {selectedFile && (isUploading || uploadProgress > 0) && (
<div className="space-y-2"> <div className="space-y-2">
<Progress value={uploadProgress} className="w-full h-3" /> <Progress value={uploadProgress} className="w-full h-3" />
<p className="text-sm text-muted-foreground text-center"> <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> </p>
</div> </div>
)} )}
{state?.error && state.message && !isPending && ( {message.type === 'error' && message.text && !isUploading && (
<Alert variant="destructive"> <Alert variant="destructive">
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{state.message}</AlertDescription> <AlertDescription>{message.text}</AlertDescription>
</Alert> </Alert>
)} )}
<SubmitButton /> <SubmitButton isUploading={isUploading} />
</form> </form>
); );
} }

View file

@ -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 };
}
}