2026-06-25 19:47:57 +02:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState, useMemo } from 'react';
|
2026-06-28 18:31:05 +02:00
|
|
|
import { getHistory, clearHistory, removeMultipleFromHistory, type UploadRecord } from '@/lib/storage';
|
|
|
|
|
import { formatBytes, gatewayLink, truncateCid } from '@/lib/helpers';
|
|
|
|
|
import { downloadFile } from '@/lib/download';
|
|
|
|
|
import { useNotify } from '@/lib/notifications';
|
|
|
|
|
import { SkeletonTable } from '@/components/Skeleton';
|
|
|
|
|
import BatchBar from '@/components/BatchBar';
|
2026-06-25 19:47:57 +02:00
|
|
|
import PortalLayout from '@/app/layout-portal';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import {
|
|
|
|
|
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload,
|
2026-06-28 18:31:05 +02:00
|
|
|
Database, Calendar, Filter, X, CheckCircle, Download,
|
|
|
|
|
Link as LinkIcon,
|
2026-06-25 19:47:57 +02:00
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
function getMethodBadge(method: UploadRecord['method']): { bg: string; text: string; label: string } {
|
|
|
|
|
switch (method) {
|
|
|
|
|
case 'free': return { bg: 'bg-accent-green/10', text: 'text-accent-green', label: 'free' };
|
|
|
|
|
case 'eth': return { bg: 'bg-brand-500/10', text: 'text-brand-400', label: 'eth' };
|
|
|
|
|
case 'token': return { bg: 'bg-accent-purple/10', text: 'text-accent-purple', label: 'token' };
|
|
|
|
|
case 'quick': return { bg: 'bg-surface-700/50', text: 'text-surface-400', label: 'quick' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ════════════════════════════ Page ════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
export default function HistoryPage() {
|
|
|
|
|
const [history, setHistory] = useState<UploadRecord[]>([]);
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const [copied, setCopied] = useState<string | null>(null);
|
2026-06-28 18:31:05 +02:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
|
|
|
|
|
|
const recordKey = (r: UploadRecord) => `${r.cid}||${r.date}`;
|
2026-06-25 19:47:57 +02:00
|
|
|
|
|
|
|
|
function load() {
|
|
|
|
|
setHistory(getHistory());
|
2026-06-28 18:31:05 +02:00
|
|
|
setLoading(false);
|
2026-06-25 19:47:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
|
|
|
|
|
|
/* ── Filter ── */
|
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
if (!search.trim()) return history;
|
|
|
|
|
const q = search.toLowerCase();
|
|
|
|
|
return history.filter(
|
|
|
|
|
(r) => r.name.toLowerCase().includes(q) || r.cid.toLowerCase().includes(q),
|
|
|
|
|
);
|
|
|
|
|
}, [history, search]);
|
|
|
|
|
|
|
|
|
|
/* ── Stats ── */
|
|
|
|
|
const stats = useMemo(() => {
|
|
|
|
|
const totalUploads = history.length;
|
|
|
|
|
const totalSize = history.reduce((sum, r) => sum + r.size, 0);
|
|
|
|
|
const uniqueCids = new Set(history.map((r) => r.cid)).size;
|
|
|
|
|
return { totalUploads, totalSize, uniqueCids };
|
|
|
|
|
}, [history]);
|
|
|
|
|
|
|
|
|
|
/* ── Clear ── */
|
|
|
|
|
function handleClear() {
|
|
|
|
|
if (!confirm('Are you sure you want to clear all upload history? This cannot be undone.')) return;
|
|
|
|
|
clearHistory();
|
|
|
|
|
setHistory([]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Clipboard ── */
|
|
|
|
|
async function copyToClipboard(text: string, key: string) {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
setCopied(key);
|
|
|
|
|
setTimeout(() => setCopied(null), 2000);
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
/* ── Selection ── */
|
|
|
|
|
function toggleSelection(key: string) {
|
|
|
|
|
setSelected((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (next.has(key)) next.delete(key);
|
|
|
|
|
else next.add(key);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-25 19:47:57 +02:00
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
function toggleAll() {
|
|
|
|
|
if (selected.size === filtered.length) {
|
|
|
|
|
setSelected(new Set());
|
|
|
|
|
} else {
|
|
|
|
|
setSelected(new Set(filtered.map(recordKey)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Batch delete ── */
|
|
|
|
|
const { notify } = useNotify();
|
|
|
|
|
|
|
|
|
|
function handleBatchDelete() {
|
|
|
|
|
const count = selected.size;
|
|
|
|
|
if (!confirm(`Delete ${count} upload${count !== 1 ? 's' : ''} from history?`)) return;
|
|
|
|
|
|
|
|
|
|
const keys = Array.from(selected).map((key) => {
|
|
|
|
|
const idx = key.lastIndexOf('||');
|
|
|
|
|
return { cid: key.slice(0, idx), date: key.slice(idx + 2) };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
removeMultipleFromHistory(keys);
|
|
|
|
|
setHistory((prev) => prev.filter((r) => !selected.has(recordKey(r))));
|
|
|
|
|
setSelected(new Set());
|
|
|
|
|
notify({ type: 'success', title: `Deleted ${count} upload${count !== 1 ? 's' : ''}` });
|
|
|
|
|
}
|
2026-06-25 19:47:57 +02:00
|
|
|
|
|
|
|
|
/* ── Render ── */
|
|
|
|
|
return (
|
|
|
|
|
<PortalLayout>
|
|
|
|
|
<div className="animate-fade-in">
|
|
|
|
|
{/* ── Header ── */}
|
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold text-white">Upload History</h1>
|
|
|
|
|
<p className="text-sm text-surface-400 mt-1">
|
|
|
|
|
{stats.totalUploads > 0
|
|
|
|
|
? `${stats.totalUploads} upload${stats.totalUploads !== 1 ? 's' : ''} · ${formatBytes(stats.totalSize)} total · ${stats.uniqueCids} unique file${stats.uniqueCids !== 1 ? 's' : ''}`
|
|
|
|
|
: 'Track your past uploads'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
{history.length > 0 && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleClear}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors self-start"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-4 h-4" />
|
|
|
|
|
Clear History
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── Search ── */}
|
|
|
|
|
{history.length > 0 && (
|
|
|
|
|
<div className="relative mb-4">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500" />
|
|
|
|
|
<input
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
placeholder="Search by file name or CID…"
|
|
|
|
|
className="w-full pl-10 pr-10 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
|
|
|
|
|
/>
|
|
|
|
|
{search && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSearch('')}
|
|
|
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-4 h-4 text-surface-500" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
{/* ── Loading skeleton ── */}
|
|
|
|
|
{loading && (
|
|
|
|
|
<div className="glass rounded-xl overflow-hidden">
|
|
|
|
|
<div className="px-1">
|
|
|
|
|
<SkeletonTable rows={8} cols={3} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-25 19:47:57 +02:00
|
|
|
{/* ── Empty state ── */}
|
2026-06-28 18:31:05 +02:00
|
|
|
{!loading && history.length === 0 && (
|
2026-06-25 19:47:57 +02:00
|
|
|
<div className="glass rounded-xl p-12 text-center animate-fade-in">
|
|
|
|
|
<div className="flex justify-center mb-4">
|
|
|
|
|
<div className="p-3 rounded-xl bg-surface-800/50">
|
|
|
|
|
<Clock className="w-8 h-8 text-surface-500" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<h3 className="text-lg font-semibold text-surface-300 mb-2">No upload history yet</h3>
|
|
|
|
|
<p className="text-sm text-surface-500 mb-6 max-w-md mx-auto">
|
|
|
|
|
Your uploaded files will appear here so you can easily copy CIDs, download files, or check upload details.
|
|
|
|
|
</p>
|
|
|
|
|
<Link
|
|
|
|
|
href="/upload"
|
|
|
|
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Upload className="w-4 h-4" />
|
|
|
|
|
Upload your first file
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Desktop table ── */}
|
|
|
|
|
{filtered.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
{/* Desktop table — hidden on small screens */}
|
|
|
|
|
<div className="hidden sm:block glass rounded-xl overflow-hidden">
|
|
|
|
|
<table className="w-full">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="border-b border-surface-800 text-left text-xs font-medium text-surface-500 uppercase tracking-wider">
|
2026-06-28 18:31:05 +02:00
|
|
|
<th className="px-5 py-3 w-10">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selected.size === filtered.length && filtered.length > 0}
|
|
|
|
|
onChange={toggleAll}
|
|
|
|
|
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
|
|
|
|
|
/>
|
|
|
|
|
</th>
|
2026-06-25 19:47:57 +02:00
|
|
|
<th className="px-5 py-3">File Name</th>
|
|
|
|
|
<th className="px-5 py-3">CID</th>
|
|
|
|
|
<th className="px-5 py-3">Size</th>
|
|
|
|
|
<th className="px-5 py-3">Date</th>
|
|
|
|
|
<th className="px-5 py-3">Method</th>
|
|
|
|
|
<th className="px-5 py-3 text-right">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-surface-800">
|
|
|
|
|
{filtered.map((rec) => {
|
|
|
|
|
const badge = getMethodBadge(rec.method);
|
|
|
|
|
const gwLink = gatewayLink(rec.cid);
|
|
|
|
|
return (
|
|
|
|
|
<tr key={rec.cid + rec.date} className="hover:bg-surface-800/30 transition-colors">
|
2026-06-28 18:31:05 +02:00
|
|
|
{/* Checkbox */}
|
|
|
|
|
<td className="px-5 py-3">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selected.has(recordKey(rec))}
|
|
|
|
|
onChange={() => toggleSelection(recordKey(rec))}
|
|
|
|
|
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
2026-06-25 19:47:57 +02:00
|
|
|
{/* File name */}
|
|
|
|
|
<td className="px-5 py-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<HardDrive className="w-3.5 h-3.5 text-surface-500 shrink-0" />
|
|
|
|
|
<span className="text-sm text-surface-200 truncate max-w-[200px]" title={rec.name}>
|
|
|
|
|
{rec.name}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* CID */}
|
|
|
|
|
<td className="px-5 py-3">
|
|
|
|
|
<span
|
|
|
|
|
className="text-xs font-mono text-surface-400 cursor-default"
|
|
|
|
|
title={rec.cid}
|
|
|
|
|
>
|
|
|
|
|
{truncateCid(rec.cid)}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* Size */}
|
|
|
|
|
<td className="px-5 py-3">
|
|
|
|
|
<span className="text-xs text-surface-400">{formatBytes(rec.size)}</span>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* Date */}
|
|
|
|
|
<td className="px-5 py-3">
|
|
|
|
|
<div className="text-xs text-surface-400">
|
|
|
|
|
<div>{new Date(rec.date).toLocaleDateString()}</div>
|
|
|
|
|
<div className="text-surface-500">{new Date(rec.date).toLocaleTimeString()}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* Method badge */}
|
|
|
|
|
<td className="px-5 py-3">
|
|
|
|
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
|
|
|
|
|
{badge.label}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
<td className="px-5 py-3 text-right">
|
|
|
|
|
<div className="flex items-center justify-end gap-1">
|
|
|
|
|
{/* Copy CID */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => copyToClipboard(rec.cid, `cid-${rec.cid}`)}
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
|
|
|
|
title="Copy CID"
|
|
|
|
|
>
|
|
|
|
|
{copied === `cid-${rec.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>
|
|
|
|
|
|
|
|
|
|
{/* Copy gateway link */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => copyToClipboard(gwLink, `gw-${rec.cid}`)}
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
|
|
|
|
title="Copy gateway link"
|
|
|
|
|
>
|
|
|
|
|
{copied === `gw-${rec.cid}` ? (
|
|
|
|
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
|
|
|
|
) : (
|
|
|
|
|
<LinkIcon className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
{/* Download */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => downloadFile(rec.cid, rec.name).catch(() => {})}
|
|
|
|
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
|
|
|
|
title="Download file"
|
|
|
|
|
>
|
|
|
|
|
<Download className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-06-25 19:47:57 +02:00
|
|
|
{/* Open in gateway */}
|
|
|
|
|
<a
|
|
|
|
|
href={gwLink}
|
|
|
|
|
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>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── Mobile cards ── */}
|
|
|
|
|
<div className="sm:hidden space-y-3">
|
|
|
|
|
{filtered.map((rec) => {
|
|
|
|
|
const badge = getMethodBadge(rec.method);
|
|
|
|
|
const gwLink = gatewayLink(rec.cid);
|
|
|
|
|
return (
|
|
|
|
|
<div key={rec.cid + rec.date} className="glass rounded-xl p-4 space-y-3">
|
2026-06-28 18:31:05 +02:00
|
|
|
{/* Checkbox + Name + method badge */}
|
2026-06-25 19:47:57 +02:00
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
2026-06-28 18:31:05 +02:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selected.has(recordKey(rec))}
|
|
|
|
|
onChange={() => toggleSelection(recordKey(rec))}
|
|
|
|
|
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mt-0.5"
|
|
|
|
|
/>
|
2026-06-25 19:47:57 +02:00
|
|
|
<HardDrive className="w-4 h-4 text-surface-500 shrink-0" />
|
|
|
|
|
<span className="text-sm text-surface-200 truncate" title={rec.name}>
|
|
|
|
|
{rec.name}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
|
|
|
|
|
{badge.label}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* CID */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span
|
|
|
|
|
className="text-xs font-mono text-surface-400 truncate"
|
|
|
|
|
title={rec.cid}
|
|
|
|
|
>
|
|
|
|
|
{truncateCid(rec.cid)}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => copyToClipboard(rec.cid, `cid-${rec.cid}`)}
|
|
|
|
|
className="p-1 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
|
|
|
|
|
>
|
|
|
|
|
{copied === `cid-${rec.cid}` ? (
|
|
|
|
|
<CheckCircle className="w-3 h-3 text-accent-green" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy className="w-3 h-3 text-surface-500" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Size + date */}
|
|
|
|
|
<div className="flex items-center gap-4 text-xs text-surface-500">
|
|
|
|
|
<span>{formatBytes(rec.size)}</span>
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<Calendar className="w-3 h-3" />
|
|
|
|
|
{new Date(rec.date).toLocaleDateString()}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<Clock className="w-3 h-3" />
|
|
|
|
|
{new Date(rec.date).toLocaleTimeString()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Actions row */}
|
|
|
|
|
<div className="flex items-center gap-2 pt-1">
|
|
|
|
|
{/* Copy CID */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => copyToClipboard(rec.cid, `cid-m-${rec.cid}`)}
|
|
|
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Copy className="w-3 h-3" />
|
|
|
|
|
Copy CID
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Copy link */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => copyToClipboard(gwLink, `gw-m-${rec.cid}`)}
|
|
|
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<LinkIcon className="w-3 h-3" />
|
|
|
|
|
Copy Link
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-06-28 18:31:05 +02:00
|
|
|
{/* Download */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => downloadFile(rec.cid, rec.name).catch(() => {})}
|
|
|
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Download className="w-3 h-3" />
|
|
|
|
|
Download
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Open */}
|
2026-06-25 19:47:57 +02:00
|
|
|
<a
|
|
|
|
|
href={gwLink}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink className="w-3 h-3" />
|
|
|
|
|
Open
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── Results count ── */}
|
|
|
|
|
{search && filtered.length < history.length && (
|
|
|
|
|
<div className="mt-3 text-center text-xs text-surface-500">
|
|
|
|
|
Showing {filtered.length} of {history.length} uploads
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSearch('')}
|
|
|
|
|
className="ml-2 text-brand-400 hover:text-brand-300 underline transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Clear filter
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── No search results ── */}
|
2026-06-28 18:31:05 +02:00
|
|
|
{!loading && history.length > 0 && filtered.length === 0 && (
|
2026-06-25 19:47:57 +02:00
|
|
|
<div className="glass rounded-xl p-12 text-center">
|
|
|
|
|
<div className="flex justify-center mb-3">
|
|
|
|
|
<Search className="w-8 h-8 text-surface-500" />
|
|
|
|
|
</div>
|
|
|
|
|
<h3 className="text-base font-semibold text-surface-300 mb-1">No results found</h3>
|
|
|
|
|
<p className="text-sm text-surface-500">
|
|
|
|
|
No uploads match “{search}”
|
|
|
|
|
</p>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSearch('')}
|
|
|
|
|
className="mt-4 text-sm text-brand-400 hover:text-brand-300 underline transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Clear search
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-28 18:31:05 +02:00
|
|
|
{/* ── Batch bar ── */}
|
|
|
|
|
<BatchBar
|
|
|
|
|
count={selected.size}
|
|
|
|
|
onClear={() => setSelected(new Set())}
|
|
|
|
|
actions={[
|
|
|
|
|
{
|
|
|
|
|
key: 'delete',
|
|
|
|
|
label: 'Delete selected',
|
|
|
|
|
icon: <Trash2 className="w-3.5 h-3.5" />,
|
|
|
|
|
variant: 'danger',
|
|
|
|
|
onClick: handleBatchDelete,
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
/>
|
2026-06-25 19:47:57 +02:00
|
|
|
</div>
|
|
|
|
|
</PortalLayout>
|
|
|
|
|
);
|
|
|
|
|
}
|