Document_Query / components /FileUploadPanel.tsx
Reubencf's picture
Deploy Next.js Query Bot as Docker Space
ff0e173
'use client';
import * as React from 'react';
import UploadedFileCard from './UploadedFileCard';
import FileTypeIcon from './FileTypeIcon';
import { KBFile } from '@/lib/kb-data';
interface FileUploadPanelProps {
files: KBFile[];
onUpload: (file: File) => Promise<void>;
onDelete: (id: string) => void;
deletingIds: Set<string>;
}
type UploadStatus = 'uploading' | 'done' | 'error';
interface PendingUpload {
id: string;
name: string;
file: File;
progress: number;
status: UploadStatus;
}
let uploadSeq = 0;
export default function FileUploadPanel({ files, onUpload, onDelete, deletingIds }: FileUploadPanelProps) {
const [isDragOver, setIsDragOver] = React.useState(false);
const [uploads, setUploads] = React.useState<PendingUpload[]>([]);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const dismissUpload = (id: string) =>
setUploads((prev) => prev.filter((u) => u.id !== id));
// Upload a real file: parsing + embedding happens server-side, so we show an
// indeterminate progress bar that creeps toward 90% until the POST resolves.
const startUpload = (file: File) => {
const fileId = `up-${uploadSeq++}`;
setUploads((prev) => [
...prev,
{ id: fileId, name: file.name, file, progress: 8, status: 'uploading' },
]);
let prog = 8;
const interval = setInterval(() => {
prog = Math.min(90, prog + Math.floor(Math.random() * 8) + 4);
setUploads((prev) =>
prev.map((u) => (u.id === fileId && u.status === 'uploading' ? { ...u, progress: prog } : u))
);
}, 300);
const succeed = () => {
clearInterval(interval);
// Don't show a separate "file uploaded" card. The file is already added
// to the knowledge-base list below, so just remove the progress toast and
// let the file card itself signal completion.
dismissUpload(fileId);
};
const fail = () => {
clearInterval(interval);
setUploads((prev) =>
prev.map((u) => (u.id === fileId ? { ...u, status: 'error' } : u))
);
};
Promise.resolve(onUpload(file)).then(succeed, fail);
};
const retryUpload = (u: PendingUpload) => {
dismissUpload(u.id);
startUpload(u.file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const droppedFiles = Array.from(e.dataTransfer.files);
const eligibleFiles = droppedFiles.filter(f => {
const ext = f.name.split('.').pop()?.toLowerCase() || '';
return ['pdf', 'docx', 'doc', 'xlsx', 'xls', 'csv'].includes(ext);
});
if (eligibleFiles.length > 0) {
eligibleFiles.forEach((f) => startUpload(f));
} else {
alert('Please drop accepted file types (PDF, DOCX, XLSX, XLS, CSV).');
}
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selected = Array.from(e.target.files);
selected.forEach((f) => startUpload(f));
// Reset input value to allow uploading same file again if deleted
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const triggerBrowse = () => {
fileInputRef.current?.click();
};
return (
<div className="flex flex-col h-full">
{/* Panel Headers */}
<div>
<h2 className="text-xl font-semibold text-white tracking-tight">Upload documents</h2>
<p className="text-sm text-white/50 mt-1 leading-relaxed">
Drag and drop PDFs, Word documents, and spreadsheets into your knowledge base.
</p>
</div>
{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.doc,.xlsx,.xls,.csv"
className="hidden"
onChange={handleFileChange}
/>
{/* Drop Zone Box */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`mt-6 border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 text-center transition-all duration-300 ${
isDragOver
? 'border-white/40 bg-white/5 hover:border-white/50'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
{/* Document Icons – fanned-out brand logos */}
<div className="flex items-end justify-center mb-5">
<FileTypeIcon
type="PDF"
size={44}
className="-rotate-12 translate-y-1 drop-shadow-xl transition-transform duration-300"
/>
<FileTypeIcon
type="DOCX"
size={54}
className="-mx-2 z-10 drop-shadow-2xl transition-transform duration-300"
/>
<FileTypeIcon
type="EXCEL"
size={44}
className="rotate-12 translate-y-1 drop-shadow-xl transition-transform duration-300"
/>
</div>
<p className="text-sm font-medium text-white/90">Drop your files here</p>
<p className="text-xs text-white/40 mt-1">PDF, DOCX, XLSX, XLS, or CSV up to 25MB</p>
<button
type="button"
onClick={triggerBrowse}
className="mt-5 inline-flex items-center justify-center h-9 px-4 rounded-full text-xs font-semibold bg-white/5 border border-white/10 text-white hover:bg-white/10 transition-colors duration-150 active:scale-[0.98] cursor-pointer"
>
Browse files
</button>
</div>
{/* Files List Heading */}
<div className="mt-8">
<h3 className="text-xs font-bold text-white/40 uppercase tracking-wider">
Knowledge Base (Files)
</h3>
{/* Upload status toasts (uploading / done / error) */}
{uploads.length > 0 && (
<div className="mt-3 space-y-2.5">
{uploads.map((u) => (
<UploadToast
key={u.id}
upload={u}
onCancel={() => dismissUpload(u.id)}
onRetry={() => retryUpload(u)}
/>
))}
</div>
)}
{/* Stable List of Files */}
{files.length === 0 && uploads.length === 0 ? (
<div className="mt-3 p-8 border border-white/5 rounded-2xl bg-black/20 text-center text-xs text-white/30">
No documents uploaded yet. Place your documents here to augment AI knowledge.
</div>
) : (
<div className="mt-3 space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
{files.map((file) => (
<UploadedFileCard
key={file.id}
file={file}
onDelete={onDelete}
isDeleting={deletingIds.has(file.id)}
/>
))}
</div>
)}
</div>
{/* Bottom informational footline */}
<p className="text-[11px] text-white/30 mt-auto pt-6 leading-normal flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Documents are indexed for AI search after upload.
</p>
</div>
);
}
const TOAST_CONFIG: Record<
UploadStatus,
{ glow: string; ring: string; bar: string; title: string; desc: string; icon: React.ReactNode }
> = {
uploading: {
glow: 'radial-gradient(circle, rgba(99,102,241,0.6) 0%, rgba(59,130,246,0.25) 45%, transparent 72%)',
ring: 'border-indigo-400/60 text-indigo-300',
bar: 'from-indigo-400 to-violet-400',
title: 'Just a minute…',
desc: 'Your file is uploading right now. Hang tight while we finish.',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m0 0l5-5m-5 5l-5-5" />
</svg>
),
},
done: {
glow: 'radial-gradient(circle, rgba(16,185,129,0.55) 0%, rgba(20,184,166,0.2) 45%, transparent 72%)',
ring: 'border-emerald-400/60 text-emerald-300',
bar: 'from-emerald-400 to-teal-400',
title: 'Your file was uploaded!',
desc: 'Added to your knowledge base and queued for AI indexing.',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
),
},
error: {
glow: 'radial-gradient(circle, rgba(244,63,94,0.55) 0%, rgba(225,29,72,0.2) 45%, transparent 72%)',
ring: 'border-rose-400/60 text-rose-300',
bar: 'from-rose-400 to-pink-400',
title: 'We are so sorry!',
desc: "There was an error and your file couldn't be uploaded. Try again?",
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6L6 18" />
</svg>
),
},
};
interface UploadToastProps {
upload: PendingUpload;
onCancel: () => void;
onRetry: () => void;
}
function UploadToast({ upload, onCancel, onRetry }: UploadToastProps) {
const { name, progress, status } = upload;
const cfg = TOAST_CONFIG[status];
const btn =
'inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-semibold bg-white/[0.06] border border-white/10 text-white hover:bg-white/10 transition-colors duration-150 cursor-pointer';
return (
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-[#161616]/90 backdrop-blur-md p-4">
{/* Soft corner glow tinted by status */}
<div
className="pointer-events-none absolute -top-12 -right-10 h-32 w-52 blur-3xl opacity-70"
style={{ background: cfg.glow }}
/>
<div className="relative flex gap-3.5">
{/* Status icon ring */}
<div className={`mt-0.5 h-9 w-9 flex-shrink-0 rounded-full border-2 flex items-center justify-center ${cfg.ring} ${status === 'uploading' ? 'animate-pulse' : ''}`}>
{cfg.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-white leading-tight">{cfg.title}</p>
<p className="text-xs text-white/50 mt-1 leading-relaxed">{cfg.desc}</p>
<p className="text-[11px] text-white/40 mt-1 truncate font-mono" title={name}>
{name}
</p>
{status === 'uploading' && (
<div className="mt-3 flex items-end gap-3">
<div className="flex-1">
<div className="flex justify-end mb-1">
<span className="text-[11px] font-semibold text-white/70 font-mono">{progress}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className={`h-full rounded-full bg-gradient-to-r ${cfg.bar} transition-all duration-200`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
<button onClick={onCancel} className={btn}>
Cancel
</button>
</div>
)}
{status === 'error' && (
<div className="mt-3 flex items-center gap-2">
<button onClick={onRetry} className={btn}>
Retry
</button>
<button onClick={onCancel} className={btn}>
Cancel
</button>
</div>
)}
{status === 'done' && (
<div className="mt-3">
<button onClick={onCancel} className={btn}>
Done
</button>
</div>
)}
</div>
</div>
</div>
);
}