7c7854e08f
- SIWE server-side auth (siwe + jose JWT) - Wallet provider fallback (MAOS -> MetaMask) - Auto-pin after upload (quick + crypto) - Pin search/filter in explorer - Share button (gateway link) per pin - Copy CID button per pin - Breadcrumb root navigation fix - Hydration mismatch fix (mounted state) - Defensive truncateCid for empty values - CSP fix (template literal) - Error boundaries (upload, profile, usage) - 191 tests passing
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
|
|
import { uploadFile } from '@/lib/api/files';
|
|
import { addToHistory, getSettings } from '@/lib/storage';
|
|
import { addPin } from '@/lib/api/pins';
|
|
import PortalLayout from '@/app/layout-portal';
|
|
import { checkUploadAllowed } from '@/lib/limits';
|
|
import { useNotify } from '@/lib/notifications';
|
|
import { formatBytes, gatewayLink } from '@/lib/helpers';
|
|
import { Zap, ArrowUp } from 'lucide-react';
|
|
import QuickUpload, { type FileEntry } from './components/QuickUpload';
|
|
import { PaymentProvider } from '@/lib/payment';
|
|
import CryptoUpload from './components/CryptoUpload';
|
|
import type { TabMode } from './helpers';
|
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
|
|
|
/* ════════════════════════════ Page ════════════════════════════ */
|
|
|
|
export default function UploadPage() {
|
|
const { notify } = useNotify();
|
|
const settings = useMemo(() => getSettings(), []);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const cryptoInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
/* ── Tab state ── */
|
|
const [tab, setTab] = useState<TabMode>('quick');
|
|
|
|
/* ── Quick Upload state ── */
|
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadIndex, setUploadIndex] = useState(0);
|
|
const [uploadDone, setUploadDone] = useState(false);
|
|
const [copied, setCopied] = useState<string | null>(null);
|
|
|
|
/* ── Crypto Upload state ── */
|
|
const [cryptoFile, setCryptoFile] = useState<File | null>(null);
|
|
const [cryptoResult, setCryptoResult] = useState<{ cid: string; size: number } | null>(null);
|
|
|
|
/* ── File management ── */
|
|
const addFiles = useCallback((incoming: FileList | File[]) => {
|
|
const s = getSettings();
|
|
const arr = Array.from(incoming);
|
|
const remaining = s.maxFiles - files.length;
|
|
const toAdd = arr.slice(0, remaining);
|
|
|
|
const allowedExts = s.allowedFileTypes
|
|
? s.allowedFileTypes.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
|
|
: null;
|
|
|
|
const rejected: string[] = [];
|
|
|
|
const entries: FileEntry[] = toAdd
|
|
.filter(f => s.maxFileSize === 0 || f.size <= s.maxFileSize)
|
|
.filter(f => !files.some(ex => ex.file.name === f.name && ex.file.size === f.size))
|
|
.filter(f => {
|
|
if (!allowedExts) return true;
|
|
const ext = f.name.split('.').pop()?.toLowerCase();
|
|
if (ext && allowedExts.includes(ext)) return true;
|
|
rejected.push(f.name);
|
|
return false;
|
|
})
|
|
.map(f => ({
|
|
id: Math.random().toString(36).slice(2, 9),
|
|
file: f,
|
|
preview: !allowedExts || allowedExts.some(ext => f.name.toLowerCase().endsWith('.' + ext))
|
|
? URL.createObjectURL(f)
|
|
: undefined,
|
|
status: 'pending' as const,
|
|
}));
|
|
|
|
if (rejected.length > 0) {
|
|
notify({
|
|
type: 'warning',
|
|
title: 'Bestandstype niet toegestaan',
|
|
message: `De volgende bestanden zijn overgeslagen: ${rejected.join(', ')}`,
|
|
duration: 5000,
|
|
});
|
|
}
|
|
|
|
if (entries.length === 0) return;
|
|
setFiles(prev => [...prev, ...entries]);
|
|
setUploadDone(false);
|
|
}, [files, notify]);
|
|
|
|
function removeFile(id: string) {
|
|
setFiles(prev => {
|
|
const entry = prev.find(f => f.id === id);
|
|
if (entry?.preview) URL.revokeObjectURL(entry.preview);
|
|
return prev.filter(f => f.id !== id);
|
|
});
|
|
}
|
|
|
|
function clearAll() {
|
|
files.forEach(f => f.preview && URL.revokeObjectURL(f.preview));
|
|
setFiles([]);
|
|
setUploadDone(false);
|
|
}
|
|
|
|
/* ── Drag / Drop ── */
|
|
function onDragOver(e: DragEvent) { e.preventDefault(); setDragOver(true); }
|
|
function onDragLeave() { setDragOver(false); }
|
|
function onDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
|
}
|
|
function onBrowse() { inputRef.current?.click(); }
|
|
function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
addFiles(e.target.files);
|
|
}
|
|
e.target.value = '';
|
|
}
|
|
|
|
/* ── Sequential Upload ── */
|
|
async function startUpload() {
|
|
const pending = files.filter(f => f.status === 'pending');
|
|
if (pending.length === 0) return;
|
|
|
|
// Check limits before uploading
|
|
const limitCheck = await checkUploadAllowed().catch(() => null);
|
|
if (limitCheck && !limitCheck.allowed) {
|
|
notify({
|
|
type: 'error',
|
|
title: 'Upload limiet bereikt',
|
|
message: limitCheck.reason || 'Je upload limiet is bereikt',
|
|
duration: 8000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
setUploadIndex(0);
|
|
setUploadDone(false);
|
|
|
|
for (let i = 0; i < pending.length; i++) {
|
|
const entry = pending[i];
|
|
|
|
setFiles(prev => prev.map(f => f.id === entry.id ? { ...f, status: 'uploading' as const } : f));
|
|
setUploadIndex(i + 1);
|
|
|
|
try {
|
|
const result = await uploadFile(entry.file);
|
|
const date = new Date().toISOString();
|
|
addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' });
|
|
// Auto-pin na succesvolle upload
|
|
addPin(result.cid, entry.file.name).catch((err) => {
|
|
console.warn('[UploadPage] Auto-pin mislukt:', err);
|
|
});
|
|
setFiles(prev => prev.map(f =>
|
|
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
|
|
));
|
|
} catch (e: unknown) {
|
|
setFiles(prev => prev.map(f =>
|
|
f.id === entry.id ? { ...f, status: 'error' as const, errorMsg: e instanceof Error ? e.message : 'Upload failed' } : f
|
|
));
|
|
}
|
|
}
|
|
|
|
setUploading(false);
|
|
setUploadDone(true);
|
|
}
|
|
|
|
/* ── Crypto flow ── */
|
|
function onCryptoSelect() { cryptoInputRef.current?.click(); }
|
|
function onCryptoFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (file) { setCryptoFile(file); setCryptoResult(null); }
|
|
e.target.value = '';
|
|
}
|
|
function onCryptoClear() { setCryptoFile(null); setCryptoResult(null); }
|
|
async function doCryptoUpload(): Promise<{ cid: string; size: number }> {
|
|
if (!cryptoFile) throw new Error('No file selected');
|
|
|
|
// Check limits before uploading
|
|
const limitCheck = await checkUploadAllowed().catch(() => null);
|
|
if (limitCheck && !limitCheck.allowed) {
|
|
throw new Error(limitCheck.reason || 'Upload limiet bereikt');
|
|
}
|
|
|
|
const r = await uploadFile(cryptoFile);
|
|
setCryptoResult(r);
|
|
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
|
|
// Auto-pin na succesvolle crypto upload
|
|
addPin(r.cid, cryptoFile.name).catch((err) => {
|
|
console.warn('[UploadPage] Auto-pin crypto mislukt:', err);
|
|
});
|
|
return r;
|
|
}
|
|
function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) {
|
|
// handled in doCryptoUpload
|
|
}
|
|
|
|
/* ── Clipboard ── */
|
|
function copyCid(cid: string) {
|
|
navigator.clipboard.writeText(cid).catch((err) => { console.error('[UploadPage] Clipboard CID failed:', err); });
|
|
setCopied(cid);
|
|
setTimeout(() => setCopied(null), 2000);
|
|
}
|
|
|
|
function copyShareLink(cid: string) {
|
|
const link = gatewayLink(cid);
|
|
navigator.clipboard.writeText(link).catch((err) => { console.error('[UploadPage] Clipboard share link failed:', err); });
|
|
setCopied(`gw-${cid}`);
|
|
setTimeout(() => setCopied(null), 2000);
|
|
}
|
|
|
|
/* ════════════ Render ════════════ */
|
|
|
|
return (
|
|
<PortalLayout>
|
|
<ErrorBoundary label="Upload">
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-white">Upload</h1>
|
|
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS — quick or with crypto payment</p>
|
|
</div>
|
|
|
|
{/* ── Tab switcher ── */}
|
|
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
|
|
<button
|
|
onClick={() => setTab('quick')}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
|
tab === 'quick'
|
|
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
|
: 'text-surface-400 hover:text-surface-200'
|
|
}`}
|
|
>
|
|
<Zap className="w-4 h-4" />
|
|
Quick Upload
|
|
</button>
|
|
<button
|
|
onClick={() => setTab('crypto')}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
|
tab === 'crypto'
|
|
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
|
: 'text-surface-400 hover:text-surface-200'
|
|
}`}
|
|
>
|
|
<ArrowUp className="w-4 h-4" />
|
|
Crypto Payment
|
|
</button>
|
|
</div>
|
|
|
|
{/* Quick Upload tab */}
|
|
{tab === 'quick' && (
|
|
<QuickUpload
|
|
files={files}
|
|
dragOver={dragOver}
|
|
uploading={uploading}
|
|
uploadIndex={uploadIndex}
|
|
uploadDone={uploadDone}
|
|
copied={copied}
|
|
inputRef={inputRef}
|
|
onAddFiles={addFiles}
|
|
onRemoveFile={removeFile}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
onBrowse={onBrowse}
|
|
onInputChange={onInputChange}
|
|
onStartUpload={startUpload}
|
|
onCopyCid={copyCid}
|
|
onCopyShareLink={copyShareLink}
|
|
onClearAll={clearAll}
|
|
gatewayLink={gatewayLink}
|
|
/>
|
|
)}
|
|
|
|
{/* Crypto Payment tab */}
|
|
{tab === 'crypto' && (
|
|
<PaymentProvider>
|
|
<CryptoUpload
|
|
cryptoFile={cryptoFile}
|
|
cryptoResult={cryptoResult}
|
|
copied={copied}
|
|
cryptoInputRef={cryptoInputRef}
|
|
onCryptoSelect={onCryptoSelect}
|
|
onCryptoFileChange={onCryptoFileChange}
|
|
onCryptoClear={onCryptoClear}
|
|
onCryptoPaid={onCryptoPaid}
|
|
doCryptoUpload={doCryptoUpload}
|
|
onCopyCid={copyCid}
|
|
gatewayLink={gatewayLink}
|
|
/>
|
|
</PaymentProvider>
|
|
)}
|
|
</ErrorBoundary>
|
|
</PortalLayout>
|
|
);
|
|
}
|