forked from maik/IPFS-portal
c9432a16ba
- SIWE auth flow: challenge/login/logout/me API routes + JWT middleware
- Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI()
- Fixed session_id → nonce field mismatch in AuthProvider
- Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array
- Added middleware route guard (307 redirect to /login)
- Added login, register, profile, usage, upload pages
- Added dashboard components (StorageGauge, FreeTierProgress)
- Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary
- Added payment integration (smart contract ABI, tiers, tokens)
- Fixed IPNS key listing: Name/Id → name/id mapping
- Added PWA support (manifest, service worker, icons)
- Added tests for helpers, limits, search-index, wallet
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import PortalLayout from '@/app/layout-portal';
|
|
import { explorerLs, explorerCat, listPins } 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 SearchBar from '@/components/SearchBar';
|
|
import type { SearchResult } from '@/lib/search-index';
|
|
import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index';
|
|
import { Search, AlertCircle, RefreshCw, Database, Loader2 } 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);
|
|
const [indexStats, setIndexStats] = useState(getIndexStats);
|
|
const [rebuilding, setRebuilding] = useState(false);
|
|
|
|
// 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);
|
|
}
|
|
|
|
/* ── Search ── */
|
|
|
|
function handleSearchResult(result: SearchResult) {
|
|
navigateTo(result.entry.cid);
|
|
}
|
|
|
|
async function rebuildSearchIndex() {
|
|
setRebuilding(true);
|
|
try {
|
|
const pinsList = await listPins();
|
|
clearIndex();
|
|
let indexed = 0;
|
|
let failed = 0;
|
|
for (const pin of pinsList) {
|
|
if (isTextFile(pin.name) || !pin.name) {
|
|
try {
|
|
const text = await explorerCat(pin.cid);
|
|
const extracted = extractText(text);
|
|
if (extracted.length > 0) {
|
|
addToIndex({
|
|
cid: pin.cid,
|
|
name: pin.name || pin.cid.slice(0, 20),
|
|
type: 'file',
|
|
text: extracted,
|
|
size: text.length,
|
|
indexedAt: Date.now(),
|
|
});
|
|
indexed++;
|
|
}
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
}
|
|
setIndexStats({ entries: indexed, totalChars: 0, lastIndexed: Date.now() });
|
|
} finally {
|
|
setRebuilding(false);
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
{/* Search + Index rebuild */}
|
|
<div className="mb-4 flex items-start gap-3">
|
|
<div className="flex-1">
|
|
<SearchBar onSelect={handleSearchResult} placeholder="Search indexed files…" />
|
|
</div>
|
|
<button
|
|
onClick={rebuildSearchIndex}
|
|
disabled={rebuilding}
|
|
className="flex items-center gap-1.5 px-3 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-xs text-surface-400 hover:text-surface-200 hover:border-surface-600 transition-colors disabled:opacity-50 shrink-0"
|
|
title={`${indexStats.entries} entries indexed`}
|
|
>
|
|
{rebuilding ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Database className="w-3.5 h-3.5" />
|
|
)}
|
|
<span className="hidden sm:inline">Index</span>
|
|
</button>
|
|
</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;
|
|
}
|