1
0
forked from maik/IPFS-portal

SIWE auth, API proxy fixes, dashboard, login, usage pages

- 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
This commit is contained in:
maikrolf
2026-06-28 18:31:05 +02:00
parent 1ddc89479c
commit c9432a16ba
84 changed files with 6679 additions and 1426 deletions
@@ -2,7 +2,9 @@
import type { IPFSEntry } from '@/lib/api';
import FileIcon from './FileIcon';
import { CheckCircle } from 'lucide-react';
import { CheckCircle, Download } from 'lucide-react';
import { truncateCid } from '@/lib/helpers';
import { downloadFile } from '@/lib/download';
interface DirectoryListingProps {
entries: IPFSEntry[];
@@ -20,11 +22,6 @@ function formatSize(bytes: number): string {
return `${s} ${units[i] ?? ''}`;
}
function truncateHash(hash: string): string {
if (hash.length <= 12) return hash;
return `${hash.slice(0, 6)}${hash.slice(-4)}`;
}
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-5 py-3 animate-pulse">
@@ -82,7 +79,7 @@ export default function DirectoryListing({
<div className="w-12 text-center">Type</div>
<div className="w-16 text-right hidden md:block">Size</div>
<div className="w-20 text-right hidden lg:block">Hash</div>
<div className="w-10 text-center" />
<div className="w-20 text-center" />
</div>
<div className="divide-y divide-surface-800/50">
@@ -113,10 +110,23 @@ export default function DirectoryListing({
</span>
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
{truncateHash(entry.hash)}
{truncateCid(entry.hash, 6)}
</code>
<div className="w-10 flex justify-center shrink-0">
<div className="w-20 flex items-center justify-center gap-1 shrink-0">
{entry.type === 'file' && (
<button
onClick={(e) => {
e.stopPropagation();
downloadFile(entry.hash, entry.name);
}}
className="p-1 rounded-md hover:bg-surface-700/50 text-surface-500 hover:text-accent-blue transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue"
aria-label={`Download ${entry.name}`}
title={`Download ${entry.name}`}
>
<Download className="w-3.5 h-3.5" />
</button>
)}
{isPinned && (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
)}
+10 -1
View File
@@ -19,8 +19,17 @@ function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'j
return 'other';
}
function renderMarkdown(text: string): string {
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderMarkdown(text: string): string {
return escapeHtml(text)
.replace(/^### (.+)$/gm, '<h3 class="text-white font-semibold text-sm mt-3 mb-1">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-white font-semibold text-base mt-4 mb-1">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-white font-bold text-lg mt-4 mb-2">$1</h1>')
+67 -2
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
import { explorerLs, explorerCat } from '@/lib/api';
import { explorerLs, explorerCat, listPins } from '@/lib/api';
import type { IPFSEntry } from '@/lib/api';
import CIDInput from './components/CIDInput';
import DirectoryListing from './components/DirectoryListing';
@@ -10,7 +10,10 @@ 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';
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';
@@ -52,6 +55,8 @@ export default function ExplorerPage() {
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) {
@@ -122,6 +127,46 @@ export default function ExplorerPage() {
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 (
@@ -134,6 +179,26 @@ export default function ExplorerPage() {
</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} />