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:
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>')
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user