feat: initial IPFS Portal — Next.js frontend for remote IPFS node management
Multi-file upload with Quick Upload + Crypto Payment tabs. Dashboard with live IPFS metrics, Explorer, Peers, Pins, History, Settings pages. Docker deploy (standalone output), full test suite (16 Playwright tests). Payment integration via IPFSPortalPayment + MockUSDC contracts on zkSync.
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, DragEvent, useCallback } from 'react';
|
||||
import { uploadFile } from '@/lib/api';
|
||||
import { addToHistory, type UploadRecord } from '@/lib/storage';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import PaymentPanel from '@/components/PaymentPanel';
|
||||
import {
|
||||
Upload, File, Image as FileImage, CheckCircle, XCircle, Loader2,
|
||||
Copy, ExternalLink, Trash2, Globe, Clock, ArrowUp, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Constants ── */
|
||||
const MAX_FILES = 20;
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
|
||||
/* ── 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];
|
||||
}
|
||||
|
||||
/* ── Types ── */
|
||||
interface FileEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
preview?: string; // object URL for images
|
||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
||||
result?: { cid: string; size: number };
|
||||
errorMsg?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
type TabMode = 'quick' | 'crypto';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
export default function UploadPage() {
|
||||
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 arr = Array.from(incoming);
|
||||
const remaining = MAX_FILES - files.length;
|
||||
const toAdd = arr.slice(0, remaining);
|
||||
|
||||
const entries: FileEntry[] = toAdd
|
||||
.filter(f => f.size <= MAX_FILE_SIZE)
|
||||
.filter(f => !files.some(ex => ex.file.name === f.name && ex.file.size === f.size))
|
||||
.map(f => ({
|
||||
id: Math.random().toString(36).slice(2, 9),
|
||||
file: f,
|
||||
preview: ALLOWED_TYPES.includes(f.type) ? URL.createObjectURL(f) : undefined,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
|
||||
if (entries.length === 0) return;
|
||||
setFiles(prev => [...prev, ...entries]);
|
||||
setUploadDone(false);
|
||||
}, [files]);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
|
||||
setUploading(true);
|
||||
setUploadIndex(0);
|
||||
setUploadDone(false);
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const entry = pending[i];
|
||||
|
||||
// Mark uploading
|
||||
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();
|
||||
|
||||
// Save to history
|
||||
addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' });
|
||||
|
||||
// Mark done
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
|
||||
));
|
||||
} catch (e: any) {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === entry.id ? { ...f, status: 'error' as const, errorMsg: 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');
|
||||
const r = await uploadFile(cryptoFile);
|
||||
setCryptoResult(r);
|
||||
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
|
||||
return r;
|
||||
}
|
||||
function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) {
|
||||
// handled in doCryptoUpload
|
||||
}
|
||||
|
||||
/* ── Clipboard ── */
|
||||
async function copyCid(cid: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cid);
|
||||
setCopied(cid);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function gatewayLink(cid: string) {
|
||||
return `https://maos.dedyn.io/ipfs/${cid}`;
|
||||
}
|
||||
|
||||
/* ── Stats ── */
|
||||
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');
|
||||
|
||||
/* ════════════ Render ════════════ */
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<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>
|
||||
|
||||
{/* ════════════ TAB: Quick Upload ════════════ */}
|
||||
{tab === 'quick' && (
|
||||
<div>
|
||||
{/* ── Drop zone ── */}
|
||||
{!uploading && !uploadDone && (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={files.length < MAX_FILES ? onDrop : undefined}
|
||||
onClick={files.length < MAX_FILES ? onBrowse : undefined}
|
||||
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"
|
||||
onChange={onInputChange}
|
||||
disabled={uploading || files.length >= MAX_FILES}
|
||||
/>
|
||||
<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">
|
||||
Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each
|
||||
</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={() => { files.forEach(f => f.preview && URL.revokeObjectURL(f.preview)); setFiles([]); setUploadDone(false); }}
|
||||
className="text-xs text-surface-500 hover:text-accent-rose transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
<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` }}
|
||||
>
|
||||
{/* Preview / icon */}
|
||||
<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>
|
||||
|
||||
{/* Info */}
|
||||
<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>
|
||||
)}
|
||||
{entry.status === 'done' && entry.result && (
|
||||
<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>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{entry.status === 'done' && entry.result && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => copyCid(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={() => removeFile(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={startUpload}
|
||||
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={() => { files.forEach(f => f.preview && URL.revokeObjectURL(f.preview)); setFiles([]); setUploadDone(false); }}
|
||||
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 */}
|
||||
{files.filter(f => f.status === 'done').map(entry => entry.result && (
|
||||
<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>
|
||||
<span className="text-xs text-surface-500">{formatBytes(entry.result.size)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => copyCid(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={() => {
|
||||
const link = gatewayLink(entry.result!.cid);
|
||||
navigator.clipboard.writeText(link).catch(() => {});
|
||||
setCopied(`gw-${entry.result!.cid}`);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* ════════════ TAB: Crypto Payment ════════════ */}
|
||||
{tab === 'crypto' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: file selection */}
|
||||
<div className="lg:col-span-2">
|
||||
{!cryptoFile ? (
|
||||
<div
|
||||
onClick={onCryptoSelect}
|
||||
className="border-2 border-dashed border-surface-700 hover:border-surface-500 rounded-xl p-12 text-center cursor-pointer transition-all bg-surface-900/50"
|
||||
>
|
||||
<input
|
||||
ref={cryptoInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onCryptoFileChange}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<ArrowUp 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 select</span> a file
|
||||
</p>
|
||||
<p className="text-xs text-surface-500 mt-1">
|
||||
Single file only. Pay with ETH, USDC, or MAOS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass rounded-xl p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<File className="w-8 h-8 text-brand-400" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-surface-200 font-medium truncate">{cryptoFile.name}</p>
|
||||
<p className="text-xs text-surface-500">{formatBytes(cryptoFile.size)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCryptoClear}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-surface-500 hover:text-accent-rose" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Crypto result */}
|
||||
{cryptoResult && (
|
||||
<div className="mt-4 space-y-2 p-3 rounded-lg bg-accent-green/10 border border-accent-green/20 animate-fade-in">
|
||||
<div className="flex items-center gap-2 text-accent-green">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Upload complete</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-surface-400">CID</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-surface-300 truncate max-w-[180px]">{cryptoResult.cid}</span>
|
||||
<button onClick={() => copyCid(cryptoResult.cid)} className="p-0.5 hover:text-surface-100">
|
||||
{copied === cryptoResult.cid ? (
|
||||
<CheckCircle className="w-3 h-3 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-surface-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-surface-400">Gateway</span>
|
||||
<a
|
||||
href={gatewayLink(cryptoResult.cid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-brand-400 hover:text-brand-300"
|
||||
>
|
||||
View <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: PaymentPanel */}
|
||||
<div className="lg:col-span-1">
|
||||
{cryptoFile ? (
|
||||
<PaymentPanel
|
||||
fileSize={cryptoFile.size}
|
||||
fileName={cryptoFile.name}
|
||||
onPaid={onCryptoPaid}
|
||||
onUpload={doCryptoUpload}
|
||||
/>
|
||||
) : (
|
||||
<div className="glass rounded-xl p-5 text-center">
|
||||
<ArrowUp className="w-6 h-6 text-surface-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-surface-500">
|
||||
Select a file to see pricing
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user