forked from maik/IPFS-portal
f72b775379
- 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
299 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|
|
|