1
0
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:
maikrolf
2026-07-05 16:57:29 +02:00
parent c9432a16ba
commit 99c113b6f5
42 changed files with 10081 additions and 263 deletions
+205 -52
View File
@@ -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>
);
+36 -28
View File
@@ -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`} />;
}
+196 -58
View File
@@ -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>
</>
);
}