Files
IPFS-portal/src/app/upload/page.tsx
T
maikrolf 7c7854e08f feat: SIWE auth, auto-pin, pin search/share, explorer improvements
- 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
2026-09-19 12:25:36 +02:00

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 &mdash; 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>
);
}