115 lines
3.7 KiB
TypeScript
115 lines
3.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useCallback } from 'react';
|
|
import { authFetch } from '@/lib/authFetch';
|
|
|
|
interface UploadResult {
|
|
url: string;
|
|
filename: string;
|
|
originalName: string;
|
|
size: number;
|
|
mimeType: string;
|
|
}
|
|
|
|
interface FileUploadProps {
|
|
onUpload: (result: UploadResult) => void;
|
|
accept?: string;
|
|
maxSize?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function FileUpload({ onUpload, accept, maxSize = 50 * 1024 * 1024, className }: FileUploadProps) {
|
|
const [uploading, setUploading] = useState(false);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
if (file.size > maxSize) {
|
|
setError(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`);
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const res = await authFetch('/api/uploads', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || 'Upload failed');
|
|
}
|
|
|
|
const result: UploadResult = await res.json();
|
|
onUpload(result);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}, [maxSize, onUpload]);
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) handleUpload(file);
|
|
}, [handleUpload]);
|
|
|
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleUpload(file);
|
|
}, [handleUpload]);
|
|
|
|
return (
|
|
<div className={className}>
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
|
dragOver
|
|
? 'border-amber-500 bg-amber-500/10'
|
|
: 'border-slate-700 hover:border-slate-600 bg-slate-800/30'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={accept}
|
|
onChange={handleChange}
|
|
className="hidden"
|
|
/>
|
|
{uploading ? (
|
|
<div className="flex items-center justify-center gap-2 text-slate-400">
|
|
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
Uploading...
|
|
</div>
|
|
) : (
|
|
<div className="text-slate-400">
|
|
<svg className="w-8 h-8 mx-auto mb-2 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
</svg>
|
|
<p className="text-sm">Drop a file here or click to browse</p>
|
|
<p className="text-xs text-slate-500 mt-1">Max {Math.round(maxSize / 1024 / 1024)}MB</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<p className="text-red-400 text-sm mt-2">{error}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|