forked from maik/IPFS-portal
fix: bw/stats 404, gateway links, history crash
- swr.ts: remove useBandwidth(), drop bw from useDashboard() - events/route.ts: skip bandwidth SSE (Kubo 0.28.0 unsupported) - usage/page.tsx: remove getBWStats call + bw references - helpers.ts: gatewayLink -> ipfs.maos.dedyn.io (subdomain only) - upload/page.tsx: fix local gatewayLink (subdomain only) - storage.ts: update default gatewayUrl, add defensive getHistory()
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
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 { SkeletonCard, SkeletonTable } from '@/components/Skeleton';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Shield, Users, HardDrive, Server, Activity, Wallet,
|
||||
ArrowUpRight, CreditCard, RefreshCw, Wifi, Database,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ════════════════════════════ Admin Page ════════════════════════════ */
|
||||
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [health, setHealth] = useState<{ status: string; userApi: string; kuboApi: string; mode: string } | null>(null);
|
||||
const [loadingHealth, setLoadingHealth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
listUsers()
|
||||
.then(setUsers)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingUsers(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth()
|
||||
.then((h: any) => setHealth(h))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingHealth(false));
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Gebruikers',
|
||||
value: loadingUsers ? '…' : users.length.toString(),
|
||||
icon: Users,
|
||||
color: 'text-accent-cyan',
|
||||
bg: 'bg-accent-cyan/10',
|
||||
href: '/users',
|
||||
},
|
||||
{
|
||||
label: 'Node Status',
|
||||
value: health?.status ?? '…',
|
||||
icon: Server,
|
||||
color: health?.status === 'ok' ? 'text-accent-green' : 'text-accent-rose',
|
||||
bg: health?.status === 'ok' ? 'bg-accent-green/10' : 'bg-accent-rose/10',
|
||||
href: null,
|
||||
},
|
||||
{
|
||||
label: 'User API',
|
||||
value: health?.userApi ?? '…',
|
||||
icon: Database,
|
||||
color: 'text-accent-amber',
|
||||
bg: 'bg-accent-amber/10',
|
||||
href: null,
|
||||
},
|
||||
{
|
||||
label: 'Kubo API',
|
||||
value: health?.kuboApi ?? '…',
|
||||
icon: Wifi,
|
||||
color: 'text-accent-purple',
|
||||
bg: 'bg-accent-purple/10',
|
||||
href: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AuthGuard requireAdmin>
|
||||
<PortalLayout>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-accent-rose" />
|
||||
Admin
|
||||
</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">System overview and management</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-surface-500">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${health?.status === 'ok' ? 'bg-accent-green' : 'bg-accent-rose'}`} />
|
||||
{health?.mode === 'proxy' ? 'Live' : 'Minimal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="glass rounded-xl p-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={`p-2 rounded-lg ${s.bg}`}>
|
||||
<s.icon className={`w-4 h-4 ${s.color}`} />
|
||||
</div>
|
||||
{s.href && (
|
||||
<Link href={s.href}>
|
||||
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500 hover:text-surface-300 transition-colors" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{s.value}</div>
|
||||
<div className="text-xs text-surface-400 mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<Link
|
||||
href="/admin/payment"
|
||||
className="glass rounded-xl p-5 border border-surface-800 hover:border-surface-700 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-lg bg-brand-500/10">
|
||||
<CreditCard className="w-5 h-5 text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white group-hover:text-brand-400 transition-colors">
|
||||
Payment Config
|
||||
</h3>
|
||||
<p className="text-xs text-surface-500">Smart contract pricing, tokens, tiers</p>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-surface-500 ml-auto group-hover:text-brand-400 transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/users"
|
||||
className="glass rounded-xl p-5 border border-surface-800 hover:border-surface-700 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-lg bg-accent-cyan/10">
|
||||
<Users className="w-5 h-5 text-accent-cyan" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white group-hover:text-accent-cyan transition-colors">
|
||||
User Management
|
||||
</h3>
|
||||
<p className="text-xs text-surface-500">Create, delete, and manage users</p>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-surface-500 ml-auto group-hover:text-accent-cyan transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-surface-400" />
|
||||
Registered Users
|
||||
</h2>
|
||||
{loadingUsers ? (
|
||||
<SkeletonTable rows={4} cols={3} />
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm text-surface-500">No users registered yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-800">
|
||||
<th className="text-left py-2 px-2 text-surface-500 font-medium">Username</th>
|
||||
<th className="text-left py-2 px-2 text-surface-500 font-medium">Created</th>
|
||||
<th className="text-right py-2 px-2 text-surface-500 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.slice(0, 10).map((u) => (
|
||||
<tr key={u.username} className="border-b border-surface-800/50 hover:bg-surface-800/30 transition-colors">
|
||||
<td className="py-2.5 px-2 text-surface-200 font-mono text-xs">{u.username}</td>
|
||||
<td className="py-2.5 px-2 text-surface-400 text-xs">
|
||||
{u.created ? new Date(u.created).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 px-2 text-right">
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${u.active ? 'text-accent-green' : 'text-surface-500'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${u.active ? 'bg-accent-green' : 'bg-surface-600'}`} />
|
||||
{u.active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{users.length > 10 && (
|
||||
<p className="text-xs text-surface-500 text-center mt-3">
|
||||
Showing 10 of {users.length} users
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -43,17 +43,6 @@ async function kuboFetch(path: string): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBWStats() {
|
||||
const raw = await kuboFetch('/api/v0/bw/stats');
|
||||
if (!raw) return null;
|
||||
return {
|
||||
totalIn: raw.TotalIn ?? 0,
|
||||
totalOut: raw.TotalOut ?? 0,
|
||||
rateIn: raw.RateIn ?? 0,
|
||||
rateOut: raw.RateOut ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPeerCount() {
|
||||
const raw = await kuboFetch('/api/v0/swarm/peers');
|
||||
if (!raw || !raw.Peers) return 0;
|
||||
@@ -81,7 +70,6 @@ export async function GET(req: Request): Promise<Response> {
|
||||
// Send initial connection event
|
||||
controller.enqueue(new TextEncoder().encode(encodeSSE('connected', { ts: Date.now() })));
|
||||
|
||||
let bwPrev = { totalIn: 0, totalOut: 0 };
|
||||
let lastRepoPoll = 0;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
@@ -91,25 +79,6 @@ export async function GET(req: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Bandwidth (elke poll)
|
||||
const bw = await fetchBWStats();
|
||||
if (bw) {
|
||||
const deltaIn = bw.totalIn - bwPrev.totalIn;
|
||||
const deltaOut = bw.totalOut - bwPrev.totalOut;
|
||||
bwPrev = { totalIn: bw.totalIn, totalOut: bw.totalOut };
|
||||
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(encodeSSE('bandwidth', {
|
||||
totalIn: bw.totalIn,
|
||||
totalOut: bw.totalOut,
|
||||
rateIn: bw.rateIn,
|
||||
rateOut: bw.rateOut,
|
||||
deltaIn: Math.max(0, deltaIn),
|
||||
deltaOut: Math.max(0, deltaOut),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Peer count (elke poll)
|
||||
const peerCount = await fetchPeerCount();
|
||||
controller.enqueue(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { IPFSEntry } from '@/lib/api';
|
||||
import { explorerLs } from '@/lib/api';
|
||||
import FileIcon from './FileIcon';
|
||||
import { CheckCircle, Download } from 'lucide-react';
|
||||
import { ChevronRight, CheckCircle, Download, Loader2 } from 'lucide-react';
|
||||
import { truncateCid } from '@/lib/helpers';
|
||||
import { downloadFile } from '@/lib/download';
|
||||
|
||||
@@ -34,6 +36,160 @@ function SkeletonRow() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Recursive tree row ── */
|
||||
|
||||
interface TreeRowProps {
|
||||
entry: IPFSEntry;
|
||||
depth: number;
|
||||
onNavigate: (cid: string, name: string) => void;
|
||||
onPreview: (cid: string) => void;
|
||||
pins: string[];
|
||||
subdirEntries: Record<string, IPFSEntry[]>;
|
||||
loadingSubdirs: Record<string, boolean>;
|
||||
expandedDirs: Set<string>;
|
||||
onToggle: (cid: string) => void;
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
entry,
|
||||
depth,
|
||||
onNavigate,
|
||||
onPreview,
|
||||
pins,
|
||||
subdirEntries,
|
||||
loadingSubdirs,
|
||||
expandedDirs,
|
||||
onToggle,
|
||||
}: TreeRowProps) {
|
||||
const isDir = entry.type === 'dir';
|
||||
const isExpanded = expandedDirs.has(entry.hash);
|
||||
const isLoading = loadingSubdirs[entry.hash] ?? false;
|
||||
const children = subdirEntries[entry.hash];
|
||||
const isPinned = pins.includes(entry.hash);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-800/30 transition-colors cursor-pointer"
|
||||
style={{ paddingLeft: `${16 + depth * 20}px` }}
|
||||
onClick={() =>
|
||||
isDir
|
||||
? onNavigate(entry.hash, entry.name)
|
||||
: onPreview(entry.hash)
|
||||
}
|
||||
>
|
||||
{/* Chevron / spacer column */}
|
||||
{isDir ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle(entry.hash);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-surface-700/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue shrink-0"
|
||||
aria-label={isExpanded ? `Collapse ${entry.name}` : `Expand ${entry.name}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 text-surface-500 animate-spin" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-surface-500 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
)}
|
||||
|
||||
<FileIcon name={entry.name} type={entry.type} className="w-4 h-4 shrink-0" />
|
||||
|
||||
<span className="flex-1 text-sm text-surface-200 truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-12 text-center shrink-0 hidden sm:block">
|
||||
{entry.type}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-16 text-right shrink-0 hidden md:block">
|
||||
{formatSize(entry.size)}
|
||||
</span>
|
||||
|
||||
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
|
||||
{truncateCid(entry.hash, 6)}
|
||||
</code>
|
||||
|
||||
<div className="w-20 flex items-center justify-center gap-1 shrink-0">
|
||||
{entry.type === 'file' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry.hash, entry.name);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-surface-700/50 text-surface-500 hover:text-accent-blue transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue"
|
||||
aria-label={`Download ${entry.name}`}
|
||||
title={`Download ${entry.name}`}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isPinned && (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded sub-tree */}
|
||||
{isExpanded && children !== undefined && (
|
||||
<div className="divide-y divide-surface-800/50">
|
||||
{children.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center px-5 py-2 text-xs text-surface-500 italic"
|
||||
style={{ paddingLeft: `${20 + (depth + 1) * 20}px` }}
|
||||
>
|
||||
empty
|
||||
</div>
|
||||
) : (
|
||||
[...children]
|
||||
.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((child) => (
|
||||
<TreeRow
|
||||
key={child.hash}
|
||||
entry={child}
|
||||
depth={depth + 1}
|
||||
onNavigate={onNavigate}
|
||||
onPreview={onPreview}
|
||||
pins={pins}
|
||||
subdirEntries={subdirEntries}
|
||||
loadingSubdirs={loadingSubdirs}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isExpanded && isLoading && children === undefined && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-5 py-2 text-xs text-surface-500"
|
||||
style={{ paddingLeft: `${20 + (depth + 1) * 20}px` }}
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main component ── */
|
||||
|
||||
export default function DirectoryListing({
|
||||
entries,
|
||||
loading,
|
||||
@@ -41,6 +197,40 @@ export default function DirectoryListing({
|
||||
onPreview,
|
||||
pins,
|
||||
}: DirectoryListingProps) {
|
||||
const [subdirEntries, setSubdirEntries] = useState<Record<string, IPFSEntry[]>>({});
|
||||
const [loadingSubdirs, setLoadingSubdirs] = useState<Record<string, boolean>>({});
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleToggle = useCallback(async (cid: string) => {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (prev.has(cid)) {
|
||||
next.delete(cid);
|
||||
} else {
|
||||
next.add(cid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Only fetch if not already cached
|
||||
setSubdirEntries((prev) => {
|
||||
if (prev[cid] !== undefined) return prev; // already cached
|
||||
// Trigger async fetch — use a microtask to avoid set-in-set
|
||||
void (async () => {
|
||||
setLoadingSubdirs((l) => ({ ...l, [cid]: true }));
|
||||
try {
|
||||
const result = await explorerLs(cid);
|
||||
setSubdirEntries((e) => ({ ...e, [cid]: result }));
|
||||
} catch {
|
||||
setSubdirEntries((e) => ({ ...e, [cid]: [] }));
|
||||
} finally {
|
||||
setLoadingSubdirs((l) => ({ ...l, [cid]: false }));
|
||||
}
|
||||
})();
|
||||
return prev; // unchanged here, effect will update
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="glass rounded-xl overflow-hidden divide-y divide-surface-800/50">
|
||||
@@ -83,57 +273,20 @@ export default function DirectoryListing({
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-surface-800/50">
|
||||
{sorted.map((entry) => {
|
||||
const isPinned = pins.includes(entry.hash);
|
||||
return (
|
||||
<div
|
||||
key={entry.hash}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-800/30 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
entry.type === 'dir'
|
||||
? onNavigate(entry.hash, entry.name)
|
||||
: onPreview(entry.hash)
|
||||
}
|
||||
>
|
||||
<FileIcon name={entry.name} type={entry.type} className="w-4 h-4 shrink-0" />
|
||||
|
||||
<span className="flex-1 text-sm text-surface-200 truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-12 text-center shrink-0 hidden sm:block">
|
||||
{entry.type}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-16 text-right shrink-0 hidden md:block">
|
||||
{formatSize(entry.size)}
|
||||
</span>
|
||||
|
||||
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
|
||||
{truncateCid(entry.hash, 6)}
|
||||
</code>
|
||||
|
||||
<div className="w-20 flex items-center justify-center gap-1 shrink-0">
|
||||
{entry.type === 'file' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry.hash, entry.name);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-surface-700/50 text-surface-500 hover:text-accent-blue transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue"
|
||||
aria-label={`Download ${entry.name}`}
|
||||
title={`Download ${entry.name}`}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isPinned && (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sorted.map((entry) => (
|
||||
<TreeRow
|
||||
key={entry.hash}
|
||||
entry={entry}
|
||||
depth={0}
|
||||
onNavigate={onNavigate}
|
||||
onPreview={onPreview}
|
||||
pins={pins}
|
||||
subdirEntries={subdirEntries}
|
||||
loadingSubdirs={loadingSubdirs}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
FileText,
|
||||
Code,
|
||||
Code2,
|
||||
Image,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Folder,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -15,34 +19,38 @@ interface FileIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ext = (name: string): string =>
|
||||
name.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
const IMAGE_EXTS = new Set([
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
|
||||
]);
|
||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv']);
|
||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
|
||||
const CODE_EXTS = new Set([
|
||||
'js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'css', 'html',
|
||||
'xml', 'yaml', 'yml', 'sh', 'bash',
|
||||
]);
|
||||
const ARCHIVE_EXTS = new Set(['zip', 'tar', 'gz', 'rar', '7z']);
|
||||
const SHEET_EXTS = new Set(['csv', 'xls', 'xlsx']);
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'log']);
|
||||
|
||||
export default function FileIcon({ name, type, className = 'w-4 h-4' }: FileIconProps) {
|
||||
if (type === 'dir') {
|
||||
return <Folder className={`${className} text-accent-cyan`} />;
|
||||
}
|
||||
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const e = ext(name);
|
||||
|
||||
switch (ext) {
|
||||
case 'md':
|
||||
return <FileText className={`${className} text-surface-400`} />;
|
||||
case 'json':
|
||||
return <Code className={`${className} text-surface-400`} />;
|
||||
case 'js':
|
||||
case 'ts':
|
||||
case 'py':
|
||||
case 'css':
|
||||
case 'html':
|
||||
return <Code2 className={`${className} text-surface-400`} />;
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
case 'svg':
|
||||
return <Image className={`${className} text-surface-400`} />;
|
||||
case 'pdf':
|
||||
return <File className={`${className} text-surface-400`} />;
|
||||
default:
|
||||
return <File className={`${className} text-surface-400`} />;
|
||||
}
|
||||
if (IMAGE_EXTS.has(e)) return <FileImage className={`${className} text-surface-400`} />;
|
||||
if (VIDEO_EXTS.has(e)) return <FileVideo className={`${className} text-surface-400`} />;
|
||||
if (AUDIO_EXTS.has(e)) return <FileAudio className={`${className} text-surface-400`} />;
|
||||
if (e === 'pdf') return <FileText className={`${className} text-accent-rose`} />; // FilePdf niet beschikbaar in deze versie
|
||||
if (e === 'json') return <FileJson className={`${className} text-surface-400`} />;
|
||||
if (CODE_EXTS.has(e)) return <FileCode className={`${className} text-surface-400`} />;
|
||||
if (ARCHIVE_EXTS.has(e)) return <FileArchive className={`${className} text-surface-400`} />;
|
||||
if (SHEET_EXTS.has(e)) return <FileSpreadsheet className={`${className} text-surface-400`} />;
|
||||
if (TEXT_EXTS.has(e)) return <FileText className={`${className} text-surface-400`} />;
|
||||
|
||||
return <File className={`${className} text-surface-400`} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { X, Download } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Download, Maximize2 } from 'lucide-react';
|
||||
import { getSettings } from '@/lib/storage';
|
||||
|
||||
interface FilePreviewProps {
|
||||
cid: string;
|
||||
@@ -10,12 +12,23 @@ interface FilePreviewProps {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'json' | 'text' | 'other' {
|
||||
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' {
|
||||
const ext = filename?.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||
if (ext === 'md') return 'markdown';
|
||||
if (ext === 'json') return 'json';
|
||||
if (['txt', 'js', 'ts', 'py', 'css', 'html', 'sh', 'yaml', 'yml', 'toml', 'xml'].includes(ext)) return 'text';
|
||||
if (['txt', 'js', 'ts', 'py', 'css', 'sh', 'yaml', 'yml', 'toml', 'xml'].includes(ext)) return 'text';
|
||||
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(ext)) return 'audio';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (['html', 'htm'].includes(ext)) return 'html';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -89,72 +102,197 @@ function JsonDisplay({ text }: { text: string }) {
|
||||
|
||||
export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
|
||||
const fileType = detectFileType(filename);
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{filename || cid.slice(0, 16) + '…'}
|
||||
</span>
|
||||
<span className="text-xs text-surface-500 uppercase px-1.5 py-0.5 rounded bg-surface-800">
|
||||
{fileType}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-400" />
|
||||
</button>
|
||||
</div>
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFullScreen(false);
|
||||
}, []);
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
useEffect(() => {
|
||||
if (fullScreen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [fullScreen, handleEscape]);
|
||||
|
||||
const renderPreview = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 rounded bg-surface-700 w-full" />
|
||||
<div className="h-3 rounded bg-surface-700 w-5/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-4/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-3/6" />
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||
<img
|
||||
src={`https://ipfs.io/ipfs/${cid}`}
|
||||
alt={filename ?? 'IPFS content'}
|
||||
className="max-w-full max-h-96 rounded object-contain"
|
||||
);
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||
<img
|
||||
src={gatewayUrl(cid)}
|
||||
alt={filename ?? 'IPFS content'}
|
||||
className={`max-w-full ${fullScreen ? 'max-h-[85vh]' : 'max-h-96'} rounded object-contain`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'markdown':
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
className={`prose prose-invert max-w-none text-sm text-surface-300 ${fullScreen ? 'h-[75vh]' : 'max-h-96'} overflow-y-auto whitespace-pre-wrap`}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
);
|
||||
case 'json':
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div className={`${fullScreen ? 'h-[75vh]' : 'max-h-96'} overflow-y-auto`}>
|
||||
<JsonDisplay text={content} />
|
||||
</div>
|
||||
);
|
||||
case 'text':
|
||||
if (!content) return null;
|
||||
return (
|
||||
<pre className={`text-sm font-mono whitespace-pre-wrap text-surface-300 ${fullScreen ? 'h-[75vh]' : 'max-h-96'} overflow-y-auto`}>
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||
<video
|
||||
controls
|
||||
className={`max-w-full ${fullScreen ? 'max-h-[85vh]' : 'max-h-96'} rounded-lg`}
|
||||
src={gatewayUrl(cid)}
|
||||
>
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-4">
|
||||
<audio controls className="w-full" src={gatewayUrl(cid)}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
case 'pdf':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<embed
|
||||
src={gatewayUrl(cid)}
|
||||
type="application/pdf"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg`}
|
||||
/>
|
||||
<p className="text-xs text-surface-500">
|
||||
PDF viewer not available?{' '}
|
||||
<a
|
||||
href={gatewayUrl(cid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-400 underline hover:text-brand-300"
|
||||
>
|
||||
Download PDF
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'html':
|
||||
if (content) {
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-same-origin"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
|
||||
srcDoc={content}
|
||||
title="HTML preview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-same-origin"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
|
||||
src={gatewayUrl(cid)}
|
||||
title="HTML preview"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-sm text-surface-500 mb-4">
|
||||
Preview not available for this file type
|
||||
</p>
|
||||
<a
|
||||
href={gatewayUrl(cid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-800 text-sm text-surface-200 hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download file
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{filename || cid.slice(0, 16) + '…'}
|
||||
</span>
|
||||
<span className="text-xs text-surface-500 uppercase px-1.5 py-0.5 rounded bg-surface-800">
|
||||
{fileType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{fileType !== 'other' && (
|
||||
<button
|
||||
onClick={() => setFullScreen((v) => !v)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||
title="Full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4 text-surface-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : fileType === 'markdown' && content ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm text-surface-300 max-h-96 overflow-y-auto whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
) : fileType === 'json' && content ? (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<JsonDisplay text={content} />
|
||||
</div>
|
||||
) : fileType === 'text' && content ? (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap text-surface-300 max-h-96 overflow-y-auto">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-sm text-surface-500 mb-4">
|
||||
Preview not available for this file type
|
||||
</p>
|
||||
<a
|
||||
href={`https://ipfs.io/ipfs/${cid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-800 text-sm text-surface-200 hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download file
|
||||
</a>
|
||||
|
||||
{/* Preview content */}
|
||||
{renderPreview()}
|
||||
</div>
|
||||
|
||||
{/* Full-screen overlay */}
|
||||
{fullScreen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-8 animate-fade-in">
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
onClick={() => setFullScreen(false)}
|
||||
className="p-2 rounded-lg bg-surface-800/80 hover:bg-surface-700 transition-colors"
|
||||
title="Exit full screen"
|
||||
>
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full max-w-5xl max-h-full">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -114,6 +115,7 @@ export default function HistoryPage() {
|
||||
/* ── Render ── */
|
||||
return (
|
||||
<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">
|
||||
@@ -476,6 +478,7 @@ export default function HistoryPage() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import {
|
||||
Settings, Globe, Server, HardDrive, RefreshCw,
|
||||
Settings, Globe, Server, HardDrive, RefreshCw, Shield,
|
||||
Sun, Moon, Save, RotateCcw, Check, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -16,6 +16,13 @@ function formatStorageMB(mb: number): string {
|
||||
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];
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -185,6 +192,90 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Upload Restrictions',
|
||||
icon: Shield,
|
||||
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')}
|
||||
/>
|
||||
{errorMsg('allowedFileTypes')}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
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>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1024}
|
||||
max={1024 * 1024 * 1024}
|
||||
step={1024 * 1024}
|
||||
value={Math.max(1024, form.maxFileSize)}
|
||||
onChange={e => update('maxFileSize', Number(e.target.value))}
|
||||
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
|
||||
style={{ accentColor: '#14b8a6' }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1024 * 1024 * 1024}
|
||||
value={form.maxFileSize}
|
||||
onChange={e => update('maxFileSize', Math.max(0, Number(e.target.value) || 0))}
|
||||
className="w-24 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Maximum file size in bytes. 0 = unlimited.
|
||||
</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>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={form.maxFiles}
|
||||
onChange={e => update('maxFiles', Number(e.target.value))}
|
||||
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
|
||||
style={{ accentColor: '#14b8a6' }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.maxFiles}
|
||||
onChange={e => update('maxFiles', Math.max(1, Math.min(100, Number(e.target.value) || 0)))}
|
||||
className="w-16 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Maximum number of files allowed in a single upload batch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Auto-Refresh',
|
||||
icon: RefreshCw,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { type DragEvent, type RefObject } from 'react';
|
||||
import { useState, type DragEvent, type RefObject } from 'react';
|
||||
import {
|
||||
Upload, File, CheckCircle, XCircle, Loader2,
|
||||
Copy, ExternalLink, Trash2, Globe,
|
||||
Copy, ExternalLink, Trash2, Globe, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
import { getSettings } from '@/lib/storage';
|
||||
|
||||
/* ── Helpers ── */
|
||||
function formatBytes(b: number): string {
|
||||
@@ -57,20 +54,80 @@ export default function QuickUpload({
|
||||
onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink,
|
||||
}: QuickUploadProps) {
|
||||
|
||||
const s = getSettings();
|
||||
const maxFiles = s.maxFiles;
|
||||
const maxFileSize = s.maxFileSize;
|
||||
|
||||
const allowedExts = s.allowedFileTypes
|
||||
? s.allowedFileTypes.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
const [fileTypeError, setFileTypeError] = useState<string | null>(null);
|
||||
|
||||
const doneCount = files.filter(f => f.status === 'done').length;
|
||||
const errorCount = files.filter(f => f.status === 'error').length;
|
||||
const totalCount = files.length;
|
||||
const allDone = files.every(f => f.status === 'done' || f.status === 'error');
|
||||
|
||||
function filterAllowedFiles(fileList: File[]): { accepted: File[]; rejected: string[] } {
|
||||
if (!allowedExts) return { accepted: fileList, rejected: [] };
|
||||
const accepted: File[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const f of fileList) {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase();
|
||||
if (ext && allowedExts.includes(ext)) {
|
||||
accepted.push(f);
|
||||
} else {
|
||||
rejected.push(f.name);
|
||||
}
|
||||
}
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
function handleLocalDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
onDragLeave();
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
const fileArr = Array.from(e.dataTransfer.files);
|
||||
const { accepted, rejected } = filterAllowedFiles(fileArr);
|
||||
if (rejected.length > 0) {
|
||||
setFileTypeError(`${rejected.length} bestand(en) overgeslagen (type niet toegestaan): ${rejected.join(', ')}`);
|
||||
setTimeout(() => setFileTypeError(null), 5000);
|
||||
}
|
||||
if (accepted.length > 0) onAddFiles(accepted);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLocalInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const fileArr = Array.from(e.target.files);
|
||||
const { accepted, rejected } = filterAllowedFiles(fileArr);
|
||||
if (rejected.length > 0) {
|
||||
setFileTypeError(`${rejected.length} bestand(en) overgeslagen (type niet toegestaan): ${rejected.join(', ')}`);
|
||||
setTimeout(() => setFileTypeError(null), 5000);
|
||||
}
|
||||
if (accepted.length > 0) onAddFiles(accepted);
|
||||
}
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* File type error warning */}
|
||||
{fileTypeError && (
|
||||
<div className="mb-4 flex items-center gap-2 p-3 rounded-xl bg-accent-rose/10 border border-accent-rose/30 text-sm text-accent-rose">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{fileTypeError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
{!uploading && !uploadDone && (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={files.length < MAX_FILES ? onDrop : undefined}
|
||||
onClick={files.length < MAX_FILES ? onBrowse : undefined}
|
||||
onDrop={files.length < maxFiles ? handleLocalDrop : undefined}
|
||||
onClick={files.length < maxFiles ? onBrowse : undefined}
|
||||
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-200 cursor-pointer ${
|
||||
dragOver
|
||||
? 'border-brand-400 bg-brand-500/10'
|
||||
@@ -82,8 +139,8 @@ export default function QuickUpload({
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onInputChange}
|
||||
disabled={uploading || files.length >= MAX_FILES}
|
||||
onChange={files.length < maxFiles ? handleLocalInputChange : undefined}
|
||||
disabled={uploading || files.length >= maxFiles}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Upload className="w-10 h-10 text-surface-500" />
|
||||
@@ -92,7 +149,7 @@ export default function QuickUpload({
|
||||
<span className="text-brand-400 font-medium">Click to browse</span> or drag & drop files
|
||||
</p>
|
||||
<p className="text-xs text-surface-500 mt-1">
|
||||
Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each
|
||||
Up to {maxFiles} files, max {formatBytes(maxFileSize)} each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +219,7 @@ export default function QuickUpload({
|
||||
Uploading…
|
||||
</span>
|
||||
)}
|
||||
{entry.status === 'done' && entry.result && (
|
||||
{entry.status === 'done' && entry.result?.cid && (
|
||||
<span className="text-xs font-mono text-surface-400 truncate max-w-[160px]">
|
||||
{entry.result.cid.slice(0, 16)}…
|
||||
</span>
|
||||
@@ -257,12 +314,12 @@ export default function QuickUpload({
|
||||
</div>
|
||||
|
||||
{/* Per-file result details */}
|
||||
{files.filter(f => f.status === 'done').map(entry => entry.result && (
|
||||
{files.filter(f => f.status === 'done').map(entry => entry.result?.cid && (
|
||||
<div key={entry.id} className="flex items-center justify-between py-2 px-3 rounded-lg bg-surface-800/50 mb-1.5">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<span className="text-sm text-surface-300 truncate max-w-[150px]">{entry.file.name}</span>
|
||||
<span className="text-xs font-mono text-surface-500 truncate max-w-[120px]">{entry.result.cid.slice(0, 16)}…</span>
|
||||
<span className="text-xs text-surface-500">{formatBytes(entry.result.size)}</span>
|
||||
<span className="text-xs text-surface-500">{formatBytes(entry.result.size ?? 0)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
|
||||
+33
-12
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, type DragEvent, useCallback } from 'react';
|
||||
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
|
||||
import { uploadFile } from '@/lib/api';
|
||||
import { addToHistory } from '@/lib/storage';
|
||||
import { addToHistory, getSettings } from '@/lib/storage';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { checkUploadAllowed } from '@/lib/limits';
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
@@ -10,17 +10,13 @@ import { Zap, ArrowUp } from 'lucide-react';
|
||||
import QuickUpload, { type FileEntry } from './components/QuickUpload';
|
||||
import CryptoUpload from './components/CryptoUpload';
|
||||
|
||||
/* ── Constants ── */
|
||||
const MAX_FILES = 20;
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
|
||||
type TabMode = 'quick' | 'crypto';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
export default function UploadPage() {
|
||||
const { notify } = useNotify();
|
||||
const settings = useMemo(() => getSettings(), []);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cryptoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -41,24 +37,49 @@ export default function UploadPage() {
|
||||
|
||||
/* ── File management ── */
|
||||
const addFiles = useCallback((incoming: FileList | File[]) => {
|
||||
const s = getSettings();
|
||||
const arr = Array.from(incoming);
|
||||
const remaining = MAX_FILES - files.length;
|
||||
const remaining = s.maxFiles - files.length;
|
||||
const toAdd = arr.slice(0, remaining);
|
||||
|
||||
const allowedExts = s.allowedFileTypes
|
||||
? s.allowedFileTypes.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
const rejected: string[] = [];
|
||||
|
||||
const entries: FileEntry[] = toAdd
|
||||
.filter(f => f.size <= MAX_FILE_SIZE)
|
||||
.filter(f => s.maxFileSize === 0 || f.size <= s.maxFileSize)
|
||||
.filter(f => !files.some(ex => ex.file.name === f.name && ex.file.size === f.size))
|
||||
.filter(f => {
|
||||
if (!allowedExts) return true;
|
||||
const ext = f.name.split('.').pop()?.toLowerCase();
|
||||
if (ext && allowedExts.includes(ext)) return true;
|
||||
rejected.push(f.name);
|
||||
return false;
|
||||
})
|
||||
.map(f => ({
|
||||
id: Math.random().toString(36).slice(2, 9),
|
||||
file: f,
|
||||
preview: ALLOWED_TYPES.includes(f.type) ? URL.createObjectURL(f) : undefined,
|
||||
preview: !allowedExts || allowedExts.some(ext => f.name.toLowerCase().endsWith('.' + ext))
|
||||
? URL.createObjectURL(f)
|
||||
: undefined,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
|
||||
if (rejected.length > 0) {
|
||||
notify({
|
||||
type: 'warning',
|
||||
title: 'Bestandstype niet toegestaan',
|
||||
message: `De volgende bestanden zijn overgeslagen: ${rejected.join(', ')}`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) return;
|
||||
setFiles(prev => [...prev, ...entries]);
|
||||
setUploadDone(false);
|
||||
}, [files]);
|
||||
}, [files, notify]);
|
||||
|
||||
function removeFile(id: string) {
|
||||
setFiles(prev => {
|
||||
@@ -176,7 +197,7 @@ export default function UploadPage() {
|
||||
}
|
||||
|
||||
function gatewayLink(cid: string) {
|
||||
return `https://maos.dedyn.io/ipfs/${cid}`;
|
||||
return `https://ipfs.maos.dedyn.io/${cid}`;
|
||||
}
|
||||
|
||||
/* ════════════ Render ════════════ */
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { getRepoStats, getBWStats, getPeers, listPins } from '@/lib/api';
|
||||
import type { RepoStats, BWStats } from '@/lib/api';
|
||||
import { getRepoStats, getPeers, listPins } from '@/lib/api';
|
||||
import type { RepoStats } from '@/lib/api';
|
||||
import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import StorageGauge from '@/app/dashboard/components/StorageGauge';
|
||||
@@ -17,7 +17,6 @@ export default function UsagePage() {
|
||||
const { user } = useAuth();
|
||||
const [usage, setUsage] = useState<UsageCheckResult | null>(null);
|
||||
const [repo, setRepo] = useState<RepoStats | null>(null);
|
||||
const [bw, setBW] = useState<BWStats | null>(null);
|
||||
const [peerCount, setPeerCount] = useState(0);
|
||||
const [pinCount, setPinCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -25,16 +24,14 @@ export default function UsagePage() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [u, r, b, p, pins] = await Promise.all([
|
||||
const [u, r, p, pins] = await Promise.all([
|
||||
checkUploadAllowed().catch(() => null),
|
||||
getRepoStats().catch(() => null),
|
||||
getBWStats().catch(() => null),
|
||||
getPeers().catch(() => []),
|
||||
listPins().catch(() => []),
|
||||
]);
|
||||
setUsage(u);
|
||||
setRepo(r);
|
||||
setBW(b);
|
||||
setPeerCount(p.length);
|
||||
setPinCount(pins.length);
|
||||
} finally {
|
||||
@@ -122,7 +119,7 @@ export default function UsagePage() {
|
||||
<span className="text-xs text-surface-400">In</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-surface-200">
|
||||
{bw ? (bw.totalIn / 1e6).toFixed(1) : '—'} MB
|
||||
— MB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
|
||||
@@ -131,7 +128,7 @@ export default function UsagePage() {
|
||||
<span className="text-xs text-surface-400">Uit</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-surface-200">
|
||||
{bw ? (bw.totalOut / 1e6).toFixed(1) : '—'} MB
|
||||
— MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user