1
0
forked from maik/IPFS-portal
Files
IPFS-portal-deploy/src/app/explorer/page.tsx
T

245 lines
8.1 KiB
TypeScript
Raw Normal View History

'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;
}