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