SIWE auth, API proxy fixes, dashboard, login, usage pages

- SIWE auth flow: challenge/login/logout/me API routes + JWT middleware
- Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI()
- Fixed session_id → nonce field mismatch in AuthProvider
- Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array
- Added middleware route guard (307 redirect to /login)
- Added login, register, profile, usage, upload pages
- Added dashboard components (StorageGauge, FreeTierProgress)
- Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary
- Added payment integration (smart contract ABI, tiers, tokens)
- Fixed IPNS key listing: Name/Id → name/id mapping
- Added PWA support (manifest, service worker, icons)
- Added tests for helpers, limits, search-index, wallet
This commit is contained in:
maikrolf
2026-06-28 18:31:05 +02:00
parent 1ddc89479c
commit c9432a16ba
84 changed files with 6679 additions and 1426 deletions
+115 -27
View File
@@ -1,29 +1,20 @@
'use client';
import { useEffect, useState, useMemo } from 'react';
import { getHistory, clearHistory, getSettings, type UploadRecord } from '@/lib/storage';
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';
import PortalLayout from '@/app/layout-portal';
import Link from 'next/link';
import {
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload,
Database, Calendar, Filter, X, CheckCircle, Link as LinkIcon,
Database, Calendar, Filter, X, CheckCircle, Download,
Link as LinkIcon,
} from 'lucide-react';
/* ── Helpers ── */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return val < 10 ? val.toFixed(1) + ' ' + units[i] : Math.round(val) + ' ' + units[i];
}
function truncateCid(cid: string): string {
if (cid.length <= 18) return cid;
return cid.slice(0, 12) + '…' + cid.slice(-4);
}
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' };
@@ -39,9 +30,14 @@ export default function HistoryPage() {
const [history, setHistory] = useState<UploadRecord[]>([]);
const [search, setSearch] = useState('');
const [copied, setCopied] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const recordKey = (r: UploadRecord) => `${r.cid}||${r.date}`;
function load() {
setHistory(getHistory());
setLoading(false);
}
useEffect(() => { load(); }, []);
@@ -79,13 +75,41 @@ export default function HistoryPage() {
} catch { /* ignore */ }
}
/* ── Gateway URL ── */
const gatewayUrl = useMemo(() => {
try { return getSettings().gatewayUrl; }
catch { return 'https://maos.dedyn.io/ipfs'; }
}, []);
/* ── 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;
});
}
const gatewayLink = (cid: string) => `${gatewayUrl}/${cid}`;
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' : ''}` });
}
/* ── Render ── */
return (
@@ -133,8 +157,17 @@ export default function HistoryPage() {
</div>
)}
{/* ── Loading skeleton ── */}
{loading && (
<div className="glass rounded-xl overflow-hidden">
<div className="px-1">
<SkeletonTable rows={8} cols={3} />
</div>
</div>
)}
{/* ── Empty state ── */}
{history.length === 0 && (
{!loading && history.length === 0 && (
<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">
@@ -163,6 +196,14 @@ export default function HistoryPage() {
<table className="w-full">
<thead>
<tr className="border-b border-surface-800 text-left text-xs font-medium text-surface-500 uppercase tracking-wider">
<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>
<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>
@@ -177,6 +218,15 @@ export default function HistoryPage() {
const gwLink = gatewayLink(rec.cid);
return (
<tr key={rec.cid + rec.date} className="hover:bg-surface-800/30 transition-colors">
{/* 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>
{/* File name */}
<td className="px-5 py-3">
<div className="flex items-center gap-2">
@@ -246,6 +296,15 @@ export default function HistoryPage() {
)}
</button>
{/* 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>
{/* Open in gateway */}
<a
href={gwLink}
@@ -272,9 +331,15 @@ export default function HistoryPage() {
const gwLink = gatewayLink(rec.cid);
return (
<div key={rec.cid + rec.date} className="glass rounded-xl p-4 space-y-3">
{/* Name + method badge */}
{/* Checkbox + Name + method badge */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<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"
/>
<HardDrive className="w-4 h-4 text-surface-500 shrink-0" />
<span className="text-sm text-surface-200 truncate" title={rec.name}>
{rec.name}
@@ -338,7 +403,16 @@ export default function HistoryPage() {
Copy Link
</button>
{/* Download / Open */}
{/* 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 */}
<a
href={gwLink}
target="_blank"
@@ -370,7 +444,7 @@ export default function HistoryPage() {
)}
{/* ── No search results ── */}
{history.length > 0 && filtered.length === 0 && (
{!loading && history.length > 0 && filtered.length === 0 && (
<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" />
@@ -387,6 +461,20 @@ export default function HistoryPage() {
</button>
</div>
)}
{/* ── 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,
},
]}
/>
</div>
</PortalLayout>
);