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
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api/ipns';
|
|
import PortalLayout from '@/app/layout-portal';
|
|
import { RefreshCw, ExternalLink, Key, FileText, Plus } from 'lucide-react';
|
|
import { SkeletonTable } from '@/components/Skeleton';
|
|
|
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
|
import type { Status } from './helpers';
|
|
|
|
export default function IPNSPage() {
|
|
const [keys, setKeys] = useState<IPNSKey[]>([]);
|
|
const [status, setStatus] = useState<Status>('loading');
|
|
const [error, setError] = useState('');
|
|
|
|
// Publish form
|
|
const [pubCid, setPubCid] = useState('');
|
|
const [pubKey, setPubKey] = useState('self');
|
|
const [pubResult, setPubResult] = useState<{ name: string; value: string } | null>(null);
|
|
const [pubLoading, setPubLoading] = useState(false);
|
|
const [pubError, setPubError] = useState('');
|
|
|
|
// Resolve form
|
|
const [resolveName, setResolveName] = useState('');
|
|
const [resolveResult, setResolveResult] = useState('');
|
|
const [resolveLoading, setResolveLoading] = useState(false);
|
|
const [resolveError, setResolveError] = useState('');
|
|
|
|
// Gen key form
|
|
const [genName, setGenName] = useState('');
|
|
const [genLoading, setGenLoading] = useState(false);
|
|
const [genError, setGenError] = useState('');
|
|
|
|
const loadKeys = useCallback(async () => {
|
|
setStatus('loading');
|
|
setError('');
|
|
try {
|
|
const k = await listIPNSKeys();
|
|
setKeys(k);
|
|
setStatus('success');
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Failed to load keys');
|
|
setStatus('error');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { loadKeys(); }, [loadKeys]);
|
|
|
|
const handlePublish = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!pubCid.trim()) return;
|
|
setPubLoading(true);
|
|
setPubError('');
|
|
setPubResult(null);
|
|
try {
|
|
const res = await ipnsPublish(pubCid.trim(), pubKey);
|
|
setPubResult(res);
|
|
} catch (e) {
|
|
setPubError(e instanceof Error ? e.message : 'Publish failed');
|
|
} finally {
|
|
setPubLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResolve = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!resolveName.trim()) return;
|
|
setResolveLoading(true);
|
|
setResolveError('');
|
|
setResolveResult('');
|
|
try {
|
|
const path = await ipnsResolve(resolveName.trim());
|
|
setResolveResult(path);
|
|
} catch (e) {
|
|
setResolveError(e instanceof Error ? e.message : 'Resolve failed');
|
|
} finally {
|
|
setResolveLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleGenKey = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!genName.trim()) return;
|
|
setGenLoading(true);
|
|
setGenError('');
|
|
try {
|
|
await ipnsGenKey(genName.trim());
|
|
setGenName('');
|
|
await loadKeys();
|
|
} catch (e) {
|
|
setGenError(e instanceof Error ? e.message : 'Key generation failed');
|
|
} finally {
|
|
setGenLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PortalLayout>
|
|
<ErrorBoundary label="IPNS">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">IPNS</h1>
|
|
<p className="text-sm text-surface-400 mt-0.5">
|
|
InterPlanetary Name System — human-readable names for IPFS content
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={loadKeys}
|
|
className="btn-ghost p-2 rounded-lg text-surface-400 hover:text-surface-200"
|
|
title="Refresh keys"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${status === 'loading' ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Keys */}
|
|
<section className="glass rounded-xl border border-surface-800 p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-sm font-semibold text-surface-200 flex items-center gap-2">
|
|
<Key className="w-4 h-4 text-brand-400" />
|
|
Signing Keys
|
|
</h2>
|
|
<span className="text-xs text-surface-500">{keys.length} key{keys.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
|
|
{status === 'loading' && (
|
|
<div className="overflow-hidden">
|
|
<SkeletonTable rows={4} cols={3} />
|
|
</div>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<div className="text-sm text-accent-rose bg-accent-rose/5 border border-accent-rose/20 rounded-lg p-3">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{status === 'success' && keys.length === 0 && (
|
|
<p className="text-sm text-surface-500 py-2">No IPNS keys found.</p>
|
|
)}
|
|
|
|
{keys.length > 0 && (
|
|
<div className="space-y-2">
|
|
{keys.map((key) => (
|
|
<div
|
|
key={key.id}
|
|
className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800/50 hover:border-surface-700/50 transition-colors"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-surface-200 truncate">{key.name}</p>
|
|
<p className="text-xs text-surface-500 font-mono truncate mt-0.5">{key.id}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setResolveName(key.id)}
|
|
className="btn-ghost p-1.5 rounded-md text-surface-500 hover:text-surface-300 shrink-0 ml-3"
|
|
title="Resolve this key"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Generate key */}
|
|
<form onSubmit={handleGenKey} className="mt-4 pt-4 border-t border-surface-800">
|
|
<label className="text-xs font-medium text-surface-500 mb-1.5 block">Generate New Key</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={genName}
|
|
onChange={(e) => setGenName(e.target.value)}
|
|
placeholder="key-name"
|
|
className="flex-1 px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50"
|
|
required
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={genLoading || !genName.trim()}
|
|
className="btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-40 flex items-center gap-1.5"
|
|
>
|
|
{genLoading ? (
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Plus className="w-3.5 h-3.5" />
|
|
)}
|
|
Generate
|
|
</button>
|
|
</div>
|
|
{genError && <p className="text-xs text-accent-rose mt-1">{genError}</p>}
|
|
</form>
|
|
</section>
|
|
|
|
{/* Two-column: Publish + Resolve */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Publish */}
|
|
<section className="glass rounded-xl border border-surface-800 p-5">
|
|
<h2 className="text-sm font-semibold text-surface-200 flex items-center gap-2 mb-4">
|
|
<FileText className="w-4 h-4 text-brand-400" />
|
|
Publish
|
|
</h2>
|
|
<p className="text-xs text-surface-500 mb-4">
|
|
Point an IPNS name to a CID. Publish is signed with the selected key.
|
|
</p>
|
|
<form onSubmit={handlePublish} className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-surface-500 mb-1 block">CID</label>
|
|
<input
|
|
type="text"
|
|
value={pubCid}
|
|
onChange={(e) => setPubCid(e.target.value)}
|
|
placeholder="Qm... or bafy..."
|
|
className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 font-mono"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-surface-500 mb-1 block">Key</label>
|
|
<select
|
|
value={pubKey}
|
|
onChange={(e) => setPubKey(e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 focus:outline-none focus:border-brand-500/50"
|
|
>
|
|
{keys.map((k) => (
|
|
<option key={k.id} value={k.name}>{k.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={pubLoading || !pubCid.trim()}
|
|
className="btn-primary w-full py-2 text-sm rounded-lg disabled:opacity-40"
|
|
>
|
|
{pubLoading ? 'Publishing...' : 'Publish'}
|
|
</button>
|
|
</form>
|
|
{pubError && <p className="text-xs text-accent-rose mt-2">{pubError}</p>}
|
|
{pubResult && (
|
|
<div className="mt-3 p-3 rounded-lg bg-surface-900/50 border border-surface-800 text-xs space-y-1">
|
|
<p className="text-surface-400">Name: <span className="text-surface-200 font-mono">{pubResult.name}</span></p>
|
|
<p className="text-surface-400">Value: <span className="text-surface-200 font-mono break-all">{pubResult.value}</span></p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Resolve */}
|
|
<section className="glass rounded-xl border border-surface-800 p-5">
|
|
<h2 className="text-sm font-semibold text-surface-200 flex items-center gap-2 mb-4">
|
|
<ExternalLink className="w-4 h-4 text-brand-400" />
|
|
Resolve
|
|
</h2>
|
|
<p className="text-xs text-surface-500 mb-4">
|
|
Resolve an IPNS name or peer ID to the current IPFS path.
|
|
</p>
|
|
<form onSubmit={handleResolve} className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-surface-500 mb-1 block">Name / Peer ID</label>
|
|
<input
|
|
type="text"
|
|
value={resolveName}
|
|
onChange={(e) => setResolveName(e.target.value)}
|
|
placeholder="k51... or /ipns/example.com"
|
|
className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 font-mono"
|
|
required
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={resolveLoading || !resolveName.trim()}
|
|
className="btn-primary w-full py-2 text-sm rounded-lg disabled:opacity-40"
|
|
>
|
|
{resolveLoading ? 'Resolving...' : 'Resolve'}
|
|
</button>
|
|
</form>
|
|
{resolveError && <p className="text-xs text-accent-rose mt-2">{resolveError}</p>}
|
|
{resolveResult && (
|
|
<div className="mt-3 p-3 rounded-lg bg-surface-900/50 border border-surface-800 text-xs">
|
|
<p className="text-surface-400">Resolved to:</p>
|
|
<p className="text-surface-200 font-mono break-all mt-1">{resolveResult}</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</ErrorBoundary>
|
|
</PortalLayout>
|
|
);
|
|
}
|