From 224b57262fc25471e254431cf9ec6d07d9caf197 Mon Sep 17 00:00:00 2001 From: WeeXnes Date: Thu, 12 Jun 2025 15:58:24 +0200 Subject: [PATCH] fixed upload progress --- src/app/api/upload/route.ts | 78 ++++++++++ .../upload/components/file-upload-form.tsx | 134 +++++++++++------- src/lib/actions/file.actions.ts | 75 ---------- 3 files changed, 164 insertions(+), 123 deletions(-) create mode 100644 src/app/api/upload/route.ts delete mode 100644 src/lib/actions/file.actions.ts diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..e8ecb3e --- /dev/null +++ b/src/app/api/upload/route.ts @@ -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((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 }); + } +} diff --git a/src/app/upload/components/file-upload-form.tsx b/src/app/upload/components/file-upload-form.tsx index 51b4bb3..95cc462 100644 --- a/src/app/upload/components/file-upload-form.tsx +++ b/src/app/upload/components/file-upload-form.tsx @@ -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 ( - ); } 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(null); const [uploadProgress, setUploadProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [message, setMessage] = useState({ text: null, type: null, uploadedFile: null }); const formRef = useRef(null); + const fileInputRef = useRef(null); const handleFileChange = (event: React.ChangeEvent) => { 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)); - } - if (progress >= 100 || state?.message) { - clearInterval(interval); - } - }, 150); - } else if (state?.message && !state.error) { - setUploadProgress(100); - } else if (state?.message && state.error) { - setUploadProgress(0); + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedFile) { + setMessage({ text: 'Please select a file to upload.', type: 'error' }); + return; } - return () => clearInterval(interval); - }, [isPending, selectedFile, state]); + 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); + } + }; + + 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); + setTimeout(() => setUploadProgress(0), 2000); + } else { + setMessage({ text: response.message || `Upload failed with status: ${xhr.status}`, type: 'error' }); + setUploadProgress(0); + } + } 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: , }); - } else { + } else if (message.type === 'success') { toast({ title: "Upload Successful!", - description: state.message, + description: message.text, icon: , }); - formRef.current?.reset(); - setSelectedFile(null); - setUploadProgress(100); - setTimeout(() => setUploadProgress(0), 2000); } } - }, [state, toast]); + }, [message, toast]); return ( -
+
- +
{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" > {selectedFile ? "Change file" : "Upload a file"} - +

{selectedFile ? "" : "or drag and drop"}

@@ -118,24 +156,24 @@ export default function FileUploadForm() {
- {selectedFile && (isPending || uploadProgress > 0) && ( + {selectedFile && (isUploading || uploadProgress > 0) && (

- {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}%`}

)} - {state?.error && state.message && !isPending && ( + {message.type === 'error' && message.text && !isUploading && ( Error - {state.message} + {message.text} )} - + ); } diff --git a/src/lib/actions/file.actions.ts b/src/lib/actions/file.actions.ts deleted file mode 100644 index 1857f77..0000000 --- a/src/lib/actions/file.actions.ts +++ /dev/null @@ -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((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 }; - } -}