1
0
forked from maik/IPFS-portal
Files
IPFS-portal-deploy/src/app/explorer/page.tsx
T
maikrolf f72b775379 refactor: codebase opschoning — type safety, error handling, central config, page splits
- categorie A: alle catch(e: any) → catch(e: unknown) met instanceof check
- categorie B: stille .catch() logging toegevoegd (SWRegister, admin, upload, auth)
- categorie C: hardcoded 192.168.1.176 IPs vervangen door env var defaults
- categorie D: page files >250L gesplitst (history, settings, explorer, admin/payment,
  ipns, users, upload, dashboard) in helpers/components
- categorie E: eslint-disable vervangen in SearchInput.tsx
- src/lib/config.ts centrale config module (localhost defaults)
- CSP in next.config.ts dynamisch via env var
- deploy.sh: geen hardcoded IP meer (REQUIRED arg)
- lib/api/ lib/auth/ lib/wallet/ lib/search-index/ lib/storage/ gesplitst in modules
- ongebruikte bestanden verwijderd: api.ts, auth.tsx, wallet.ts, search-index.ts,
  PaymentPanel.tsx, SearchBar.tsx, proxy.ts, serve-static.js
2026-07-19 15:35:18 +02:00

299 lines
10 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer';
import { listPins } from '@/lib/api/pins';
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/search';
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';
import Skeleton from '@/components/Skeleton';
import ErrorBoundary from '@/components/ErrorBoundary';
import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
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 (err) {
console.error('[ExplorerPage] Failed to fetch preview content:', err);
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 (err) {
console.error('[ExplorerPage] Failed to index pin:', err);
failed++;
}
}
}
setIndexStats({ entries: indexed, totalChars: 0, lastIndexed: Date.now() });
} finally {
setRebuilding(false);
}
}
const isPinned = previewCid ? pins.includes(previewCid) : false;
return (
<PortalLayout>
<ErrorBoundary label="Verkenner">
{/* 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 — skeleton */}
{state === 'resolving' && (
<div className="glass rounded-xl p-8 animate-fade-in">
<div className="flex items-center gap-3 mb-6">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-28" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="glass rounded-xl p-5 space-y-3">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-48" />
<Skeleton className="h-2 w-full" />
</div>
))}
</div>
</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>
)}
</ErrorBoundary>
</PortalLayout>
);
}