forked from maik/IPFS-portal
feat: initial IPFS Portal — Next.js frontend for remote IPFS node management
Multi-file upload with Quick Upload + Crypto Payment tabs. Dashboard with live IPFS metrics, Explorer, Peers, Pins, History, Settings pages. Docker deploy (standalone output), full test suite (16 Playwright tests). Payment integration via IPFSPortalPayment + MockUSDC contracts on zkSync.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
interface BreadcrumbSegment {
|
||||
cid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
path: BreadcrumbSegment[];
|
||||
onNavigate: (cid: string) => void;
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav className="flex items-center flex-wrap gap-1 text-sm">
|
||||
<button
|
||||
onClick={() => onNavigate(path[0]?.cid ?? '')}
|
||||
className="text-surface-400 hover:text-white transition-colors font-medium"
|
||||
>
|
||||
Root
|
||||
</button>
|
||||
{path.map((segment, i) => (
|
||||
<span key={`${segment.cid}-${i}`} className="flex items-center gap-1">
|
||||
<span className="text-surface-600 text-xs">/</span>
|
||||
<button
|
||||
onClick={() => onNavigate(segment.cid)}
|
||||
className={`transition-colors font-mono text-xs ${
|
||||
i === path.length - 1
|
||||
? 'text-white'
|
||||
: 'text-surface-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{segment.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
interface CIDInputProps {
|
||||
onNavigate: (cid: string) => void;
|
||||
}
|
||||
|
||||
export default function CIDInput({ onNavigate }: CIDInputProps) {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
onNavigate(trimmed);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSubmit();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-0 glass rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-brand-500/50 transition-all duration-200">
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<Globe className="w-4 h-4 text-surface-400" />
|
||||
</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter CID or IPNS name..."
|
||||
className="flex-1 px-3 py-3 bg-transparent text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!value.trim()}
|
||||
className="px-5 py-3 bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-surface-500 mt-2 ml-1">
|
||||
Paste a CID to browse its contents
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import type { IPFSEntry } from '@/lib/api';
|
||||
import FileIcon from './FileIcon';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
|
||||
interface DirectoryListingProps {
|
||||
entries: IPFSEntry[];
|
||||
loading: boolean;
|
||||
onNavigate: (cid: string, name: string) => void;
|
||||
onPreview: (cid: string) => void;
|
||||
pins: string[];
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const s = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
|
||||
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">
|
||||
<div className="w-4 h-4 rounded bg-surface-700" />
|
||||
<div className="flex-1 h-3 rounded bg-surface-700" />
|
||||
<div className="w-12 h-3 rounded bg-surface-700 hidden sm:block" />
|
||||
<div className="w-16 h-3 rounded bg-surface-700 hidden md:block" />
|
||||
<div className="w-20 h-3 rounded bg-surface-700 hidden lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DirectoryListing({
|
||||
entries,
|
||||
loading,
|
||||
onNavigate,
|
||||
onPreview,
|
||||
pins,
|
||||
}: DirectoryListingProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="glass rounded-xl overflow-hidden divide-y divide-surface-800/50">
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-surface-800 flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-surface-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25-2.25M12 11.625v6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-surface-400">This directory is empty</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl overflow-hidden">
|
||||
{/* Header row — hidden on smallest screens */}
|
||||
<div className="hidden sm:flex items-center gap-3 px-5 py-2.5 text-xs font-medium text-surface-500 uppercase tracking-wider border-b border-surface-800/50">
|
||||
<div className="w-4 shrink-0" />
|
||||
<div className="flex-1">Name</div>
|
||||
<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>
|
||||
|
||||
<div className="divide-y divide-surface-800/50">
|
||||
{sorted.map((entry) => {
|
||||
const isPinned = pins.includes(entry.hash);
|
||||
return (
|
||||
<div
|
||||
key={entry.hash}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-800/30 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
entry.type === 'dir'
|
||||
? onNavigate(entry.hash, entry.name)
|
||||
: onPreview(entry.hash)
|
||||
}
|
||||
>
|
||||
<FileIcon name={entry.name} type={entry.type} className="w-4 h-4 shrink-0" />
|
||||
|
||||
<span className="flex-1 text-sm text-surface-200 truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-12 text-center shrink-0 hidden sm:block">
|
||||
{entry.type}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-16 text-right shrink-0 hidden md:block">
|
||||
{formatSize(entry.size)}
|
||||
</span>
|
||||
|
||||
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
|
||||
{truncateHash(entry.hash)}
|
||||
</code>
|
||||
|
||||
<div className="w-10 flex justify-center shrink-0">
|
||||
{isPinned && (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
FileText,
|
||||
Code,
|
||||
Code2,
|
||||
Image,
|
||||
File,
|
||||
Folder,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FileIconProps {
|
||||
name: string;
|
||||
type: 'file' | 'dir';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FileIcon({ name, type, className = 'w-4 h-4' }: FileIconProps) {
|
||||
if (type === 'dir') {
|
||||
return <Folder className={`${className} text-accent-cyan`} />;
|
||||
}
|
||||
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
switch (ext) {
|
||||
case 'md':
|
||||
return <FileText className={`${className} text-surface-400`} />;
|
||||
case 'json':
|
||||
return <Code className={`${className} text-surface-400`} />;
|
||||
case 'js':
|
||||
case 'ts':
|
||||
case 'py':
|
||||
case 'css':
|
||||
case 'html':
|
||||
return <Code2 className={`${className} text-surface-400`} />;
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
case 'svg':
|
||||
return <Image className={`${className} text-surface-400`} />;
|
||||
case 'pdf':
|
||||
return <File className={`${className} text-surface-400`} />;
|
||||
default:
|
||||
return <File className={`${className} text-surface-400`} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { X, Download } from 'lucide-react';
|
||||
|
||||
interface FilePreviewProps {
|
||||
cid: string;
|
||||
content: string | null;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'json' | 'text' | 'other' {
|
||||
const ext = filename?.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||
if (ext === 'md') return 'markdown';
|
||||
if (ext === 'json') return 'json';
|
||||
if (['txt', 'js', 'ts', 'py', 'css', 'html', 'sh', 'yaml', 'yml', 'toml', 'xml'].includes(ext)) return 'text';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return 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>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white">$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em class="text-surface-300">$1</em>')
|
||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" class="text-brand-400 underline hover:text-brand-300" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/^- (.+)$/gm, '<li class="text-surface-300 ml-4 list-disc">$1</li>')
|
||||
.replace(/`(.+?)`/g, '<code class="text-accent-cyan bg-surface-800 px-1 py-0.5 rounded text-xs">$1</code>')
|
||||
.replace(/\n\n/g, '</p><p class="text-surface-300 text-sm mb-2">')
|
||||
.replace(/^(?!<[hop])/gm, (match) => match ? match : '');
|
||||
}
|
||||
|
||||
function renderJson(text: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const syntax = JSON.stringify(parsed, null, 2);
|
||||
return syntax;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function JsonDisplay({ text }: { text: string }) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return <pre className="text-sm font-mono whitespace-pre-wrap text-surface-300">{text}</pre>;
|
||||
}
|
||||
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
|
||||
const colored = formatted.replace(
|
||||
/("(?:\\.|[^"\\])*")\s*:/g,
|
||||
'<span class="text-accent-cyan">$1</span>:',
|
||||
).replace(
|
||||
/:\s*("(?:\\.|[^"\\])*")/g,
|
||||
':<span class="text-accent-green"> $1</span>',
|
||||
).replace(
|
||||
/:\s*(true|false)/g,
|
||||
':<span class="text-accent-purple"> $1</span>',
|
||||
).replace(
|
||||
/:\s*(\d+\.?\d*)/g,
|
||||
':<span class="text-accent-amber"> $1</span>',
|
||||
).replace(
|
||||
/:\s*(null)/g,
|
||||
':<span class="text-surface-500"> $1</span>',
|
||||
);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="text-sm font-mono whitespace-pre-wrap text-surface-300"
|
||||
dangerouslySetInnerHTML={{ __html: colored }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
|
||||
const fileType = detectFileType(filename);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{filename || cid.slice(0, 16) + '…'}
|
||||
</span>
|
||||
<span className="text-xs text-surface-500 uppercase px-1.5 py-0.5 rounded bg-surface-800">
|
||||
{fileType}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 rounded bg-surface-700 w-full" />
|
||||
<div className="h-3 rounded bg-surface-700 w-5/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-4/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-3/6" />
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||
<img
|
||||
src={`https://ipfs.io/ipfs/${cid}`}
|
||||
alt={filename ?? 'IPFS content'}
|
||||
className="max-w-full max-h-96 rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : fileType === 'markdown' && content ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm text-surface-300 max-h-96 overflow-y-auto whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
) : fileType === 'json' && content ? (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<JsonDisplay text={content} />
|
||||
</div>
|
||||
) : fileType === 'text' && content ? (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap text-surface-300 max-h-96 overflow-y-auto">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-sm text-surface-500 mb-4">
|
||||
Preview not available for this file type
|
||||
</p>
|
||||
<a
|
||||
href={`https://ipfs.io/ipfs/${cid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-800 text-sm text-surface-200 hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download file
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface GatewayLinkProps {
|
||||
cid: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const gatewayUrl = `https://ipfs.io/ipfs/${cid}${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`;
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(gatewayUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard write failed silently
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<code className="flex-1 truncate text-surface-400 bg-surface-900 px-2 py-1 rounded">
|
||||
{gatewayUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
|
||||
title="Copy gateway URL"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5 text-surface-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Pin } from 'lucide-react';
|
||||
import { addPin } from '@/lib/api';
|
||||
|
||||
interface PinBadgeProps {
|
||||
cid: string;
|
||||
isPinned: boolean;
|
||||
onPin: (cid: string) => void;
|
||||
}
|
||||
|
||||
export default function PinBadge({ cid, isPinned, onPin }: PinBadgeProps) {
|
||||
const [pinning, setPinning] = useState(false);
|
||||
|
||||
async function handlePin() {
|
||||
setPinning(true);
|
||||
try {
|
||||
await addPin(cid);
|
||||
onPin(cid);
|
||||
} catch {
|
||||
// pin failed silently
|
||||
} finally {
|
||||
setPinning(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isPinned) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-accent-green/10 text-accent-green text-xs font-medium">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Pinned
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handlePin}
|
||||
disabled={pinning}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-surface-600 text-surface-300 text-xs font-medium hover:bg-surface-700 hover:border-surface-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
{pinning ? 'Pinning…' : 'Pin this CID'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user