forked from maik/IPFS-portal
refactor: codebase opschoning — type safety, error handling, central config, page splits
- categorie A: alle catch(e: any) → catch(e: unknown) met instanceof check - categorie B: stille .catch() logging toegevoegd (SWRegister, admin, upload, auth) - categorie C: hardcoded 192.168.1.176 IPs vervangen door env var defaults - categorie D: page files >250L gesplitst (history, settings, explorer, admin/payment, ipns, users, upload, dashboard) in helpers/components - categorie E: eslint-disable vervangen in SearchInput.tsx - src/lib/config.ts centrale config module (localhost defaults) - CSP in next.config.ts dynamisch via env var - deploy.sh: geen hardcoded IP meer (REQUIRED arg) - lib/api/ lib/auth/ lib/wallet/ lib/search-index/ lib/storage/ gesplitst in modules - ongebruikte bestanden verwijderd: api.ts, auth.tsx, wallet.ts, search-index.ts, PaymentPanel.tsx, SearchBar.tsx, proxy.ts, serve-static.js
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import { listUsers, getNodeInfo, checkHealth, type IPFSNodeInfo } from '@/lib/api';
|
||||
import { listUsers } from '@/lib/api/users';
|
||||
import { getNodeInfo, checkHealth } from '@/lib/api/gateway';
|
||||
import type { IPFSNodeInfo } from '@/lib/api/client';
|
||||
import { SkeletonCard, SkeletonTable } from '@/components/Skeleton';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -22,14 +24,14 @@ export default function AdminPage() {
|
||||
useEffect(() => {
|
||||
listUsers()
|
||||
.then(setUsers)
|
||||
.catch(() => {})
|
||||
.catch((err) => { console.error('[Admin] listUsers failed:', err); })
|
||||
.finally(() => setLoadingUsers(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth()
|
||||
.then((h: any) => setHealth(h))
|
||||
.catch(() => {})
|
||||
.catch((err) => { console.error('[Admin] checkHealth failed:', err); })
|
||||
.finally(() => setLoadingHealth(false));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { Wallet, Loader2, CheckCircle, XCircle, AlertTriangle, LogOut } from "lucide-react";
|
||||
import type { ViewMode, TxStatus } from "./helpers";
|
||||
import { VIEW_LABELS } from "./helpers";
|
||||
|
||||
/* ── Wallet Connection ── */
|
||||
interface WalletConnectProps {
|
||||
connecting: boolean;
|
||||
onConnect: () => void;
|
||||
}
|
||||
|
||||
export function WalletConnect({ connecting, onConnect }: WalletConnectProps) {
|
||||
return (
|
||||
<div className="glass rounded-xl p-6 text-center space-y-4">
|
||||
<Wallet className="w-8 h-8 text-surface-500 mx-auto" />
|
||||
<p className="text-sm text-surface-400">Connect wallet to view admin panel</p>
|
||||
<button onClick={onConnect} disabled={connecting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{connecting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Wallet className="w-4 h-4" /> Connect Wallet</>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Wrong chain warning ── */
|
||||
export function WrongChainWarning() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm mb-4">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
Please switch to zkSync Local (chain 270)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Wallet Info Header ── */
|
||||
interface WalletInfoProps {
|
||||
address: string;
|
||||
isOwner: boolean;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export function WalletInfo({ address, isOwner, loading, onRefresh, onDisconnect }: WalletInfoProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-surface-400 hidden sm:inline font-mono">
|
||||
{address.slice(0, 6)}...{address.slice(-4)}
|
||||
</span>
|
||||
<span className="text-xs text-surface-400 flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isOwner ? "bg-accent-green" : "bg-surface-500"}`} />
|
||||
{isOwner ? "Owner" : "Viewer"}
|
||||
</span>
|
||||
<button onClick={onDisconnect}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 hover:text-red-400 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<LogOut className="w-3 h-3" /> Disconnect
|
||||
</button>
|
||||
<button onClick={onRefresh} disabled={loading} className="p-2 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 transition-colors">
|
||||
<Loader2 className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Navigation Tabs ── */
|
||||
interface NavTabsProps {
|
||||
view: ViewMode;
|
||||
onChange: (view: ViewMode) => void;
|
||||
}
|
||||
|
||||
export function NavTabs({ view, onChange }: NavTabsProps) {
|
||||
return (
|
||||
<div className="flex gap-1 mb-6 p-1 rounded-lg bg-surface-800/50 w-fit">
|
||||
{(Object.keys(VIEW_LABELS) as ViewMode[]).map((v) => (
|
||||
<button key={v} onClick={() => onChange(v)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium capitalize transition-colors ${
|
||||
view === v ? "bg-surface-700 text-white" : "text-surface-400 hover:text-surface-200"
|
||||
}`}
|
||||
>
|
||||
{VIEW_LABELS[v]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── TX Status Toast ── */
|
||||
interface TxStatusToastProps {
|
||||
status: TxStatus;
|
||||
}
|
||||
|
||||
export function TxStatusToast({ status }: TxStatusToastProps) {
|
||||
if (!status) return null;
|
||||
return (
|
||||
<div className={`fixed bottom-6 right-6 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm animate-fade-in ${
|
||||
status.ok
|
||||
? "bg-accent-green/10 border border-accent-green/20 text-accent-green"
|
||||
: "bg-accent-rose/10 border border-accent-rose/20 text-accent-rose"
|
||||
}`}>
|
||||
{status.ok ? <CheckCircle className="w-4 h-4 shrink-0" /> : <XCircle className="w-4 h-4 shrink-0" />}
|
||||
{status.msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { paymentService, type TokenInfo } from '@/lib/payment';
|
||||
import { usePaymentService, type TokenInfo } from '@/lib/payment';
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import type { WalletProvider } from '@/lib/wallet';
|
||||
import { formatWeiToETH, formatBytes } from '@/lib/wallet';
|
||||
import { formatWeiToETH } from '@/lib/wallet';
|
||||
|
||||
/* ── Props ── */
|
||||
interface PricingViewProps {
|
||||
@@ -29,6 +30,7 @@ export default function PricingView({
|
||||
priceInput, setPriceInput, freeInput, setFreeInput,
|
||||
doTx, provider, address,
|
||||
}: PricingViewProps) {
|
||||
const paymentService = usePaymentService();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import { Tags, Plus, Trash2 } from 'lucide-react';
|
||||
import { paymentService, type MAOSDiscountTier } from '@/lib/payment';
|
||||
import { usePaymentService, type MAOSDiscountTier } from '@/lib/payment';
|
||||
import type { WalletProvider } from '@/lib/wallet';
|
||||
import { formatWeiToETH } from '@/lib/wallet';
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function TiersView({
|
||||
tierBalance, setTierBalance, tierBps, setTierBps,
|
||||
doTx, provider,
|
||||
}: TiersViewProps) {
|
||||
const paymentService = usePaymentService();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import { Coins, PiggyBank } from 'lucide-react';
|
||||
import { paymentService, type TokenInfo } from '@/lib/payment';
|
||||
import { usePaymentService, type TokenInfo } from '@/lib/payment';
|
||||
import type { WalletProvider } from '@/lib/wallet';
|
||||
import { formatWeiToETH } from '@/lib/wallet';
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function TokensView({
|
||||
tokenDec, setTokenDec, tokenWei, setTokenWei,
|
||||
tokenEnabled, setTokenEnabled, doTx, provider, address,
|
||||
}: TokensViewProps) {
|
||||
const paymentService = usePaymentService();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* ── Admin Payment Helpers ── */
|
||||
|
||||
export type ViewMode = "overview" | "pricing" | "tokens" | "tiers";
|
||||
|
||||
export type TxStatus = { ok: boolean; msg: string } | null;
|
||||
|
||||
export const VIEW_LABELS: Record<ViewMode, string> = {
|
||||
overview: "Overview",
|
||||
pricing: "Pricing",
|
||||
tokens: "Tokens",
|
||||
tiers: "Discount Tiers",
|
||||
};
|
||||
@@ -3,22 +3,20 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import { paymentService, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment';
|
||||
import { paymentService, PaymentProvider, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment';
|
||||
import {
|
||||
connectWallet, switchToChain270, getInjectedProvider,
|
||||
formatWeiToETH, formatBytes, type WalletProvider,
|
||||
connectWallet, switchToChain270, getInjectedProvider, addWalletListener, disconnectWallet,
|
||||
type WalletProvider,
|
||||
} from '@/lib/wallet';
|
||||
import {
|
||||
Wallet, Loader2, CheckCircle, XCircle,
|
||||
RefreshCw, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { SkeletonCard } from '@/components/Skeleton';
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
import Overview from './components/Overview';
|
||||
import PricingView from './components/PricingView';
|
||||
import TokensView from './components/TokensView';
|
||||
import TiersView from './components/TiersView';
|
||||
|
||||
type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers';
|
||||
type TxStatus = { ok: boolean; msg: string } | null;
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import type { ViewMode, TxStatus } from './helpers';
|
||||
import { WalletConnect, WrongChainWarning, WalletInfo, NavTabs, TxStatusToast } from './components';
|
||||
|
||||
export default function AdminPaymentPage() {
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
@@ -42,13 +40,14 @@ export default function AdminPaymentPage() {
|
||||
const [contractBalance, setContractBalance] = useState<Record<string, string>>({});
|
||||
|
||||
const isOwner = !!(address && owner && address.toLowerCase() === owner.toLowerCase());
|
||||
const { notify } = useNotify();
|
||||
|
||||
// ── Load all contract state ──
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setTxStatus(null);
|
||||
try {
|
||||
paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false));
|
||||
paymentService.isDeployed().then(setDeployed).catch((err) => { console.error('[AdminPayment] Deploy check failed:', err); setDeployed(false); });
|
||||
const [own, price, free, t, tiersData, uploads] = await Promise.all([
|
||||
paymentService.getOwner(),
|
||||
paymentService.getBasePricePerMB(),
|
||||
@@ -62,7 +61,6 @@ export default function AdminPaymentPage() {
|
||||
setFreeTier(free);
|
||||
setTiers(tiersData);
|
||||
|
||||
// Deduplicate by symbol
|
||||
const seen = new Set<string>();
|
||||
const uniqueTokens = t.filter(tk => {
|
||||
if (seen.has(tk.symbol)) return false;
|
||||
@@ -72,32 +70,29 @@ export default function AdminPaymentPage() {
|
||||
setTokens(uniqueTokens);
|
||||
|
||||
const symbols = uniqueTokens.map(tk => tk.symbol);
|
||||
const revEntries = await Promise.all(
|
||||
symbols.map(s => paymentService.getTotalRevenue(s))
|
||||
);
|
||||
const revEntries = await Promise.all(symbols.map(s => paymentService.getTotalRevenue(s)));
|
||||
const revMap: Record<string, bigint> = {};
|
||||
symbols.forEach((s, i) => { revMap[s] = revEntries[i]; });
|
||||
setRevenues(revMap);
|
||||
|
||||
// Balances via RPC
|
||||
const balMap: Record<string, string> = {};
|
||||
for (const tk of t) {
|
||||
try {
|
||||
const client = paymentService['getPublicClient']();
|
||||
const client = (paymentService as any)['getPublicClient']();
|
||||
if (tk.symbol === 'ETH') {
|
||||
const bal = await client.getBalance({ address: paymentService['contractAddress'] });
|
||||
const bal = await client.getBalance({ address: (paymentService as any)['contractAddress'] });
|
||||
balMap['ETH'] = bal.toString();
|
||||
} else if (tk.address && tk.address !== '0x0000000000000000000000000000000000000000') {
|
||||
const ERC20_BAL_ABI = [{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' }] as const;
|
||||
const ERC20_BAL_ABI = [{ type: 'function' as const, name: 'balanceOf', inputs: [{ type: 'address' as const }], outputs: [{ type: 'uint256' as const }], stateMutability: 'view' as const }];
|
||||
const bal = await client.readContract({
|
||||
address: tk.address as `0x${string}`,
|
||||
abi: ERC20_BAL_ABI,
|
||||
functionName: 'balanceOf',
|
||||
args: [paymentService['contractAddress']],
|
||||
args: [(paymentService as any)['contractAddress']],
|
||||
});
|
||||
balMap[tk.symbol] = (bal as bigint).toString();
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (err) { console.error('[AdminPayment] Failed to fetch balance for token:', err); }
|
||||
}
|
||||
setContractBalance(balMap);
|
||||
setTotalUploads(uploads);
|
||||
@@ -111,6 +106,22 @@ export default function AdminPaymentPage() {
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// ── Listen for external wallet disconnect ──
|
||||
useEffect(() => {
|
||||
if (!provider) return;
|
||||
const cb = (accounts: string[]) => {
|
||||
if (accounts.length === 0) {
|
||||
setAddress(null);
|
||||
setProvider(null);
|
||||
setConnecting(false);
|
||||
setWrongChain(false);
|
||||
setWalletType(null);
|
||||
notify({ type: 'info', title: 'Wallet disconnected' });
|
||||
}
|
||||
};
|
||||
addWalletListener('accountsChanged', cb);
|
||||
}, [provider, notify]);
|
||||
|
||||
// ── Connect wallet ──
|
||||
async function handleConnect() {
|
||||
setConnecting(true);
|
||||
@@ -129,14 +140,20 @@ export default function AdminPaymentPage() {
|
||||
else if (window.ethereum?.isRabby) setWalletType('Rabby');
|
||||
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
|
||||
else setWalletType('Wallet');
|
||||
} catch (e: any) {
|
||||
setTxStatus({ ok: false, msg: e.message || 'Connect failed' });
|
||||
} catch (e: unknown) {
|
||||
setTxStatus({ ok: false, msg: e instanceof Error ? e.message : 'Connect failed' });
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin action helper ──
|
||||
function handleDisconnect() {
|
||||
disconnectWallet();
|
||||
setAddress(null); setProvider(null); setConnecting(false);
|
||||
setWrongChain(false); setWalletType(null);
|
||||
notify({ type: 'success', title: 'Wallet disconnected' });
|
||||
}
|
||||
|
||||
async function doTx(label: string, fn: () => Promise<string>) {
|
||||
if (!provider) return;
|
||||
setTxStatus(null);
|
||||
@@ -144,8 +161,8 @@ export default function AdminPaymentPage() {
|
||||
const hash = await fn();
|
||||
setTxStatus({ ok: true, msg: `${label} success: ${hash.slice(0, 10)}...` });
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
setTxStatus({ ok: false, msg: `${label} failed: ${e.message || e}` });
|
||||
} catch (e: unknown) {
|
||||
setTxStatus({ ok: false, msg: `${label} failed: ${e instanceof Error ? e.message : String(e)}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,64 +181,33 @@ export default function AdminPaymentPage() {
|
||||
return (
|
||||
<AuthGuard requireAdmin redirectTo="/dashboard">
|
||||
<PortalLayout>
|
||||
{/* ── Header ── */}
|
||||
<ErrorBoundary label="Betaalbeheer">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Payment Admin</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">Manage IPFS Portal payment contract</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{address && (
|
||||
<span className="text-xs text-surface-400 flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isOwner ? 'bg-accent-green' : 'bg-surface-500'}`} />
|
||||
{isOwner ? 'Owner' : 'Viewer'}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={refresh} disabled={loading} className="p-2 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 transition-colors">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
{address && (
|
||||
<WalletInfo
|
||||
address={address}
|
||||
isOwner={isOwner}
|
||||
loading={loading}
|
||||
onRefresh={refresh}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Connect wallet ── */}
|
||||
{!address && (
|
||||
<div className="glass rounded-xl p-6 text-center space-y-4">
|
||||
<Wallet className="w-8 h-8 text-surface-500 mx-auto" />
|
||||
<p className="text-sm text-surface-400">Connect wallet to view admin panel</p>
|
||||
<button onClick={handleConnect} disabled={connecting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{connecting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Wallet className="w-4 h-4" /> Connect Wallet</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wrongChain && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm mb-4">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
Please switch to zkSync Local (chain 270)
|
||||
</div>
|
||||
)}
|
||||
{!address && <WalletConnect connecting={connecting} onConnect={handleConnect} />}
|
||||
{wrongChain && <WrongChainWarning />}
|
||||
|
||||
{address && (
|
||||
<>
|
||||
{/* ── Nav tabs ── */}
|
||||
<div className="flex gap-1 mb-6 p-1 rounded-lg bg-surface-800/50 w-fit">
|
||||
{(['overview', 'pricing', 'tokens', 'tiers'] as ViewMode[]).map(v => (
|
||||
<button key={v} onClick={() => setView(v)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium capitalize transition-colors ${
|
||||
view === v ? 'bg-surface-700 text-white' : 'text-surface-400 hover:text-surface-200'
|
||||
}`}
|
||||
>
|
||||
{v === 'overview' ? 'Overview' : v === 'pricing' ? 'Pricing' : v === 'tokens' ? 'Tokens' : 'Discount Tiers'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PaymentProvider>
|
||||
<NavTabs view={view} onChange={setView} />
|
||||
|
||||
{/* ── Loading ── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-surface-400 text-sm py-12 justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading contract state...
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 animate-fade-in">
|
||||
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -231,7 +217,7 @@ export default function AdminPaymentPage() {
|
||||
tokens={tokens}
|
||||
revenues={revenues}
|
||||
contractBalance={contractBalance}
|
||||
contractAddress={paymentService['contractAddress']}
|
||||
contractAddress={(paymentService as any)['contractAddress']}
|
||||
owner={owner}
|
||||
isOwner={isOwner}
|
||||
address={address}
|
||||
@@ -239,7 +225,6 @@ export default function AdminPaymentPage() {
|
||||
onNavigate={(v: string) => setView(v as ViewMode)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'pricing' && (
|
||||
<PricingView
|
||||
basePrice={basePrice}
|
||||
@@ -256,7 +241,6 @@ export default function AdminPaymentPage() {
|
||||
address={address}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'tokens' && (
|
||||
<TokensView
|
||||
tokens={tokens}
|
||||
@@ -277,7 +261,6 @@ export default function AdminPaymentPage() {
|
||||
address={address}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'tiers' && (
|
||||
<TiersView
|
||||
tiers={tiers}
|
||||
@@ -293,19 +276,10 @@ export default function AdminPaymentPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── TX Status ── */}
|
||||
{txStatus && (
|
||||
<div className={`fixed bottom-6 right-6 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm animate-fade-in ${
|
||||
txStatus.ok
|
||||
? 'bg-accent-green/10 border border-accent-green/20 text-accent-green'
|
||||
: 'bg-accent-rose/10 border border-accent-rose/20 text-accent-rose'
|
||||
}`}>
|
||||
{txStatus.ok ? <CheckCircle className="w-4 h-4 shrink-0" /> : <XCircle className="w-4 h-4 shrink-0" />}
|
||||
{txStatus.msg}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<TxStatusToast status={txStatus} />
|
||||
</PaymentProvider>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
@@ -3,85 +3,9 @@ import { describe, it, expect } from 'vitest';
|
||||
/* ── Route matching & Kubo mapping tests ──
|
||||
*
|
||||
* These test the pure functions from route.ts in isolation.
|
||||
* We import the source directly since these functions have no
|
||||
* external dependencies (no fetch, no cookies, no process.env).
|
||||
* They're now exported so we import directly instead of duplicating logic.
|
||||
*/
|
||||
|
||||
// Inline import — vitest resolves @/ alias via vitest.config.ts
|
||||
// We need to import the actual functions. Since they're not exported
|
||||
// (they're module-private), we duplicate the logic here for testing.
|
||||
// In a real project, consider exporting them for testability.
|
||||
|
||||
function matchRoute(path: string[], method: string): { target: string; path: string; method: string } | null {
|
||||
if (!path || path.length === 0) return null;
|
||||
const [segment, ...rest] = path;
|
||||
|
||||
switch (segment) {
|
||||
case 'health':
|
||||
return { target: 'health', path: '/health', method };
|
||||
case 'auth':
|
||||
return { target: 'auth', path: '/' + (rest.length > 0 ? rest.join('/') : ''), method };
|
||||
case 'users':
|
||||
return { target: 'users', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method };
|
||||
case 'explorer':
|
||||
case 'ipns':
|
||||
return { target: 'ipfs', path: '/' + path.join('/'), method };
|
||||
default:
|
||||
return { target: 'ipfs', path: '/' + path.join('/'), method };
|
||||
}
|
||||
}
|
||||
|
||||
function mapToKuboAPI(path: string, method: string): string {
|
||||
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
switch (p) {
|
||||
case 'node/info':
|
||||
return '/api/v0/id';
|
||||
case 'peers':
|
||||
return '/api/v0/swarm/peers';
|
||||
case 'pins':
|
||||
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
|
||||
default:
|
||||
if (p.startsWith('pins/')) {
|
||||
const cid = p.slice(5);
|
||||
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p === 'files/upload') {
|
||||
return '/api/v0/add?pin=true';
|
||||
}
|
||||
if (p.startsWith('files/')) {
|
||||
return '/api/v0/ls';
|
||||
}
|
||||
if (p.startsWith('explorer/ls/')) {
|
||||
const cid = p.slice('explorer/ls/'.length);
|
||||
return `/api/v0/ls?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/cat/')) {
|
||||
const cid = p.slice('explorer/cat/'.length);
|
||||
return `/api/v0/cat?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/stat/')) {
|
||||
const cid = p.slice('explorer/stat/'.length);
|
||||
return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/resolve/')) {
|
||||
const name = p.slice('explorer/resolve/'.length);
|
||||
return `/api/v0/resolve?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
if (p === 'ipns/publish') return '/api/v0/name/publish';
|
||||
if (p === 'ipns/keys') return '/api/v0/key/list';
|
||||
if (p.startsWith('ipns/resolve/')) {
|
||||
const name = p.slice('ipns/resolve/'.length);
|
||||
return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
if (p.startsWith('ipns/keys/gen/')) {
|
||||
const name = p.slice('ipns/keys/gen/'.length);
|
||||
return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
if (p === 'repo/stats') return '/api/v0/repo/stat';
|
||||
if (p === 'bw/stats') return '/api/v0/bw/stats';
|
||||
return '/api/v0/' + p;
|
||||
}
|
||||
}
|
||||
import { matchRoute, mapToKuboAPI } from '../route';
|
||||
|
||||
/* ════════════════════════ matchRoute ════════════════════════ */
|
||||
|
||||
@@ -96,37 +20,24 @@ describe('matchRoute', () => {
|
||||
expect(r?.path).toBe('/health');
|
||||
});
|
||||
|
||||
it('routes auth', () => {
|
||||
const r = matchRoute(['auth'], 'GET');
|
||||
expect(r?.target).toBe('auth');
|
||||
expect(r?.path).toBe('/');
|
||||
});
|
||||
|
||||
it('routes auth/me', () => {
|
||||
const r = matchRoute(['auth', 'me'], 'GET');
|
||||
expect(r?.target).toBe('auth');
|
||||
expect(r?.path).toBe('/me');
|
||||
});
|
||||
|
||||
it('routes auth/login with POST', () => {
|
||||
const r = matchRoute(['auth', 'login'], 'POST');
|
||||
expect(r?.target).toBe('auth');
|
||||
expect(r?.path).toBe('/login');
|
||||
expect(r?.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('routes users', () => {
|
||||
const r = matchRoute(['users'], 'GET');
|
||||
expect(r?.target).toBe('users');
|
||||
expect(r?.target).toBe('user');
|
||||
expect(r?.path).toBe('/users');
|
||||
});
|
||||
|
||||
it('routes users with subpath', () => {
|
||||
const r = matchRoute(['users', 'create'], 'POST');
|
||||
expect(r?.target).toBe('users');
|
||||
expect(r?.target).toBe('user');
|
||||
expect(r?.path).toBe('/users/create');
|
||||
});
|
||||
|
||||
it('routes node/info to user API', () => {
|
||||
const r = matchRoute(['node', 'info'], 'GET');
|
||||
expect(r?.target).toBe('user');
|
||||
expect(r?.path).toBe('/node/info');
|
||||
});
|
||||
|
||||
it('routes explorer to ipfs', () => {
|
||||
const r = matchRoute(['explorer', 'ls', 'QmTest'], 'GET');
|
||||
expect(r?.target).toBe('ipfs');
|
||||
@@ -212,7 +123,7 @@ describe('mapToKuboAPI', () => {
|
||||
});
|
||||
|
||||
it('maps bw/stats', () => {
|
||||
expect(mapToKuboAPI('bw/stats', 'GET')).toBe('/api/v0/bw/stats');
|
||||
expect(mapToKuboAPI('bw/stats', 'GET')).toBe('/api/v0/bw');
|
||||
});
|
||||
|
||||
it('falls through for unknown paths', () => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userApiBase, userApiAdminKey } from '@/lib/config';
|
||||
|
||||
export async function handleHealth(req: NextRequest): Promise<NextResponse> {
|
||||
// Check User API reachability
|
||||
let userApiOk = false;
|
||||
let userApiMsg = 'unknown';
|
||||
try {
|
||||
const r = await fetch(`${userApiBase()}/users`, {
|
||||
headers: { 'X-Admin-Key': userApiAdminKey() },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (r.ok) {
|
||||
userApiOk = true;
|
||||
userApiMsg = 'connected';
|
||||
} else {
|
||||
userApiMsg = `status ${r.status}`;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
userApiMsg = e instanceof Error ? e.message.substring(0, 60) : 'error';
|
||||
}
|
||||
|
||||
// Check Kubo API reachability
|
||||
let kuboApiOk = false;
|
||||
let kuboApiMsg = 'not configured';
|
||||
const kuboSvc = process.env.KUBO_API_URL;
|
||||
const kuboAuth = process.env.KUBO_BASIC_AUTH;
|
||||
if (kuboSvc) {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (kuboAuth) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(kuboAuth).toString('base64');
|
||||
}
|
||||
const r = await fetch(kuboSvc.replace(/\/+$/, '') + '/api/v0/version', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
kuboApiOk = true;
|
||||
kuboApiMsg = `v${data.Version || 'unknown'}`;
|
||||
} else {
|
||||
kuboApiMsg = `status ${r.status}`;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
kuboApiMsg = e instanceof Error ? e.message.substring(0, 60) : 'error';
|
||||
}
|
||||
}
|
||||
|
||||
const overall = userApiOk || kuboApiOk ? 'ok' : 'degraded';
|
||||
|
||||
return NextResponse.json({
|
||||
status: overall,
|
||||
userApi: userApiMsg,
|
||||
kuboApi: kuboApiMsg,
|
||||
mode: process.env.KUBO_API_URL ? 'proxy' : 'minimal',
|
||||
});
|
||||
}
|
||||
|
||||
export async function proxyResult(res: Response): Promise<NextResponse> {
|
||||
const text = await res.text();
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
|
||||
|
||||
export async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> {
|
||||
// In dev, the Kubo API is behind nginx with Basic Auth.
|
||||
// If no KUBO_API_URL is configured, return a clear error.
|
||||
const kuboSvc = kuboApiUrl();
|
||||
if (!kuboSvc) {
|
||||
return NextResponse.json(
|
||||
{ error: 'IPFS proxy not configured', detail: 'Set KUBO_API_URL in env for dev, or use nginx routing in production' },
|
||||
{ status: 501 }
|
||||
);
|
||||
}
|
||||
|
||||
// Map /api/explorer/ls/<cid> → /api/v0/ls?arg=<cid>, etc.
|
||||
const url = new URL(kuboSvc);
|
||||
const kuboPath = mapToKuboAPI(path, method);
|
||||
// Split pathname and query string — url.pathname encodes `?` as `%3F`
|
||||
const [namePart, ...qsParts] = kuboPath.split('?');
|
||||
url.pathname = namePart;
|
||||
if (qsParts.length > 0) {
|
||||
url.search = qsParts.join('?');
|
||||
} else {
|
||||
// Forward incoming query params (used by ipns/publish etc.)
|
||||
const incomingSearch = req.nextUrl.search;
|
||||
if (incomingSearch) {
|
||||
url.search = incomingSearch;
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const auth = kuboBasicAuth();
|
||||
if (auth) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
|
||||
}
|
||||
|
||||
// Forward Content-Type (preserves multipart boundary for file uploads)
|
||||
const reqCt = req.headers.get('content-type');
|
||||
if (reqCt) {
|
||||
headers['Content-Type'] = reqCt;
|
||||
}
|
||||
|
||||
// Kubo uses POST for everything
|
||||
const body = method === 'POST' || method === 'DELETE' ? req.body : null;
|
||||
const fetchOpts: RequestInit & { duplex?: string } = {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
};
|
||||
// Node.js fetch requires duplex: 'half' when streaming a body
|
||||
if (body) {
|
||||
fetchOpts.duplex = 'half';
|
||||
fetchOpts.body = body;
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url.toString(), fetchOpts);
|
||||
} catch (fetchErr: unknown) {
|
||||
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
||||
console.error('[proxyIPFS] fetch error:', msg);
|
||||
return NextResponse.json(
|
||||
{ error: 'IPFS proxy fetch failed', detail: msg.substring(0, 200) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('Content-Type') || 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function mapToKuboAPI(path: string, method: string): string {
|
||||
// Normalize: /api/node/info → /api/v0/id, etc.
|
||||
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
switch (p) {
|
||||
case 'node/info':
|
||||
return '/api/v0/id';
|
||||
case 'peers':
|
||||
return '/api/v0/swarm/peers';
|
||||
case 'pins':
|
||||
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
|
||||
default:
|
||||
if (p.startsWith('pins/')) {
|
||||
const cid = p.slice(5);
|
||||
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p === 'files/upload') {
|
||||
return '/api/v0/add?pin=true';
|
||||
}
|
||||
if (p.startsWith('files/')) {
|
||||
return '/api/v0/ls';
|
||||
}
|
||||
// Explorer routes: /explorer/ls/<cid>, /explorer/cat/<cid>, etc.
|
||||
if (p.startsWith('explorer/ls/')) {
|
||||
const cid = p.slice('explorer/ls/'.length);
|
||||
return `/api/v0/ls?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/cat/')) {
|
||||
const cid = p.slice('explorer/cat/'.length);
|
||||
return `/api/v0/cat?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/stat/')) {
|
||||
const cid = p.slice('explorer/stat/'.length);
|
||||
return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/resolve/')) {
|
||||
const name = p.slice('explorer/resolve/'.length);
|
||||
return `/api/v0/resolve?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
// IPNS routes
|
||||
if (p === 'ipns/publish') return '/api/v0/name/publish';
|
||||
if (p === 'ipns/keys') return '/api/v0/key/list';
|
||||
if (p.startsWith('ipns/resolve/')) {
|
||||
const name = p.slice('ipns/resolve/'.length);
|
||||
return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
if (p.startsWith('ipns/keys/gen/')) {
|
||||
const name = p.slice('ipns/keys/gen/'.length);
|
||||
return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
// Dashboard routes
|
||||
if (p === 'repo/stats') return '/api/v0/repo/stat';
|
||||
if (p === 'bw/stats') return '/api/v0/bw';
|
||||
return '/api/v0/' + p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
|
||||
import { userApiBase, userApiAdminKey } from '@/lib/config';
|
||||
|
||||
async function getSessionToken(req: NextRequest): Promise<string | null> {
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
const session = await verifySessionJWT(token);
|
||||
return session?.sessionToken ?? null;
|
||||
}
|
||||
|
||||
export async function proxyUserAPI(path: string, method: string, body: ReadableStream<Uint8Array> | null, req: NextRequest): Promise<Response> {
|
||||
const url = `${userApiBase()}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'X-Admin-Key': userApiAdminKey(),
|
||||
};
|
||||
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Forward session token from JWT cookie to Python backend
|
||||
const sessionToken = await getSessionToken(req);
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
}
|
||||
@@ -11,24 +11,18 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
|
||||
import type { RouteMatch } from './types';
|
||||
import { proxyUserAPI } from './proxy-user';
|
||||
import { proxyIPFS } from './proxy-ipfs';
|
||||
import { handleHealth, proxyResult } from './handlers';
|
||||
|
||||
export { mapToKuboAPI } from './proxy-ipfs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/* ── Backend targets ── */
|
||||
|
||||
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
|
||||
const ADMIN_KEY = process.env.USER_API_ADMIN_KEY || 'maos-admin-2024';
|
||||
|
||||
/* ── Route matching ── */
|
||||
|
||||
interface RouteMatch {
|
||||
target: 'user' | 'ipfs' | 'health';
|
||||
path: string; // path relative to the backend
|
||||
method: string;
|
||||
}
|
||||
|
||||
function matchRoute(path: string[], method: string): RouteMatch | null {
|
||||
export function matchRoute(path: string[], method: string): RouteMatch | null {
|
||||
if (!path || path.length === 0) return null;
|
||||
|
||||
const [segment, ...rest] = path;
|
||||
@@ -58,175 +52,6 @@ function matchRoute(path: string[], method: string): RouteMatch | null {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Extract session token from JWT cookie ── */
|
||||
|
||||
async function getSessionToken(req: NextRequest): Promise<string | null> {
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
const session = await verifySessionJWT(token);
|
||||
return session?.sessionToken ?? null;
|
||||
}
|
||||
|
||||
/* ── User API proxy ── */
|
||||
|
||||
async function proxyUserAPI(path: string, method: string, body: ReadableStream<Uint8Array> | null, req: NextRequest): Promise<Response> {
|
||||
const url = `${USER_API_BASE}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'X-Admin-Key': ADMIN_KEY,
|
||||
};
|
||||
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Forward session token from JWT cookie to Python backend
|
||||
const sessionToken = await getSessionToken(req);
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── IPFS Kubo proxy (via nginx) ── */
|
||||
|
||||
async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> {
|
||||
// In dev, the Kubo API is behind nginx with Basic Auth.
|
||||
// If no KUBO_API_URL is configured, return a clear error.
|
||||
const kuboSvc = process.env.KUBO_API_URL || process.env.NEXT_PUBLIC_KUBO_API_URL;
|
||||
if (!kuboSvc) {
|
||||
return NextResponse.json(
|
||||
{ error: 'IPFS proxy not configured', detail: 'Set KUBO_API_URL in env for dev, or use nginx routing in production' },
|
||||
{ status: 501 }
|
||||
);
|
||||
}
|
||||
|
||||
// Map /api/explorer/ls/<cid> → /api/v0/ls?arg=<cid>, etc.
|
||||
const url = new URL(kuboSvc);
|
||||
const kuboPath = mapToKuboAPI(path, method);
|
||||
// Split pathname and query string — url.pathname encodes `?` as `%3F`
|
||||
const [namePart, ...qsParts] = kuboPath.split('?');
|
||||
url.pathname = namePart;
|
||||
if (qsParts.length > 0) {
|
||||
url.search = qsParts.join('?');
|
||||
} else {
|
||||
// Forward incoming query params (used by ipns/publish etc.)
|
||||
const incomingSearch = req.nextUrl.search;
|
||||
if (incomingSearch) {
|
||||
url.search = incomingSearch;
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const auth = process.env.KUBO_BASIC_AUTH;
|
||||
if (auth) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
|
||||
}
|
||||
|
||||
// Forward Content-Type (preserves multipart boundary for file uploads)
|
||||
const reqCt = req.headers.get('content-type');
|
||||
if (reqCt) {
|
||||
headers['Content-Type'] = reqCt;
|
||||
}
|
||||
|
||||
// Kubo uses POST for everything
|
||||
const fetchOpts: RequestInit & { duplex?: string } = {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
};
|
||||
// Node.js fetch requires duplex: 'half' when streaming a body
|
||||
if (method === 'POST' && req.body) {
|
||||
fetchOpts.duplex = 'half';
|
||||
}
|
||||
|
||||
// Forward body for add/pin operations
|
||||
if (method === 'POST' && req.body) {
|
||||
fetchOpts.body = req.body;
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url.toString(), fetchOpts);
|
||||
} catch (fetchErr: any) {
|
||||
console.error('[proxyIPFS] fetch error:', fetchErr.message);
|
||||
return NextResponse.json(
|
||||
{ error: 'IPFS proxy fetch failed', detail: fetchErr.message?.substring(0, 200) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('Content-Type') || 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mapToKuboAPI(path: string, method: string): string {
|
||||
// Normalize: /api/node/info → /api/v0/id, etc.
|
||||
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
switch (p) {
|
||||
case 'node/info':
|
||||
return '/api/v0/id';
|
||||
case 'peers':
|
||||
return '/api/v0/swarm/peers';
|
||||
case 'pins':
|
||||
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
|
||||
default:
|
||||
if (p.startsWith('pins/')) {
|
||||
const cid = p.slice(5);
|
||||
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p === 'files/upload') {
|
||||
return '/api/v0/add?pin=true';
|
||||
}
|
||||
if (p.startsWith('files/')) {
|
||||
return '/api/v0/ls';
|
||||
}
|
||||
// Explorer routes: /explorer/ls/<cid>, /explorer/cat/<cid>, etc.
|
||||
if (p.startsWith('explorer/ls/')) {
|
||||
const cid = p.slice('explorer/ls/'.length);
|
||||
return `/api/v0/ls?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/cat/')) {
|
||||
const cid = p.slice('explorer/cat/'.length);
|
||||
return `/api/v0/cat?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/stat/')) {
|
||||
const cid = p.slice('explorer/stat/'.length);
|
||||
return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`;
|
||||
}
|
||||
if (p.startsWith('explorer/resolve/')) {
|
||||
const name = p.slice('explorer/resolve/'.length);
|
||||
return `/api/v0/resolve?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
// IPNS routes
|
||||
if (p === 'ipns/publish') return '/api/v0/name/publish';
|
||||
if (p === 'ipns/keys') return '/api/v0/key/list';
|
||||
if (p.startsWith('ipns/resolve/')) {
|
||||
const name = p.slice('ipns/resolve/'.length);
|
||||
return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
if (p.startsWith('ipns/keys/gen/')) {
|
||||
const name = p.slice('ipns/keys/gen/'.length);
|
||||
return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`;
|
||||
}
|
||||
// Dashboard routes
|
||||
if (p === 'repo/stats') return '/api/v0/repo/stat';
|
||||
if (p === 'bw/stats') return '/api/v0/bw/stats';
|
||||
return '/api/v0/' + p;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Main handler ── */
|
||||
|
||||
export async function GET(
|
||||
@@ -282,74 +107,3 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Health check ── */
|
||||
|
||||
async function handleHealth(req: NextRequest): Promise<NextResponse> {
|
||||
// Check User API reachability
|
||||
let userApiOk = false;
|
||||
let userApiMsg = 'unknown';
|
||||
try {
|
||||
const r = await fetch(`${USER_API_BASE}/users`, {
|
||||
headers: { 'X-Admin-Key': ADMIN_KEY },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (r.ok) {
|
||||
userApiOk = true;
|
||||
userApiMsg = 'connected';
|
||||
} else {
|
||||
userApiMsg = `status ${r.status}`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
userApiMsg = e.message?.substring(0, 60) || 'error';
|
||||
}
|
||||
|
||||
// Check Kubo API reachability
|
||||
let kuboApiOk = false;
|
||||
let kuboApiMsg = 'not configured';
|
||||
const kuboSvc = process.env.KUBO_API_URL;
|
||||
const kuboAuth = process.env.KUBO_BASIC_AUTH;
|
||||
if (kuboSvc) {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (kuboAuth) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(kuboAuth).toString('base64');
|
||||
}
|
||||
const r = await fetch(kuboSvc.replace(/\/+$/, '') + '/api/v0/version', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
kuboApiOk = true;
|
||||
kuboApiMsg = `v${data.Version || 'unknown'}`;
|
||||
} else {
|
||||
kuboApiMsg = `status ${r.status}`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
kuboApiMsg = e.message?.substring(0, 60) || 'error';
|
||||
}
|
||||
}
|
||||
|
||||
const overall = userApiOk || kuboApiOk ? 'ok' : 'degraded';
|
||||
|
||||
return NextResponse.json({
|
||||
status: overall,
|
||||
userApi: userApiMsg,
|
||||
kuboApi: kuboApiMsg,
|
||||
mode: process.env.KUBO_API_URL ? 'proxy' : 'minimal',
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
async function proxyResult(res: Response): Promise<NextResponse> {
|
||||
const text = await res.text();
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface RouteMatch {
|
||||
target: 'user' | 'ipfs' | 'health';
|
||||
path: string; // path relative to the backend
|
||||
method: string;
|
||||
}
|
||||
@@ -5,11 +5,10 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
@@ -19,7 +18,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Address required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${USER_API_BASE}/auth/challenge`, {
|
||||
const res = await fetch(`${userApiBase()}/auth/challenge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address: address.toLowerCase() }),
|
||||
@@ -28,9 +27,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json(
|
||||
{ error: e.message || 'Challenge failed' },
|
||||
{ error: msg || 'Challenge failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createSessionJWT, cookieOptions } from '@/lib/auth-server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
@@ -24,7 +23,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Proxy login to Python User API
|
||||
const res = await fetch(`${USER_API_BASE}/auth/login`, {
|
||||
const res = await fetch(`${userApiBase()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -48,7 +47,7 @@ export async function POST(req: NextRequest) {
|
||||
// Determine role — Python backend returns role info via /auth/verify
|
||||
let role: 'admin' | 'user' = 'user';
|
||||
try {
|
||||
const verifyRes = await fetch(`${USER_API_BASE}/auth/verify`, {
|
||||
const verifyRes = await fetch(`${userApiBase()}/auth/verify`, {
|
||||
headers: { 'X-Session-Token': pythonSessionToken },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
@@ -76,9 +75,10 @@ export async function POST(req: NextRequest) {
|
||||
response.cookies.set('ipfs-portal-session', jwt, cookieOptions());
|
||||
|
||||
return response;
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json(
|
||||
{ error: e.message || 'Login failed' },
|
||||
{ error: msg || 'Login failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/* ── POST /api/auth/logout ──
|
||||
*
|
||||
* Logt uit bij Python backend en wist JWT cookie.
|
||||
* Best-effort: als Python backend niet bereikbaar is, cookie wordt altijd gewist.
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
const jwt = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
@@ -27,16 +27,17 @@ export async function POST() {
|
||||
// Notify Python backend (best effort)
|
||||
if (sessionToken) {
|
||||
try {
|
||||
await fetch(`${USER_API_BASE}/auth/logout`, {
|
||||
await fetch(`${userApiBase()}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Session-Token': sessionToken },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
} catch {
|
||||
// Non-critical
|
||||
} catch (e) {
|
||||
console.warn('[logout] Python backend unreachable:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe cookie regardless
|
||||
const response = NextResponse.json({ authenticated: false });
|
||||
response.cookies.set(SESSION_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
|
||||
|
||||
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
|
||||
const ADMIN_KEY = process.env.USER_API_ADMIN_KEY || 'maos-admin-2024';
|
||||
import { userApiBase, userApiAdminKey } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -32,9 +30,9 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Current and new password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${USER_API_BASE}/auth/password`, {
|
||||
const res = await fetch(`${userApiBase()}/auth/password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Admin-Key': ADMIN_KEY },
|
||||
headers: { 'Content-Type': 'application/json', 'X-Admin-Key': userApiAdminKey() },
|
||||
body: JSON.stringify({
|
||||
address: session.address,
|
||||
currentPassword,
|
||||
@@ -52,7 +50,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message || 'Password change failed' }, { status: 500 });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json({ error: msg || 'Password change failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
* Alternatief voor WebSocket (werkt door proxy heen).
|
||||
*/
|
||||
|
||||
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const KUBO_API = process.env.KUBO_API_URL || process.env.NEXT_PUBLIC_KUBO_API_URL;
|
||||
const KUBO_AUTH = process.env.KUBO_BASIC_AUTH;
|
||||
const POLL_INTERVAL = 3_000; // 3 seconden
|
||||
|
||||
/* ── Helpers ── */
|
||||
@@ -23,13 +23,15 @@ function encodeSSE(event: string, data: unknown): string {
|
||||
}
|
||||
|
||||
async function kuboFetch(path: string): Promise<any> {
|
||||
if (!KUBO_API) return null;
|
||||
const api = kuboApiUrl();
|
||||
if (!api) return null;
|
||||
try {
|
||||
const url = new URL(KUBO_API);
|
||||
const url = new URL(api);
|
||||
url.pathname = path;
|
||||
const headers: Record<string, string> = {};
|
||||
if (KUBO_AUTH) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(KUBO_AUTH).toString('base64');
|
||||
const auth = kuboBasicAuth();
|
||||
if (auth) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
|
||||
}
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
|
||||
@@ -14,18 +14,12 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createPublicClient, http, type Address, type Hash, type Chain } from 'viem';
|
||||
import { IPFS_PORTAL_PAYMENT_ABI } from '@/lib/payment';
|
||||
import { createPublicClient, http, type Address, type Hash } from 'viem';
|
||||
import { IPFS_PORTAL_PAYMENT_ABI, zkSyncLocal } from '@/lib/payment';
|
||||
import { zkSyncRpcUrl, paymentContractAddress } from '@/lib/config';
|
||||
|
||||
const RPC_URL = process.env.ZKSYNC_RPC_URL || 'http://192.168.1.176:3050';
|
||||
const CONTRACT_ADDRESS = (process.env.PAYMENT_CONTRACT_ADDRESS || '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe') as Address;
|
||||
|
||||
const zkSyncLocal: Chain = {
|
||||
id: 270,
|
||||
name: 'zkSync Local',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: { default: { http: [RPC_URL] } },
|
||||
};
|
||||
const RPC_URL = zkSyncRpcUrl();
|
||||
const CONTRACT_ADDRESS = paymentContractAddress() as Address;
|
||||
|
||||
const client = createPublicClient({
|
||||
chain: zkSyncLocal,
|
||||
@@ -117,11 +111,12 @@ export async function GET(req: NextRequest) {
|
||||
})),
|
||||
verified: matchingUpload !== null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[payment/verify] Error:', err.message);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[payment/verify] Error:', msg);
|
||||
return NextResponse.json({
|
||||
error: 'Verification failed',
|
||||
detail: err.message?.substring(0, 200),
|
||||
detail: msg.substring(0, 200),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -159,12 +154,13 @@ export async function POST(req: NextRequest) {
|
||||
gasUsed: receipt.gasUsed?.toString(),
|
||||
effectiveGasPrice: receipt.effectiveGasPrice?.toString(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[payment/verify/tx] Error:', err.message);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[payment/verify/tx] Error:', msg);
|
||||
return NextResponse.json({
|
||||
confirmed: false,
|
||||
error: 'Verification failed',
|
||||
detail: err.message?.substring(0, 200),
|
||||
detail: msg.substring(0, 200),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/* ── Dashboard Page Helpers ── */
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface StatCard {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
export function formatRepoSize(bytes: number): string {
|
||||
return (bytes / 1e9).toFixed(2);
|
||||
}
|
||||
|
||||
export function formatBandwidthMb(bytes: number): string {
|
||||
return (bytes / 1e6).toFixed(1);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Fingerprint,
|
||||
} from 'lucide-react';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { formatRepoSize, formatBandwidthMb } from './helpers';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isValidating } = useDashboard();
|
||||
const realtime = useRealtime();
|
||||
@@ -39,9 +42,9 @@ export default function DashboardPage() {
|
||||
|
||||
const loading = isValidating && !data;
|
||||
|
||||
const repoSizeGb = repo ? (repo.repoSize / 1e9).toFixed(2) : '—';
|
||||
const bwIn = bw ? (bw.totalIn / 1e6).toFixed(1) : '—';
|
||||
const bwOut = bw ? (bw.totalOut / 1e6).toFixed(1) : '—';
|
||||
const repoSizeGb = repo ? formatRepoSize(repo.repoSize) : '—';
|
||||
const bwIn = bw ? formatBandwidthMb(bw.totalIn) : '—';
|
||||
const bwOut = bw ? formatBandwidthMb(bw.totalOut) : '—';
|
||||
|
||||
// Peer count from SSE (live) or SWR
|
||||
const peerCount = realtime.peers?.count ?? peers.length;
|
||||
@@ -57,6 +60,7 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Dashboard">
|
||||
{/* Page header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
@@ -258,6 +262,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { IPFSEntry } from '@/lib/api';
|
||||
import { explorerLs } from '@/lib/api';
|
||||
import { explorerLs, type IPFSEntry } from '@/lib/api/explorer';
|
||||
import FileIcon from './FileIcon';
|
||||
import { ChevronRight, CheckCircle, Download, Loader2 } from 'lucide-react';
|
||||
import { truncateCid } from '@/lib/helpers';
|
||||
@@ -221,7 +220,8 @@ export default function DirectoryListing({
|
||||
try {
|
||||
const result = await explorerLs(cid);
|
||||
setSubdirEntries((e) => ({ ...e, [cid]: result }));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[DirectoryListing] Failed to list subdirectory:', err);
|
||||
setSubdirEntries((e) => ({ ...e, [cid]: [] }));
|
||||
} finally {
|
||||
setLoadingSubdirs((l) => ({ ...l, [cid]: false }));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Download, Maximize2 } from 'lucide-react';
|
||||
import { getSettings } from '@/lib/storage';
|
||||
import { gatewayUrl } from '@/lib/helpers';
|
||||
|
||||
interface FilePreviewProps {
|
||||
cid: string;
|
||||
@@ -12,11 +13,6 @@ interface FilePreviewProps {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
function gatewayUrl(cid: string): string {
|
||||
const { gatewayUrl: base } = getSettings();
|
||||
return `${base}/${cid}`;
|
||||
}
|
||||
|
||||
function detectFileType(
|
||||
filename: string | undefined,
|
||||
): 'image' | 'markdown' | 'json' | 'text' | 'video' | 'audio' | 'pdf' | 'html' | 'other' {
|
||||
@@ -60,7 +56,8 @@ function renderJson(text: string): string {
|
||||
const parsed = JSON.parse(text);
|
||||
const syntax = JSON.stringify(parsed, null, 2);
|
||||
return syntax;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[FilePreview] JSON parse failed in renderJson:', err);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -69,13 +66,18 @@ function JsonDisplay({ text }: { text: string }) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[FilePreview] JSON parse failed in JsonDisplay:', err);
|
||||
return <pre className="text-sm font-mono whitespace-pre-wrap text-surface-300">{text}</pre>;
|
||||
}
|
||||
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
const escaped = formatted
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
const colored = formatted.replace(
|
||||
const colored = escaped.replace(
|
||||
/("(?:\\.|[^"\\])*")\s*:/g,
|
||||
'<span class="text-accent-cyan">$1</span>:',
|
||||
).replace(
|
||||
@@ -205,7 +207,7 @@ export default function FilePreview({ cid, content, loading, onClose, filename }
|
||||
if (content) {
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-same-origin"
|
||||
sandbox="allow-scripts"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
|
||||
srcDoc={content}
|
||||
title="HTML preview"
|
||||
@@ -214,7 +216,7 @@ export default function FilePreview({ cid, content, loading, onClose, filename }
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-same-origin"
|
||||
sandbox="allow-scripts"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
|
||||
src={gatewayUrl(cid)}
|
||||
title="HTML preview"
|
||||
|
||||
@@ -17,8 +17,8 @@ export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
|
||||
await navigator.clipboard.writeText(gatewayUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard write failed silently
|
||||
} catch (err) {
|
||||
console.error('[GatewayLink] Clipboard write failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Pin } from 'lucide-react';
|
||||
import { addPin } from '@/lib/api';
|
||||
import { addPin } from '@/lib/api/pins';
|
||||
|
||||
interface PinBadgeProps {
|
||||
cid: string;
|
||||
@@ -18,8 +18,8 @@ export default function PinBadge({ cid, isPinned, onPin }: PinBadgeProps) {
|
||||
try {
|
||||
await addPin(cid);
|
||||
onPin(cid);
|
||||
} catch {
|
||||
// pin failed silently
|
||||
} catch (err) {
|
||||
console.error('[PinBadge] Failed to pin CID:', err);
|
||||
} finally {
|
||||
setPinning(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* ── Explorer Page Helpers ── */
|
||||
|
||||
import type { IPFSEntry } from "@/lib/api/explorer";
|
||||
|
||||
export const RECENT_STORAGE_KEY = "ipfs-explorer-recent-cids";
|
||||
|
||||
export interface BreadcrumbSegment {
|
||||
cid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ExplorerState = "idle" | "loading" | "resolving" | "loaded" | "error";
|
||||
|
||||
export function loadRecentCids(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||
} catch (err) {
|
||||
console.error("[ExplorerPage] Failed to load recent CIDs from localStorage:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRecentCid(cid: string) {
|
||||
try {
|
||||
const existing = loadRecentCids();
|
||||
const updated = [cid, ...existing.filter((c) => c !== cid)].slice(0, 10);
|
||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch (err) {
|
||||
console.error("[ExplorerPage] Failed to save recent CID to localStorage:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
|
||||
return entries.find((e) => e.hash === hash)?.name;
|
||||
}
|
||||
+28
-39
@@ -2,47 +2,23 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { explorerLs, explorerCat, listPins } from '@/lib/api';
|
||||
import type { IPFSEntry } from '@/lib/api';
|
||||
import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer';
|
||||
import { listPins } from '@/lib/api/pins';
|
||||
import CIDInput from './components/CIDInput';
|
||||
import DirectoryListing from './components/DirectoryListing';
|
||||
import FilePreview from './components/FilePreview';
|
||||
import Breadcrumbs from './components/Breadcrumbs';
|
||||
import PinBadge from './components/PinBadge';
|
||||
import GatewayLink from './components/GatewayLink';
|
||||
import SearchBar from '@/components/SearchBar';
|
||||
import SearchBar from '@/components/search';
|
||||
import type { SearchResult } from '@/lib/search-index';
|
||||
import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index';
|
||||
import { Search, AlertCircle, RefreshCw, Database, Loader2 } from 'lucide-react';
|
||||
import Skeleton from '@/components/Skeleton';
|
||||
|
||||
const RECENT_STORAGE_KEY = 'ipfs-explorer-recent-cids';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
interface BreadcrumbSegment {
|
||||
cid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function loadRecentCids(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecentCid(cid: string) {
|
||||
try {
|
||||
const existing = loadRecentCids();
|
||||
const updated = [cid, ...existing.filter((c) => c !== cid)].slice(0, 10);
|
||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// localStorage write failed silently
|
||||
}
|
||||
}
|
||||
|
||||
type ExplorerState = 'idle' | 'loading' | 'resolving' | 'loaded' | 'error';
|
||||
import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
|
||||
|
||||
export default function ExplorerPage() {
|
||||
const [cid, setCid] = useState('');
|
||||
@@ -111,7 +87,8 @@ export default function ExplorerPage() {
|
||||
try {
|
||||
const content = await explorerCat(hash);
|
||||
setPreviewContent(content);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[ExplorerPage] Failed to fetch preview content:', err);
|
||||
setPreviewContent(null);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
@@ -156,7 +133,8 @@ export default function ExplorerPage() {
|
||||
});
|
||||
indexed++;
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[ExplorerPage] Failed to index pin:', err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
@@ -171,6 +149,7 @@ export default function ExplorerPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Verkenner">
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">IPFS Explorer</h1>
|
||||
@@ -234,11 +213,22 @@ export default function ExplorerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolving IPNS */}
|
||||
{/* Resolving IPNS — skeleton */}
|
||||
{state === 'resolving' && (
|
||||
<div className="glass rounded-xl p-8 flex flex-col items-center justify-center text-center animate-fade-in">
|
||||
<RefreshCw className="w-5 h-5 text-accent-cyan animate-spin mb-3" />
|
||||
<p className="text-sm text-surface-400">Resolving IPNS name...</p>
|
||||
<div className="glass rounded-xl p-8 animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="glass rounded-xl p-5 space-y-3">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -300,10 +290,9 @@ export default function ExplorerPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
|
||||
return entries.find((e) => e.hash === hash)?.name;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,3 +138,18 @@ html.theme-transitioning *::after {
|
||||
.animate-fade-in { animation: fade-in 0.4s ease-out both; }
|
||||
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
||||
.animate-slide-up { animation: slide-up 0.3s ease-out both; }
|
||||
|
||||
/* ── Button variants (used in IPNS page) ── */
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
background: var(--color-brand-600);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--color-brand-500); }
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import type { UploadRecord } from "@/lib/storage";
|
||||
import { formatBytes, truncateCid, gatewayLink } from "@/lib/helpers";
|
||||
import { downloadFile } from "@/lib/download";
|
||||
import { SkeletonTable } from "@/components/Skeleton";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload,
|
||||
Database, Calendar, Filter, X, CheckCircle, Download,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react";
|
||||
import { getMethodBadge, recordKey } from "./helpers";
|
||||
import BatchBar from "@/components/BatchBar";
|
||||
|
||||
/* ── Header ── */
|
||||
interface HistoryHeaderProps {
|
||||
totalUploads: number;
|
||||
totalSize: number;
|
||||
uniqueCids: number;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function HistoryHeader({ totalUploads, totalSize, uniqueCids, onClear }: HistoryHeaderProps) {
|
||||
return (
|
||||
<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">
|
||||
{totalUploads > 0
|
||||
? `${totalUploads} upload${totalUploads !== 1 ? "s" : ""} · ${formatBytes(totalSize)} total · ${uniqueCids} unique file${uniqueCids !== 1 ? "s" : ""}`
|
||||
: "Track your past uploads"}
|
||||
</p>
|
||||
</div>
|
||||
{totalUploads > 0 && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
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 Bar ── */
|
||||
interface HistorySearchProps {
|
||||
search: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function HistorySearch({ search, onChange }: HistorySearchProps) {
|
||||
return (
|
||||
<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) => onChange(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={() => onChange("")}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Loading Skeleton ── */
|
||||
export function HistorySkeleton() {
|
||||
return (
|
||||
<div className="glass rounded-xl overflow-hidden">
|
||||
<div className="px-1">
|
||||
<SkeletonTable rows={8} cols={3} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Empty State ── */
|
||||
export function HistoryEmpty() {
|
||||
return (
|
||||
<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 ── */
|
||||
interface HistoryTableProps {
|
||||
records: UploadRecord[];
|
||||
selected: Set<string>;
|
||||
copied: string | null;
|
||||
onToggle: (key: string) => void;
|
||||
onToggleAll: () => void;
|
||||
allSelected: boolean;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
}
|
||||
|
||||
export function HistoryTable({ records, selected, copied, onToggle, onToggleAll, allSelected, onCopy }: HistoryTableProps) {
|
||||
return (
|
||||
<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">
|
||||
<th className="px-5 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={onToggleAll}
|
||||
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>
|
||||
<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">
|
||||
{records.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">
|
||||
<td className="px-5 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(recordKey(rec))}
|
||||
onChange={() => onToggle(recordKey(rec))}
|
||||
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
</td>
|
||||
<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>
|
||||
<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>
|
||||
<td className="px-5 py-3">
|
||||
<span className="text-xs text-surface-400">{formatBytes(rec.size)}</span>
|
||||
</td>
|
||||
<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>
|
||||
<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>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => onCopy(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>
|
||||
<button
|
||||
onClick={() => onCopy(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>
|
||||
<button
|
||||
onClick={() => downloadFile(rec.cid, rec.name).catch((err) => { console.error("[HistoryPage] Download failed:", err); })}
|
||||
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>
|
||||
<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 Card ── */
|
||||
interface HistoryMobileCardProps {
|
||||
rec: UploadRecord;
|
||||
selected: Set<string>;
|
||||
copied: string | null;
|
||||
onToggle: (key: string) => void;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
}
|
||||
|
||||
export function HistoryMobileCard({ rec, selected, copied, onToggle, onCopy }: HistoryMobileCardProps) {
|
||||
const badge = getMethodBadge(rec.method);
|
||||
const gwLink = gatewayLink(rec.cid);
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 space-y-3">
|
||||
<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={() => onToggle(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}
|
||||
</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>
|
||||
<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={() => onCopy(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>
|
||||
<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>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => onCopy(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>
|
||||
<button
|
||||
onClick={() => onCopy(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>
|
||||
<button
|
||||
onClick={() => downloadFile(rec.cid, rec.name).catch((err) => { console.error("[HistoryPage] Download failed:", err); })}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── No Results ── */
|
||||
interface HistoryNoResultsProps {
|
||||
search: string;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function HistoryNoResults({ search, onClear }: HistoryNoResultsProps) {
|
||||
return (
|
||||
<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={onClear}
|
||||
className="mt-4 text-sm text-brand-400 hover:text-brand-300 underline transition-colors"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Results Count ── */
|
||||
interface HistoryResultsCountProps {
|
||||
filtered: number;
|
||||
total: number;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function HistoryResultsCount({ filtered, total, onClear }: HistoryResultsCountProps) {
|
||||
return (
|
||||
<div className="mt-3 text-center text-xs text-surface-500">
|
||||
Showing {filtered} of {total} uploads
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="ml-2 text-brand-400 hover:text-brand-300 underline transition-colors"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* ── History Page Helpers ── */
|
||||
|
||||
import type { UploadRecord } from "@/lib/storage";
|
||||
|
||||
export function recordKey(r: UploadRecord) {
|
||||
return `${r.cid}||${r.date}`;
|
||||
}
|
||||
|
||||
export 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" };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRecordKey(key: string): { cid: string; date: string } {
|
||||
const idx = key.lastIndexOf("||");
|
||||
return { cid: key.slice(0, idx), date: key.slice(idx + 2) };
|
||||
}
|
||||
+50
-356
@@ -2,28 +2,22 @@
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
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 ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import Link from 'next/link';
|
||||
import BatchBar from '@/components/BatchBar';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { recordKey, parseRecordKey } from './helpers';
|
||||
import {
|
||||
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload,
|
||||
Database, Calendar, Filter, X, CheckCircle, Download,
|
||||
Link as LinkIcon,
|
||||
} 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' };
|
||||
}
|
||||
}
|
||||
HistoryHeader,
|
||||
HistorySearch,
|
||||
HistorySkeleton,
|
||||
HistoryEmpty,
|
||||
HistoryTable,
|
||||
HistoryMobileCard,
|
||||
HistoryNoResults,
|
||||
HistoryResultsCount,
|
||||
} from './components';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
@@ -34,8 +28,6 @@ export default function HistoryPage() {
|
||||
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);
|
||||
@@ -73,7 +65,7 @@ export default function HistoryPage() {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[HistoryPage] Clipboard write failed:', err); }
|
||||
}
|
||||
|
||||
/* ── Selection ── */
|
||||
@@ -100,12 +92,7 @@ export default function HistoryPage() {
|
||||
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) };
|
||||
});
|
||||
|
||||
const keys = Array.from(selected).map(parseRecordKey);
|
||||
removeMultipleFromHistory(keys);
|
||||
setHistory((prev) => prev.filter((r) => !selected.has(recordKey(r))));
|
||||
setSelected(new Set());
|
||||
@@ -117,353 +104,60 @@ export default function HistoryPage() {
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="HistoryPage">
|
||||
<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>
|
||||
<HistoryHeader
|
||||
totalUploads={stats.totalUploads}
|
||||
totalSize={stats.totalSize}
|
||||
uniqueCids={stats.uniqueCids}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
{/* ── 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>
|
||||
<HistorySearch search={search} onChange={setSearch} />
|
||||
)}
|
||||
|
||||
{/* ── Loading skeleton ── */}
|
||||
{loading && (
|
||||
<div className="glass rounded-xl overflow-hidden">
|
||||
<div className="px-1">
|
||||
<SkeletonTable rows={8} cols={3} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && <HistorySkeleton />}
|
||||
|
||||
{/* ── Empty state ── */}
|
||||
{!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">
|
||||
<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>
|
||||
)}
|
||||
{!loading && history.length === 0 && <HistoryEmpty />}
|
||||
|
||||
{/* ── 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">
|
||||
<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>
|
||||
<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">
|
||||
{/* 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">
|
||||
<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>
|
||||
<HistoryTable
|
||||
records={filtered}
|
||||
selected={selected}
|
||||
copied={copied}
|
||||
onToggle={toggleSelection}
|
||||
onToggleAll={toggleAll}
|
||||
allSelected={selected.size === filtered.length}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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}
|
||||
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">
|
||||
{/* 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}
|
||||
</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>
|
||||
|
||||
{/* 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"
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{filtered.map((rec) => (
|
||||
<HistoryMobileCard
|
||||
key={rec.cid + rec.date}
|
||||
rec={rec}
|
||||
selected={selected}
|
||||
copied={copied}
|
||||
onToggle={toggleSelection}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
<HistoryResultsCount
|
||||
filtered={filtered.length}
|
||||
total={history.length}
|
||||
onClear={() => setSearch('')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── No search results ── */}
|
||||
{!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" />
|
||||
</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>
|
||||
<HistoryNoResults search={search} onClear={() => setSearch('')} />
|
||||
)}
|
||||
{/* ── Batch bar ── */}
|
||||
|
||||
<BatchBar
|
||||
count={selected.size}
|
||||
onClear={() => setSelected(new Set())}
|
||||
@@ -472,7 +166,7 @@ export default function HistoryPage() {
|
||||
key: 'delete',
|
||||
label: 'Delete selected',
|
||||
icon: <Trash2 className="w-3.5 h-3.5" />,
|
||||
variant: 'danger',
|
||||
variant: 'danger' as const,
|
||||
onClick: handleBatchDelete,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/* ── IPNS Page Helpers ── */
|
||||
|
||||
export type Status = "idle" | "loading" | "success" | "error";
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api';
|
||||
import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api/ipns';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { RefreshCw, ExternalLink, Key, FileText, Plus } from 'lucide-react';
|
||||
import { SkeletonTable } from '@/components/Skeleton';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import type { Status } from './helpers';
|
||||
|
||||
export default function IPNSPage() {
|
||||
const [keys, setKeys] = useState<IPNSKey[]>([]);
|
||||
@@ -95,6 +97,7 @@ export default function IPNSPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="IPNS">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -123,9 +126,8 @@ export default function IPNSPage() {
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-surface-400 py-4">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
Loading keys...
|
||||
<div className="overflow-hidden">
|
||||
<SkeletonTable rows={4} cols={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -281,6 +283,7 @@ export default function IPNSPage() {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
import PortalSidebar from '@/components/PortalSidebar';
|
||||
import PortalHeader from '@/components/PortalHeader';
|
||||
|
||||
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
interface PortalLayoutProps {
|
||||
children: React.ReactNode;
|
||||
/** Optional custom header. Defaults to <PortalHeader /> if omitted. */
|
||||
header?: React.ReactNode;
|
||||
/** Optional custom sidebar. Defaults to <PortalSidebar /> if omitted. */
|
||||
sidebar?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PortalLayout({ children, header, sidebar }: PortalLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<PortalSidebar />
|
||||
{sidebar ?? <PortalSidebar />}
|
||||
<div className="flex-1 flex flex-col ml-56">
|
||||
<PortalHeader />
|
||||
{header ?? <PortalHeader />}
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,8 @@ export default function LoginPage() {
|
||||
await loginWithWallet(address, dw.provider);
|
||||
notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}…${address.slice(-4)}` });
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
notify({ type: 'error', title: 'Login mislukt', message: err.message || 'Onbekende fout' });
|
||||
} catch (err: unknown) {
|
||||
notify({ type: 'error', title: 'Login mislukt', message: err instanceof Error ? err.message : 'Onbekende fout' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setBusyWallet(null);
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { connectWallet, signMessage } from '@/lib/wallet';
|
||||
import { checkHealth } from '@/lib/api';
|
||||
import { checkHealth } from '@/lib/api/gateway';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react';
|
||||
|
||||
@@ -21,8 +21,8 @@ export default function LandingPage() {
|
||||
// Non-blocking health check — portal UI werkt ook zonder backend
|
||||
checkHealth().catch(() => {});
|
||||
router.push('/dashboard');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Connection failed');
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Connection failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getPeers } from '@/lib/api';
|
||||
import { getPeers } from '@/lib/api/gateway';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { SkeletonTable } from '@/components/Skeleton';
|
||||
import { Wifi, Globe, Clock } from 'lucide-react';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function PeersPage() {
|
||||
const [peers, setPeers] = useState<{ id: string; addr: string; latency: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -15,7 +17,7 @@ export default function PeersPage() {
|
||||
try {
|
||||
const items = await getPeers();
|
||||
setPeers(items);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PeersPage] Failed to load peers:', err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
@@ -25,6 +27,7 @@ export default function PeersPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Peers">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Peers</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">{peers.length} connected peers</p>
|
||||
@@ -59,6 +62,7 @@ export default function PeersPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-6
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listPins, addPin, removePin } from '@/lib/api';
|
||||
import { listPins, addPin, removePin } from '@/lib/api/pins';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import Link from 'next/link';
|
||||
import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink, Download } from 'lucide-react';
|
||||
import Skeleton, { SkeletonRow, SkeletonTable } from '@/components/Skeleton';
|
||||
import BatchBar, { type BatchAction } from '@/components/BatchBar';
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function PinsPage() {
|
||||
const [pins, setPins] = useState<{ cid: string; name: string; size: number; created: string }[]>([]);
|
||||
@@ -25,7 +26,7 @@ export default function PinsPage() {
|
||||
try {
|
||||
const items = await listPins();
|
||||
setPins(items);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PinsPage] Failed to load pins:', err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
@@ -39,14 +40,14 @@ export default function PinsPage() {
|
||||
setNewName('');
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PinsPage] Failed to add pin:', err); }
|
||||
}
|
||||
|
||||
async function handleRemove(cid: string) {
|
||||
try {
|
||||
await removePin(cid);
|
||||
setPins((p) => p.filter((x) => x.cid !== cid));
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PinsPage] Failed to remove pin:', err); }
|
||||
}
|
||||
|
||||
async function copyCid(cid: string) {
|
||||
@@ -54,7 +55,7 @@ export default function PinsPage() {
|
||||
await navigator.clipboard.writeText(cid);
|
||||
setCopied(cid);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PinsPage] Clipboard write failed:', err); }
|
||||
}
|
||||
|
||||
function toggleSelect(cid: string) {
|
||||
@@ -78,7 +79,8 @@ export default function PinsPage() {
|
||||
try {
|
||||
await removePin(cid);
|
||||
success++;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[PinsPage] Batch unpin failed for CID:', err);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +100,7 @@ export default function PinsPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Pinnen">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Pins</h1>
|
||||
@@ -225,6 +228,7 @@ export default function PinsPage() {
|
||||
onClear={clearSelection}
|
||||
label="selected"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ export default function ProfilePage() {
|
||||
setCurrentPass('');
|
||||
setNewPass('');
|
||||
setConfirmPass('');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function RegisterPage() {
|
||||
message: `Welkom, ${username.trim()}! Je kunt nu inloggen.`,
|
||||
});
|
||||
router.push('/login');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registratie mislukt');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Registratie mislukt');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Save, RotateCcw, Check, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface SettingsActionsProps {
|
||||
dirty: boolean;
|
||||
saved: boolean;
|
||||
error: string | null;
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function errorMsg(field: string, validation: Record<string, string | null>): ReactNode {
|
||||
return validation[field] ? (
|
||||
<p className="mt-1 text-xs text-accent-rose">{validation[field]}</p>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function SettingsActions({ dirty, saved, error, onSave, onReset }: SettingsActionsProps) {
|
||||
return (
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!dirty && !error}
|
||||
className="flex items-center gap-2 px-5 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"
|
||||
>
|
||||
{saved ? (
|
||||
<><Check className="w-4 h-4 text-accent-green" /> Saved!</>
|
||||
) : (
|
||||
<><Save className="w-4 h-4" /> Save Settings</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-800 hover:bg-surface-700 text-surface-300 text-sm font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset Defaults
|
||||
</button>
|
||||
|
||||
{dirty && !saved && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ── Settings Page Helpers ── */
|
||||
|
||||
import type { PortalSettings } from "@/lib/storage";
|
||||
|
||||
export function formatStorageMB(mb: number): string {
|
||||
if (mb >= 1024) return (mb / 1024).toFixed(0) + " GB";
|
||||
return mb + " MB";
|
||||
}
|
||||
|
||||
export function inputCls(field: string, validation: Record<string, string | null>): string {
|
||||
return `w-full rounded-lg bg-surface-900 border ${
|
||||
validation[field] ? "border-accent-rose/50" : "border-surface-700"
|
||||
} px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors`;
|
||||
}
|
||||
|
||||
export function validateSettings(
|
||||
form: PortalSettings,
|
||||
): { valid: boolean; errors: Record<string, string | null> } {
|
||||
const errors: Record<string, string | null> = {};
|
||||
let valid = true;
|
||||
|
||||
if (!form.gatewayUrl.startsWith("http://") && !form.gatewayUrl.startsWith("https://")) {
|
||||
errors.gatewayUrl = "Must start with http:// or https://";
|
||||
valid = false;
|
||||
}
|
||||
if (form.gatewayUrl.length > 500) {
|
||||
errors.gatewayUrl = "Too long (max 500 chars)";
|
||||
valid = false;
|
||||
}
|
||||
if (form.apiEndpoint && !form.apiEndpoint.startsWith("/") && !form.apiEndpoint.startsWith("http")) {
|
||||
errors.apiEndpoint = "Must be empty, a path (/…), or a URL";
|
||||
valid = false;
|
||||
}
|
||||
if (form.storageMax < 1 || form.storageMax > 102400) {
|
||||
errors.storageMax = "Must be 1–102400 MB";
|
||||
valid = false;
|
||||
}
|
||||
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
|
||||
errors.refreshInterval = "Must be 5–300 seconds";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return { valid, errors };
|
||||
}
|
||||
+31
-119
@@ -3,25 +3,16 @@
|
||||
import { useState } from 'react';
|
||||
import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import {
|
||||
Settings, Globe, Server, HardDrive, RefreshCw, Shield,
|
||||
Sun, Moon, Save, RotateCcw, Check, AlertTriangle,
|
||||
Globe, Server, HardDrive, RefreshCw, Shield,
|
||||
Sun, Moon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function formatStorageMB(mb: number): string {
|
||||
if (mb >= 1024) return (mb / 1024).toFixed(0) + ' GB';
|
||||
return mb + ' MB';
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { formatStorageMB, inputCls, validateSettings } from './helpers';
|
||||
import { errorMsg } from './components';
|
||||
import { SettingsActions } from './components';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
@@ -40,47 +31,19 @@ export default function SettingsPage() {
|
||||
setForm(prev => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
|
||||
// Clear validation error on change
|
||||
if (validation[key]) {
|
||||
setValidation(prev => ({ ...prev, [key]: null }));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Validate ── */
|
||||
function validate(): boolean {
|
||||
const errs: Record<string, string | null> = {};
|
||||
let valid = true;
|
||||
|
||||
if (!form.gatewayUrl.startsWith('http://') && !form.gatewayUrl.startsWith('https://')) {
|
||||
errs.gatewayUrl = 'Must start with http:// or https://';
|
||||
valid = false;
|
||||
}
|
||||
if (form.gatewayUrl.length > 500) {
|
||||
errs.gatewayUrl = 'Too long (max 500 chars)';
|
||||
valid = false;
|
||||
}
|
||||
if (form.apiEndpoint && !form.apiEndpoint.startsWith('/') && !form.apiEndpoint.startsWith('http')) {
|
||||
errs.apiEndpoint = 'Must be empty, a path (/…), or a URL';
|
||||
valid = false;
|
||||
}
|
||||
if (form.storageMax < 1 || form.storageMax > 102400) {
|
||||
errs.storageMax = 'Must be 1–102400 MB';
|
||||
valid = false;
|
||||
}
|
||||
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
|
||||
errs.refreshInterval = 'Must be 5–300 seconds';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
setValidation(errs);
|
||||
if (!valid) setError('Fix validation errors before saving');
|
||||
return valid;
|
||||
}
|
||||
|
||||
/* ── Save ── */
|
||||
function handleSave() {
|
||||
if (!validate()) return;
|
||||
const result = validateSettings(form);
|
||||
setValidation(result.errors);
|
||||
if (!result.valid) {
|
||||
setError('Fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
const merged = saveSettings(form);
|
||||
setForm(merged);
|
||||
setOriginal({ ...merged });
|
||||
@@ -100,18 +63,6 @@ export default function SettingsPage() {
|
||||
setValidation({});
|
||||
}
|
||||
|
||||
/* ── Render helpers ── */
|
||||
|
||||
function inputCls(field: string): string {
|
||||
return `w-full rounded-lg bg-surface-900 border ${validation[field] ? 'border-accent-rose/50' : 'border-surface-700'} px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors`;
|
||||
}
|
||||
|
||||
function errorMsg(field: string) {
|
||||
return validation[field] ? (
|
||||
<p className="mt-1 text-xs text-accent-rose">{validation[field]}</p>
|
||||
) : null;
|
||||
}
|
||||
|
||||
/* ════════════ Sections ════════════ */
|
||||
|
||||
const sections = [
|
||||
@@ -126,9 +77,9 @@ export default function SettingsPage() {
|
||||
value={form.gatewayUrl}
|
||||
onChange={e => update('gatewayUrl', e.target.value)}
|
||||
placeholder="https://ipfs.io/ipfs"
|
||||
className={inputCls('gatewayUrl')}
|
||||
className={inputCls('gatewayUrl', validation)}
|
||||
/>
|
||||
{errorMsg('gatewayUrl')}
|
||||
{errorMsg('gatewayUrl', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Base URL for IPFS gateway links. Used in dashboard, history, and share links.
|
||||
</p>
|
||||
@@ -146,9 +97,9 @@ export default function SettingsPage() {
|
||||
value={form.apiEndpoint}
|
||||
onChange={e => update('apiEndpoint', e.target.value)}
|
||||
placeholder="(same-origin)"
|
||||
className={inputCls('apiEndpoint')}
|
||||
className={inputCls('apiEndpoint', validation)}
|
||||
/>
|
||||
{errorMsg('apiEndpoint')}
|
||||
{errorMsg('apiEndpoint', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Leave empty to use the same origin. Set to a custom URL to proxy through another server.
|
||||
</p>
|
||||
@@ -185,7 +136,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<span className="text-xs text-surface-500">MB</span>
|
||||
</div>
|
||||
{errorMsg('storageMax')}
|
||||
{errorMsg('storageMax', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Maximum storage the IPFS node should use. Applied server-side.
|
||||
</p>
|
||||
@@ -198,23 +149,20 @@ export default function SettingsPage() {
|
||||
iconColor: 'text-accent-rose',
|
||||
fields: (
|
||||
<div className="space-y-4">
|
||||
{/* Allowed file types */}
|
||||
<div>
|
||||
<label className="text-xs text-surface-400 mb-1.5 block">Allowed File Extensions</label>
|
||||
<input
|
||||
value={form.allowedFileTypes}
|
||||
onChange={e => update('allowedFileTypes', e.target.value)}
|
||||
placeholder=".jpg,.png,.pdf,.mp4 (leave empty for all)"
|
||||
className={inputCls('allowedFileTypes')}
|
||||
className={inputCls('allowedFileTypes', validation)}
|
||||
/>
|
||||
{errorMsg('allowedFileTypes')}
|
||||
{errorMsg('allowedFileTypes', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Comma-separated extensions. Empty = all file types accepted.
|
||||
Extension check is case-insensitive.
|
||||
Comma-separated extensions. Empty = all file types accepted. Extension check is case-insensitive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max file size */}
|
||||
<div>
|
||||
<label className="text-xs text-surface-400 mb-1.5 block">
|
||||
Max File Size: <span className="text-surface-200 font-medium">{formatBytes(form.maxFileSize)}</span>
|
||||
@@ -244,7 +192,6 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max files per batch */}
|
||||
<div>
|
||||
<label className="text-xs text-surface-400 mb-1.5 block">
|
||||
Max Files Per Upload: <span className="text-surface-200 font-medium">{form.maxFiles}</span>
|
||||
@@ -306,7 +253,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<span className="text-xs text-surface-500">sec</span>
|
||||
</div>
|
||||
{errorMsg('refreshInterval')}
|
||||
{errorMsg('refreshInterval', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
How often the dashboard auto-refreshes peer/bandwidth data.
|
||||
</p>
|
||||
@@ -346,13 +293,12 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
{/* ── Header ── */}
|
||||
<ErrorBoundary label="Instellingen">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">IPFS Portal configuration</p>
|
||||
</div>
|
||||
|
||||
{/* ── Settings grid ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{sections.map((s) => (
|
||||
<div key={s.title} className="glass rounded-xl p-5 animate-fade-in">
|
||||
@@ -365,48 +311,14 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Actions + status ── */}
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Save */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty && !error}
|
||||
className="flex items-center gap-2 px-5 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"
|
||||
>
|
||||
{saved ? (
|
||||
<><Check className="w-4 h-4 text-accent-green" /> Saved!</>
|
||||
) : (
|
||||
<><Save className="w-4 h-4" /> Save Settings</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Reset */}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-800 hover:bg-surface-700 text-surface-300 text-sm font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset Defaults
|
||||
</button>
|
||||
|
||||
{/* Dirty indicator */}
|
||||
{dirty && !saved && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsActions
|
||||
dirty={dirty}
|
||||
saved={saved}
|
||||
error={error}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,8 @@ import { type RefObject } from 'react';
|
||||
import {
|
||||
File, CheckCircle, Copy, ExternalLink, Trash2, ArrowUp,
|
||||
} from 'lucide-react';
|
||||
import PaymentPanel from '@/components/PaymentPanel';
|
||||
|
||||
/* ── 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];
|
||||
}
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import PaymentPanel from '@/components/payment';
|
||||
|
||||
/* ── Props ── */
|
||||
interface CryptoUploadProps {
|
||||
|
||||
@@ -5,17 +5,9 @@ import {
|
||||
Upload, File, CheckCircle, XCircle, Loader2,
|
||||
Copy, ExternalLink, Trash2, Globe, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import { getSettings } from '@/lib/storage';
|
||||
|
||||
/* ── 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];
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/* ── Upload Page Helpers ── */
|
||||
|
||||
export type TabMode = "quick" | "crypto";
|
||||
+23
-24
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
|
||||
import { uploadFile } from '@/lib/api';
|
||||
import { uploadFile } from '@/lib/api/files';
|
||||
import { addToHistory, getSettings } from '@/lib/storage';
|
||||
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';
|
||||
|
||||
type TabMode = 'quick' | 'crypto';
|
||||
import type { TabMode } from './helpers';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
@@ -145,9 +146,9 @@ export default function UploadPage() {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
|
||||
));
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === entry.id ? { ...f, status: 'error' as const, errorMsg: e.message || 'Upload failed' } : f
|
||||
f.id === entry.id ? { ...f, status: 'error' as const, errorMsg: e instanceof Error ? e.message : 'Upload failed' } : f
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -184,22 +185,18 @@ export default function UploadPage() {
|
||||
|
||||
/* ── Clipboard ── */
|
||||
function copyCid(cid: string) {
|
||||
navigator.clipboard.writeText(cid).catch(() => {});
|
||||
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(() => {});
|
||||
navigator.clipboard.writeText(link).catch((err) => { console.error('[UploadPage] Clipboard share link failed:', err); });
|
||||
setCopied(`gw-${cid}`);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
function gatewayLink(cid: string) {
|
||||
return `https://ipfs.maos.dedyn.io/${cid}`;
|
||||
}
|
||||
|
||||
/* ════════════ Render ════════════ */
|
||||
|
||||
return (
|
||||
@@ -262,19 +259,21 @@ export default function UploadPage() {
|
||||
|
||||
{/* Crypto Payment tab */}
|
||||
{tab === 'crypto' && (
|
||||
<CryptoUpload
|
||||
cryptoFile={cryptoFile}
|
||||
cryptoResult={cryptoResult}
|
||||
copied={copied}
|
||||
cryptoInputRef={cryptoInputRef}
|
||||
onCryptoSelect={onCryptoSelect}
|
||||
onCryptoFileChange={onCryptoFileChange}
|
||||
onCryptoClear={onCryptoClear}
|
||||
onCryptoPaid={onCryptoPaid}
|
||||
doCryptoUpload={doCryptoUpload}
|
||||
onCopyCid={copyCid}
|
||||
gatewayLink={gatewayLink}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</PortalLayout>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { getRepoStats, getPeers, listPins } from '@/lib/api';
|
||||
import type { RepoStats } from '@/lib/api';
|
||||
import { getRepoStats, getPeers } from '@/lib/api/gateway';
|
||||
import { listPins } from '@/lib/api/pins';
|
||||
import type { RepoStats } from '@/lib/api/client';
|
||||
import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import StorageGauge from '@/app/dashboard/components/StorageGauge';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/* ── Users Page Helpers ── */
|
||||
|
||||
export function formatTimestamp(ts: bigint): string {
|
||||
const d = new Date(Number(ts) * 1000);
|
||||
return d.toLocaleDateString() + " " + d.toLocaleTimeString();
|
||||
}
|
||||
+16
-14
@@ -1,18 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listUsers, createUser, deleteUser } from '@/lib/api';
|
||||
import { listUsers, createUser, deleteUser } from '@/lib/api/users';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { SkeletonTable } from '@/components/Skeleton';
|
||||
import { paymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
|
||||
import { formatWeiToETH, formatBytes } from '@/lib/wallet';
|
||||
import { usePaymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import { formatWeiToETH } from '@/lib/wallet';
|
||||
import { formatTimestamp } from './helpers';
|
||||
import {
|
||||
Users, Plus, Trash2, UserPlus, Search, Wallet,
|
||||
HardDrive, Upload, Clock, ExternalLink, Copy,
|
||||
CheckCircle, Loader2, XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function UsersPage() {
|
||||
const paymentService = usePaymentService();
|
||||
// ── htpasswd users ──
|
||||
const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -31,7 +36,7 @@ export default function UsersPage() {
|
||||
try {
|
||||
const items = await listUsers();
|
||||
setUsers(items);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[UsersPage] Failed to load users:', err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
@@ -45,14 +50,14 @@ export default function UsersPage() {
|
||||
setNewPass('');
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[UsersPage] Failed to create user:', err); }
|
||||
}
|
||||
|
||||
async function handleDelete(username: string) {
|
||||
try {
|
||||
await deleteUser(username);
|
||||
setUsers((u) => u.filter((x) => x.username !== username));
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[UsersPage] Failed to delete user:', err); }
|
||||
}
|
||||
|
||||
// ── Wallet lookup ──
|
||||
@@ -68,8 +73,8 @@ export default function UsersPage() {
|
||||
try {
|
||||
const stats = await paymentService.getUserUploads(addr as `0x${string}`);
|
||||
setWalletStats(stats);
|
||||
} catch (e: any) {
|
||||
setWalletError(e.message || 'Failed to fetch wallet stats');
|
||||
} catch (e: unknown) {
|
||||
setWalletError(e instanceof Error ? e.message : 'Failed to fetch wallet stats');
|
||||
} finally {
|
||||
setWalletLoading(false);
|
||||
}
|
||||
@@ -80,16 +85,12 @@ export default function UsersPage() {
|
||||
await navigator.clipboard.writeText(cid);
|
||||
setCopiedCid(cid);
|
||||
setTimeout(() => setCopiedCid(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: bigint): string {
|
||||
const d = new Date(Number(ts) * 1000);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||||
} catch (err) { console.error('[UsersPage] Clipboard write failed:', err); }
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Gebruikers">
|
||||
{/* ── Header with count badge ── */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -271,6 +272,7 @@ export default function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user