1
0
forked from maik/IPFS-portal

feat: initial IPFS Portal — Next.js frontend for remote IPFS node management

Multi-file upload with Quick Upload + Crypto Payment tabs.
Dashboard with live IPFS metrics, Explorer, Peers, Pins, History, Settings pages.
Docker deploy (standalone output), full test suite (16 Playwright tests).
Payment integration via IPFSPortalPayment + MockUSDC contracts on zkSync.
This commit is contained in:
maikrolf
2026-06-25 19:47:57 +02:00
commit 1ddc89479c
42 changed files with 8383 additions and 0 deletions
@@ -0,0 +1,39 @@
'use client';
interface BreadcrumbSegment {
cid: string;
name: string;
}
interface BreadcrumbsProps {
path: BreadcrumbSegment[];
onNavigate: (cid: string) => void;
}
export default function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
return (
<nav className="flex items-center flex-wrap gap-1 text-sm">
<button
onClick={() => onNavigate(path[0]?.cid ?? '')}
className="text-surface-400 hover:text-white transition-colors font-medium"
>
Root
</button>
{path.map((segment, i) => (
<span key={`${segment.cid}-${i}`} className="flex items-center gap-1">
<span className="text-surface-600 text-xs">/</span>
<button
onClick={() => onNavigate(segment.cid)}
className={`transition-colors font-mono text-xs ${
i === path.length - 1
? 'text-white'
: 'text-surface-400 hover:text-white'
}`}
>
{segment.name}
</button>
</span>
))}
</nav>
);
}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import { useState } from 'react';
import { Globe } from 'lucide-react';
interface CIDInputProps {
onNavigate: (cid: string) => void;
}
export default function CIDInput({ onNavigate }: CIDInputProps) {
const [value, setValue] = useState('');
function handleSubmit() {
const trimmed = value.trim();
if (!trimmed) return;
onNavigate(trimmed);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleSubmit();
}
return (
<div className="w-full">
<div className="flex items-center gap-0 glass rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-brand-500/50 transition-all duration-200">
<div className="flex items-center justify-center pl-4">
<Globe className="w-4 h-4 text-surface-400" />
</div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter CID or IPNS name..."
className="flex-1 px-3 py-3 bg-transparent text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none"
/>
<button
onClick={handleSubmit}
disabled={!value.trim()}
className="px-5 py-3 bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Go
</button>
</div>
<p className="text-xs text-surface-500 mt-2 ml-1">
Paste a CID to browse its contents
</p>
</div>
);
}
@@ -0,0 +1,130 @@
'use client';
import type { IPFSEntry } from '@/lib/api';
import FileIcon from './FileIcon';
import { CheckCircle } from 'lucide-react';
interface DirectoryListingProps {
entries: IPFSEntry[];
loading: boolean;
onNavigate: (cid: string, name: string) => void;
onPreview: (cid: string) => void;
pins: string[];
}
function formatSize(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));
const s = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
return `${s} ${units[i] ?? ''}`;
}
function truncateHash(hash: string): string {
if (hash.length <= 12) return hash;
return `${hash.slice(0, 6)}${hash.slice(-4)}`;
}
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-5 py-3 animate-pulse">
<div className="w-4 h-4 rounded bg-surface-700" />
<div className="flex-1 h-3 rounded bg-surface-700" />
<div className="w-12 h-3 rounded bg-surface-700 hidden sm:block" />
<div className="w-16 h-3 rounded bg-surface-700 hidden md:block" />
<div className="w-20 h-3 rounded bg-surface-700 hidden lg:block" />
</div>
);
}
export default function DirectoryListing({
entries,
loading,
onNavigate,
onPreview,
pins,
}: DirectoryListingProps) {
if (loading) {
return (
<div className="glass rounded-xl overflow-hidden divide-y divide-surface-800/50">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
);
}
if (entries.length === 0) {
return (
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 rounded-full bg-surface-800 flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-surface-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25-2.25M12 11.625v6.75" />
</svg>
</div>
<p className="text-sm text-surface-400">This directory is empty</p>
</div>
);
}
const sorted = [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<div className="glass rounded-xl overflow-hidden">
{/* Header row — hidden on smallest screens */}
<div className="hidden sm:flex items-center gap-3 px-5 py-2.5 text-xs font-medium text-surface-500 uppercase tracking-wider border-b border-surface-800/50">
<div className="w-4 shrink-0" />
<div className="flex-1">Name</div>
<div className="w-12 text-center">Type</div>
<div className="w-16 text-right hidden md:block">Size</div>
<div className="w-20 text-right hidden lg:block">Hash</div>
<div className="w-10 text-center" />
</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">
{truncateHash(entry.hash)}
</code>
<div className="w-10 flex justify-center shrink-0">
{isPinned && (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
)}
</div>
</div>
);
})}
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import {
FileText,
Code,
Code2,
Image,
File,
Folder,
} from 'lucide-react';
interface FileIconProps {
name: string;
type: 'file' | 'dir';
className?: string;
}
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() ?? '';
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`} />;
}
}
+151
View File
@@ -0,0 +1,151 @@
'use client';
import { X, Download } from 'lucide-react';
interface FilePreviewProps {
cid: string;
content: string | null;
loading: boolean;
onClose: () => void;
filename?: string;
}
function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'json' | 'text' | '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';
return 'other';
}
function renderMarkdown(text: string): string {
return text
.replace(/^### (.+)$/gm, '<h3 class="text-white font-semibold text-sm mt-3 mb-1">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-white font-semibold text-base mt-4 mb-1">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-white font-bold text-lg mt-4 mb-2">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white">$1</strong>')
.replace(/\*(.+?)\*/g, '<em class="text-surface-300">$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" class="text-brand-400 underline hover:text-brand-300" target="_blank" rel="noopener">$1</a>')
.replace(/^- (.+)$/gm, '<li class="text-surface-300 ml-4 list-disc">$1</li>')
.replace(/`(.+?)`/g, '<code class="text-accent-cyan bg-surface-800 px-1 py-0.5 rounded text-xs">$1</code>')
.replace(/\n\n/g, '</p><p class="text-surface-300 text-sm mb-2">')
.replace(/^(?!<[hop])/gm, (match) => match ? match : '');
}
function renderJson(text: string): string {
try {
const parsed = JSON.parse(text);
const syntax = JSON.stringify(parsed, null, 2);
return syntax;
} catch {
return text;
}
}
function JsonDisplay({ text }: { text: string }) {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return <pre className="text-sm font-mono whitespace-pre-wrap text-surface-300">{text}</pre>;
}
const formatted = JSON.stringify(parsed, null, 2);
const colored = formatted.replace(
/("(?:\\.|[^"\\])*")\s*:/g,
'<span class="text-accent-cyan">$1</span>:',
).replace(
/:\s*("(?:\\.|[^"\\])*")/g,
':<span class="text-accent-green"> $1</span>',
).replace(
/:\s*(true|false)/g,
':<span class="text-accent-purple"> $1</span>',
).replace(
/:\s*(\d+\.?\d*)/g,
':<span class="text-accent-amber"> $1</span>',
).replace(
/:\s*(null)/g,
':<span class="text-surface-500"> $1</span>',
);
return (
<pre
className="text-sm font-mono whitespace-pre-wrap text-surface-300"
dangerouslySetInnerHTML={{ __html: colored }}
/>
);
}
export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
const fileType = detectFileType(filename);
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>
{/* Content */}
{loading ? (
<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"
/>
</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>
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import { Copy, CheckCircle } from 'lucide-react';
interface GatewayLinkProps {
cid: string;
filename?: string;
}
export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
const [copied, setCopied] = useState(false);
const gatewayUrl = `https://ipfs.io/ipfs/${cid}${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`;
async function handleCopy() {
try {
await navigator.clipboard.writeText(gatewayUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard write failed silently
}
}
return (
<div className="flex items-center gap-2 text-xs">
<code className="flex-1 truncate text-surface-400 bg-surface-900 px-2 py-1 rounded">
{gatewayUrl}
</code>
<button
onClick={handleCopy}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
title="Copy gateway URL"
>
{copied ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<Copy className="w-3.5 h-3.5 text-surface-400" />
)}
</button>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useState } from 'react';
import { CheckCircle, Pin } from 'lucide-react';
import { addPin } from '@/lib/api';
interface PinBadgeProps {
cid: string;
isPinned: boolean;
onPin: (cid: string) => void;
}
export default function PinBadge({ cid, isPinned, onPin }: PinBadgeProps) {
const [pinning, setPinning] = useState(false);
async function handlePin() {
setPinning(true);
try {
await addPin(cid);
onPin(cid);
} catch {
// pin failed silently
} finally {
setPinning(false);
}
}
if (isPinned) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-accent-green/10 text-accent-green text-xs font-medium">
<CheckCircle className="w-3.5 h-3.5" />
Pinned
</span>
);
}
return (
<button
onClick={handlePin}
disabled={pinning}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-surface-600 text-surface-300 text-xs font-medium hover:bg-surface-700 hover:border-surface-500 transition-colors disabled:opacity-50"
>
<Pin className="w-3.5 h-3.5" />
{pinning ? 'Pinning…' : 'Pin this CID'}
</button>
);
}
+244
View File
@@ -0,0 +1,244 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
import { explorerLs, explorerCat } from '@/lib/api';
import type { IPFSEntry } from '@/lib/api';
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 { Search, AlertCircle, RefreshCw } from 'lucide-react';
const RECENT_STORAGE_KEY = 'ipfs-explorer-recent-cids';
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';
export default function ExplorerPage() {
const [cid, setCid] = useState('');
const [entries, setEntries] = useState<IPFSEntry[]>([]);
const [previewCid, setPreviewCid] = useState<string | null>(null);
const [previewContent, setPreviewContent] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [path, setPath] = useState<BreadcrumbSegment[]>([]);
const [state, setState] = useState<ExplorerState>('idle');
const [error, setError] = useState('');
const [pins, setPins] = useState<string[]>([]);
const [recentCids] = useState<string[]>(loadRecentCids);
// Load pins from the pins page (shared localStorage or just track in-memory)
function handlePinToggle(cid: string) {
setPins((prev) => (prev.includes(cid) ? prev : [...prev, cid]));
}
async function navigateTo(targetCid: string) {
setCid(targetCid);
setPreviewCid(null);
setPreviewContent(null);
setError('');
setState('loading');
saveRecentCid(targetCid);
// Simple IPNS detection — if it contains a dot, assume it's an IPNS name
if (targetCid.includes('.') && !targetCid.startsWith('Qm') && !targetCid.startsWith('baf')) {
setState('resolving');
}
try {
const result = await explorerLs(targetCid);
setEntries(result);
setState('loaded');
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to browse CID';
setError(message);
setState('error');
setEntries([]);
}
}
async function handleNavigate(hash: string, name: string) {
setPath((prev) => [...prev, { cid: hash, name }]);
await navigateTo(hash);
}
async function handleBreadcrumbNavigate(cid: string) {
// Find the index of the clicked segment
const idx = path.findIndex((s) => s.cid === cid);
if (idx >= 0) {
setPath(path.slice(0, idx));
} else {
setPath([]);
}
await navigateTo(cid);
}
async function handlePreview(hash: string) {
setPreviewCid(hash);
setPreviewLoading(true);
setPreviewContent(null);
try {
const content = await explorerCat(hash);
setPreviewContent(content);
} catch {
setPreviewContent(null);
} finally {
setPreviewLoading(false);
}
}
function handleClosePreview() {
setPreviewCid(null);
setPreviewContent(null);
}
function handleRetry() {
if (cid) navigateTo(cid);
}
const isPinned = previewCid ? pins.includes(previewCid) : false;
return (
<PortalLayout>
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">IPFS Explorer</h1>
<p className="text-sm text-surface-400 mt-1">
Browse IPFS content by CID directories, files, and pin management
</p>
</div>
{/* CID input */}
<div className="mb-6">
<CIDInput onNavigate={navigateTo} />
</div>
{/* Recent CIDs suggestion */}
{state === 'idle' && recentCids.length > 0 && (
<div className="mb-6 animate-fade-in">
<p className="text-xs text-surface-500 mb-2">Recent CIDs</p>
<div className="flex flex-wrap gap-2">
{recentCids.slice(0, 5).map((rc) => (
<button
key={rc}
onClick={() => navigateTo(rc)}
className="px-3 py-1.5 rounded-lg bg-surface-800 text-xs font-mono text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
>
{rc.slice(0, 12)}
</button>
))}
</div>
</div>
)}
{/* Idle state */}
{state === 'idle' && (
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center animate-fade-in">
<div className="w-14 h-14 rounded-full bg-surface-800 flex items-center justify-center mb-4">
<Search className="w-6 h-6 text-surface-500" />
</div>
<p className="text-sm text-surface-400 max-w-md">
Enter a CID to browse IPFS content directory listings, file previews, and pin management
</p>
</div>
)}
{/* Resolving IPNS */}
{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>
)}
{/* Error state */}
{state === 'error' && (
<div className="glass rounded-xl p-6 animate-fade-in border border-accent-rose/20">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-accent-rose shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-accent-rose mb-1">
Failed to browse content
</p>
<p className="text-xs text-surface-400 break-all">{error}</p>
</div>
<button
onClick={handleRetry}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-800 text-xs text-surface-300 hover:bg-surface-700 transition-colors shrink-0"
>
<RefreshCw className="w-3.5 h-3.5" />
Retry
</button>
</div>
</div>
)}
{/* Loaded state — breadcrumbs + directory listing */}
{(state === 'loaded' || state === 'loading') && (
<div className="space-y-4 animate-fade-in">
{/* Breadcrumbs + Pin + Gateway row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<Breadcrumbs
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
onNavigate={handleBreadcrumbNavigate}
/>
<div className="flex items-center gap-3 shrink-0">
<GatewayLink cid={cid} />
<PinBadge cid={cid} isPinned={pins.includes(cid)} onPin={handlePinToggle} />
</div>
</div>
{/* Directory listing */}
<DirectoryListing
entries={entries}
loading={state === 'loading'}
onNavigate={handleNavigate}
onPreview={handlePreview}
pins={pins}
/>
{/* File preview */}
{previewCid && (
<FilePreview
cid={previewCid}
content={previewContent}
loading={previewLoading}
onClose={handleClosePreview}
filename={findFilename(entries, previewCid)}
/>
)}
</div>
)}
</PortalLayout>
);
}
function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
return entries.find((e) => e.hash === hash)?.name;
}