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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user