2026-06-28 18:31:05 +02:00
|
|
|
'use client';
|
|
|
|
|
|
2026-07-05 16:57:29 +02:00
|
|
|
import { useState, type DragEvent, type RefObject } from 'react';
|
2026-06-28 18:31:05 +02:00
|
|
|
import {
|
|
|
|
|
Upload, File, CheckCircle, XCircle, Loader2,
|
2026-07-05 16:57:29 +02:00
|
|
|
Copy, ExternalLink, Trash2, Globe, AlertTriangle,
|
2026-06-28 18:31:05 +02:00
|
|
|
} from 'lucide-react';
|
2026-07-05 16:57:29 +02:00
|
|
|
import { getSettings } from '@/lib/storage';
|
2026-06-28 18:31:05 +02:00
|
|
|
|
|
|
|
|
/* ── Helpers ── */
|
|
|
|
|
function formatBytes(b: number): string {
|
|
|
|
|
if (b === 0) return '0 B';
|
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
|
|
|
const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
|
|
|
|
|
const val = b / Math.pow(1024, i);
|
|
|
|
|
return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FileEntry {
|
|
|
|
|
id: string;
|
|
|
|
|
file: File;
|
|
|
|
|
preview?: string;
|
|
|
|
|
status: 'pending' | 'uploading' | 'done' | 'error';
|
|
|
|
|
result?: { cid: string; size: number };
|
|
|
|
|
errorMsg?: string;
|
|
|
|
|
date?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface QuickUploadProps {
|
|
|
|
|
files: FileEntry[];
|
|
|
|
|
dragOver: boolean;
|
|
|
|
|
uploading: boolean;
|
|
|
|
|
uploadIndex: number;
|
|
|
|
|
uploadDone: boolean;
|
|
|
|
|
copied: string | null;
|
|
|
|
|
inputRef: RefObject<HTMLInputElement | null>;
|
|
|
|
|
onAddFiles: (files: FileList | File[]) => void;
|
|
|
|
|
onRemoveFile: (id: string) => void;
|
|
|
|
|
onDragOver: (e: DragEvent) => void;
|
|
|
|
|
onDragLeave: () => void;
|
|
|
|
|
onDrop: (e: DragEvent) => void;
|
|
|
|
|
onBrowse: () => void;
|
|
|
|
|
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
|
|
|
onStartUpload: () => void;
|
|
|
|
|
onCopyCid: (cid: string) => void;
|
|
|
|
|
onCopyShareLink: (cid: string) => void;
|
|
|
|
|
onClearAll: () => void;
|
|
|
|
|
gatewayLink: (cid: string) => string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function QuickUpload({
|
|
|
|
|
files, dragOver, uploading, uploadIndex, uploadDone, copied,
|
|
|
|
|
inputRef, onAddFiles, onRemoveFile, onDragOver, onDragLeave, onDrop,
|
|
|
|
|
onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink,
|
|
|
|
|
}: QuickUploadProps) {
|
|
|
|
|
|
2026-07-05 16:57:29 +02:00
|
|
|
const s = getSettings();
|
|
|
|
|
const maxFiles = s.maxFiles;
|
|
|
|
|
const maxFileSize = s.maxFileSize;
|
|
|
|
|
|
|
|
|
|
const allowedExts = s.allowedFileTypes
|
|
|
|
|
? s.allowedFileTypes.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const [fileTypeError, setFileTypeError] = useState<string | null>(null);
|
|
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
const doneCount = files.filter(f => f.status === 'done').length;
|
|
|
|
|
const errorCount = files.filter(f => f.status === 'error').length;
|
|
|
|
|
const totalCount = files.length;
|
|
|
|
|
const allDone = files.every(f => f.status === 'done' || f.status === 'error');
|
|
|
|
|
|
2026-07-05 16:57:29 +02:00
|
|
|
function filterAllowedFiles(fileList: File[]): { accepted: File[]; rejected: string[] } {
|
|
|
|
|
if (!allowedExts) return { accepted: fileList, rejected: [] };
|
|
|
|
|
const accepted: File[] = [];
|
|
|
|
|
const rejected: string[] = [];
|
|
|
|
|
for (const f of fileList) {
|
|
|
|
|
const ext = f.name.split('.').pop()?.toLowerCase();
|
|
|
|
|
if (ext && allowedExts.includes(ext)) {
|
|
|
|
|
accepted.push(f);
|
|
|
|
|
} else {
|
|
|
|
|
rejected.push(f.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { accepted, rejected };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleLocalDrop(e: DragEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
onDragLeave();
|
|
|
|
|
if (e.dataTransfer.files.length > 0) {
|
|
|
|
|
const fileArr = Array.from(e.dataTransfer.files);
|
|
|
|
|
const { accepted, rejected } = filterAllowedFiles(fileArr);
|
|
|
|
|
if (rejected.length > 0) {
|
|
|
|
|
setFileTypeError(`${rejected.length} bestand(en) overgeslagen (type niet toegestaan): ${rejected.join(', ')}`);
|
|
|
|
|
setTimeout(() => setFileTypeError(null), 5000);
|
|
|
|
|
}
|
|
|
|
|
if (accepted.length > 0) onAddFiles(accepted);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleLocalInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
|
|
|
const fileArr = Array.from(e.target.files);
|
|
|
|
|
const { accepted, rejected } = filterAllowedFiles(fileArr);
|
|
|
|
|
if (rejected.length > 0) {
|
|
|
|
|
setFileTypeError(`${rejected.length} bestand(en) overgeslagen (type niet toegestaan): ${rejected.join(', ')}`);
|
|
|
|
|
setTimeout(() => setFileTypeError(null), 5000);
|
|
|
|
|
}
|
|
|
|
|
if (accepted.length > 0) onAddFiles(accepted);
|
|
|
|
|
}
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
return (
|
|
|
|
|
<div>
|
2026-07-05 16:57:29 +02:00
|
|
|
{/* File type error warning */}
|
|
|
|
|
{fileTypeError && (
|
|
|
|
|
<div className="mb-4 flex items-center gap-2 p-3 rounded-xl bg-accent-rose/10 border border-accent-rose/30 text-sm text-accent-rose">
|
|
|
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
|
|
|
<span>{fileTypeError}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
{/* Drop zone */}
|
|
|
|
|
{!uploading && !uploadDone && (
|
|
|
|
|
<div
|
|
|
|
|
onDragOver={onDragOver}
|
|
|
|
|
onDragLeave={onDragLeave}
|
2026-07-05 16:57:29 +02:00
|
|
|
onDrop={files.length < maxFiles ? handleLocalDrop : undefined}
|
|
|
|
|
onClick={files.length < maxFiles ? onBrowse : undefined}
|
2026-06-28 18:31:05 +02:00
|
|
|
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-200 cursor-pointer ${
|
|
|
|
|
dragOver
|
|
|
|
|
? 'border-brand-400 bg-brand-500/10'
|
|
|
|
|
: 'border-surface-700 hover:border-surface-500 bg-surface-900/50'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
multiple
|
|
|
|
|
className="hidden"
|
2026-07-05 16:57:29 +02:00
|
|
|
onChange={files.length < maxFiles ? handleLocalInputChange : undefined}
|
|
|
|
|
disabled={uploading || files.length >= maxFiles}
|
2026-06-28 18:31:05 +02:00
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col items-center gap-3">
|
|
|
|
|
<Upload className="w-10 h-10 text-surface-500" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-surface-300">
|
|
|
|
|
<span className="text-brand-400 font-medium">Click to browse</span> or drag & drop files
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-xs text-surface-500 mt-1">
|
2026-07-05 16:57:29 +02:00
|
|
|
Up to {maxFiles} files, max {formatBytes(maxFileSize)} each
|
2026-06-28 18:31:05 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* File list */}
|
|
|
|
|
{files.length > 0 && (
|
|
|
|
|
<div className="mt-6 space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-sm text-surface-400">
|
|
|
|
|
{totalCount} file{totalCount !== 1 ? 's' : ''} selected
|
|
|
|
|
{uploading && <> · Uploading {uploadIndex}/{totalCount}</>}
|
|
|
|
|
</span>
|
|
|
|
|
{!uploading && !allDone && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClearAll}
|
|
|
|
|
className="text-xs text-surface-500 hover:text-accent-rose transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Clear all
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{uploading && (
|
|
|
|
|
<div className="w-full h-1.5 rounded-full bg-surface-800 overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full rounded-full bg-gradient-to-r from-brand-500 to-accent-cyan transition-all duration-300"
|
|
|
|
|
style={{ width: `${(uploadIndex / totalCount) * 100}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{files.map((entry, idx) => (
|
|
|
|
|
<div
|
|
|
|
|
key={entry.id}
|
|
|
|
|
className={`glass rounded-xl p-3 flex items-center gap-3 transition-all duration-200 animate-fade-in ${
|
|
|
|
|
entry.status === 'done' ? 'border-l-2 border-l-accent-green' :
|
|
|
|
|
entry.status === 'error' ? 'border-l-2 border-l-accent-rose' : ''
|
|
|
|
|
}`}
|
|
|
|
|
style={{ animationDelay: `${idx * 30}ms` }}
|
|
|
|
|
>
|
|
|
|
|
<div className="w-10 h-10 rounded-lg bg-surface-800 flex items-center justify-center shrink-0 overflow-hidden">
|
|
|
|
|
{entry.preview ? (
|
|
|
|
|
<img src={entry.preview} alt="" className="w-full h-full object-cover" />
|
|
|
|
|
) : (
|
|
|
|
|
<File className="w-5 h-5 text-surface-500" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-sm text-surface-200 truncate">{entry.file.name}</span>
|
|
|
|
|
{entry.status === 'done' && entry.result && (
|
|
|
|
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green shrink-0" />
|
|
|
|
|
)}
|
|
|
|
|
{entry.status === 'error' && (
|
|
|
|
|
<XCircle className="w-3.5 h-3.5 text-accent-rose shrink-0" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-3 mt-0.5">
|
|
|
|
|
<span className="text-xs text-surface-500">{formatBytes(entry.file.size)}</span>
|
|
|
|
|
{entry.status === 'uploading' && (
|
|
|
|
|
<span className="text-xs text-brand-400 flex items-center gap-1">
|
|
|
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
|
|
|
Uploading…
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-07-05 16:57:29 +02:00
|
|
|
{entry.status === 'done' && entry.result?.cid && (
|
2026-06-28 18:31:05 +02:00
|
|
|
<span className="text-xs font-mono text-surface-400 truncate max-w-[160px]">
|
|
|
|
|
{entry.result.cid.slice(0, 16)}…
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{entry.status === 'error' && (
|
|
|
|
|
<span className="text-xs text-accent-rose truncate max-w-[200px]">
|
|
|
|
|
{entry.errorMsg}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
|
|
|
{entry.status === 'done' && entry.result && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onCopyCid(entry.result!.cid)}
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
|
|
|
|
title="Copy CID"
|
|
|
|
|
>
|
|
|
|
|
{copied === entry.result!.cid ? (
|
|
|
|
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<a
|
|
|
|
|
href={gatewayLink(entry.result!.cid)}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
|
|
|
|
title="Open in gateway"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
|
|
|
|
|
</a>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{entry.status === 'pending' && !uploading && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onRemoveFile(entry.id)}
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
|
|
|
|
title="Remove"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-3.5 h-3.5 text-surface-500 group-hover:text-accent-rose" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Action buttons */}
|
|
|
|
|
{files.length > 0 && !allDone && (
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={onStartUpload}
|
|
|
|
|
disabled={uploading || files.filter(f => f.status === 'pending').length === 0}
|
|
|
|
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{uploading ? (
|
|
|
|
|
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading…</>
|
|
|
|
|
) : (
|
|
|
|
|
<><Upload className="w-4 h-4" /> Upload All ({files.filter(f => f.status === 'pending').length})</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Results summary */}
|
|
|
|
|
{uploadDone && (
|
|
|
|
|
<div className="mt-6 glass rounded-xl p-5 animate-fade-in">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{errorCount === 0 ? (
|
|
|
|
|
<CheckCircle className="w-5 h-5 text-accent-green" />
|
|
|
|
|
) : (
|
|
|
|
|
<XCircle className="w-5 h-5 text-accent-rose" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-sm font-medium text-white">
|
|
|
|
|
{doneCount}/{totalCount} files uploaded
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClearAll}
|
|
|
|
|
className="flex items-center gap-1 text-xs text-brand-400 hover:text-brand-300 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Upload className="w-3 h-3" />
|
|
|
|
|
Upload more
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Per-file result details */}
|
2026-07-05 16:57:29 +02:00
|
|
|
{files.filter(f => f.status === 'done').map(entry => entry.result?.cid && (
|
2026-06-28 18:31:05 +02:00
|
|
|
<div key={entry.id} className="flex items-center justify-between py-2 px-3 rounded-lg bg-surface-800/50 mb-1.5">
|
|
|
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
|
|
|
<span className="text-sm text-surface-300 truncate max-w-[150px]">{entry.file.name}</span>
|
|
|
|
|
<span className="text-xs font-mono text-surface-500 truncate max-w-[120px]">{entry.result.cid.slice(0, 16)}…</span>
|
2026-07-05 16:57:29 +02:00
|
|
|
<span className="text-xs text-surface-500">{formatBytes(entry.result.size ?? 0)}</span>
|
2026-06-28 18:31:05 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onCopyCid(entry.result!.cid)}
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
|
|
|
|
title="Copy CID"
|
|
|
|
|
>
|
|
|
|
|
{copied === entry.result!.cid ? (
|
|
|
|
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy className="w-3.5 h-3.5 text-surface-500" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onCopyShareLink(entry.result!.cid)}
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
|
|
|
|
title="Copy share link"
|
|
|
|
|
>
|
|
|
|
|
{copied === `gw-${entry.result!.cid}` ? (
|
|
|
|
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
|
|
|
|
) : (
|
|
|
|
|
<Globe className="w-3.5 h-3.5 text-surface-500" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<a
|
|
|
|
|
href={gatewayLink(entry.result!.cid)}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
|
|
|
|
title="Open in gateway"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink className="w-3.5 h-3.5 text-surface-500" />
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Empty state */}
|
|
|
|
|
{files.length === 0 && !uploading && (
|
|
|
|
|
<div className="mt-6 glass rounded-xl p-8 text-center">
|
|
|
|
|
<Upload className="w-8 h-8 text-surface-600 mx-auto mb-3" />
|
|
|
|
|
<p className="text-sm text-surface-500">
|
|
|
|
|
Drag files above or click to browse. No crypto payment needed for quick uploads.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|