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:
maikrolf
2026-06-25 19:47:57 +02:00
commit 1ddc89479c
42 changed files with 8383 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
'use client';
import { useState } from 'react';
import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage';
import PortalLayout from '@/app/layout-portal';
import {
Settings, Globe, Server, HardDrive, RefreshCw,
Sun, Moon, Save, RotateCcw, Check, AlertTriangle,
} from 'lucide-react';
/* ── Helpers ── */
function formatStorageMB(mb: number): string {
if (mb >= 1024) return (mb / 1024).toFixed(0) + ' GB';
return mb + ' MB';
}
/* ════════════════════════════ Page ════════════════════════════ */
export default function SettingsPage() {
const [form, setForm] = useState<PortalSettings>(() => getSettings());
const [original, setOriginal] = useState<PortalSettings>(() => getSettings());
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validation, setValidation] = useState<Record<string, string | null>>({});
const dirty = JSON.stringify(form) !== JSON.stringify(original);
/* ── Field update ── */
function update<K extends keyof PortalSettings>(key: K, value: PortalSettings[K]) {
setForm(prev => ({ ...prev, [key]: value }));
setSaved(false);
setError(null);
// Clear validation error on change
if (validation[key]) {
setValidation(prev => ({ ...prev, [key]: null }));
}
}
/* ── Validate ── */
function validate(): boolean {
const errs: Record<string, string | null> = {};
let valid = true;
if (!form.gatewayUrl.startsWith('http://') && !form.gatewayUrl.startsWith('https://')) {
errs.gatewayUrl = 'Must start with http:// or https://';
valid = false;
}
if (form.gatewayUrl.length > 500) {
errs.gatewayUrl = 'Too long (max 500 chars)';
valid = false;
}
if (form.apiEndpoint && !form.apiEndpoint.startsWith('/') && !form.apiEndpoint.startsWith('http')) {
errs.apiEndpoint = 'Must be empty, a path (/…), or a URL';
valid = false;
}
if (form.storageMax < 1 || form.storageMax > 102400) {
errs.storageMax = 'Must be 1102400 MB';
valid = false;
}
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
errs.refreshInterval = 'Must be 5300 seconds';
valid = false;
}
setValidation(errs);
if (!valid) setError('Fix validation errors before saving');
return valid;
}
/* ── Save ── */
function handleSave() {
if (!validate()) return;
const merged = saveSettings(form);
setForm(merged);
setOriginal({ ...merged });
setSaved(true);
setError(null);
setTimeout(() => setSaved(false), 2500);
}
/* ── Reset ── */
function handleReset() {
if (!confirm('Reset all settings to defaults?')) return;
const defaults = resetSettings();
setForm(defaults);
setOriginal({ ...defaults });
setSaved(false);
setError(null);
setValidation({});
}
/* ── Render helpers ── */
function inputCls(field: string): string {
return `w-full rounded-lg bg-surface-900 border ${validation[field] ? 'border-accent-rose/50' : 'border-surface-700'} px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors`;
}
function errorMsg(field: string) {
return validation[field] ? (
<p className="mt-1 text-xs text-accent-rose">{validation[field]}</p>
) : null;
}
/* ════════════ Sections ════════════ */
const sections = [
{
title: 'IPFS Gateway',
icon: Globe,
iconColor: 'text-accent-cyan',
fields: (
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Gateway URL</label>
<input
value={form.gatewayUrl}
onChange={e => update('gatewayUrl', e.target.value)}
placeholder="https://ipfs.io/ipfs"
className={inputCls('gatewayUrl')}
/>
{errorMsg('gatewayUrl')}
<p className="mt-1.5 text-[11px] text-surface-500">
Base URL for IPFS gateway links. Used in dashboard, history, and share links.
</p>
</div>
),
},
{
title: 'API Endpoint',
icon: Server,
iconColor: 'text-accent-purple',
fields: (
<div>
<label className="text-xs text-surface-400 mb-1.5 block">API Base URL</label>
<input
value={form.apiEndpoint}
onChange={e => update('apiEndpoint', e.target.value)}
placeholder="(same-origin)"
className={inputCls('apiEndpoint')}
/>
{errorMsg('apiEndpoint')}
<p className="mt-1.5 text-[11px] text-surface-500">
Leave empty to use the same origin. Set to a custom URL to proxy through another server.
</p>
</div>
),
},
{
title: 'Storage',
icon: HardDrive,
iconColor: 'text-accent-amber',
fields: (
<div>
<label className="text-xs text-surface-400 mb-1.5 block">
Max Storage: <span className="text-surface-200 font-medium">{formatStorageMB(form.storageMax)}</span>
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={1024}
max={102400}
step={1024}
value={form.storageMax}
onChange={e => update('storageMax', Number(e.target.value))}
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
style={{ accentColor: '#14b8a6' }}
/>
<input
type="number"
min={1}
max={102400}
value={form.storageMax}
onChange={e => update('storageMax', Math.max(1, Math.min(102400, Number(e.target.value) || 0)))}
className="w-20 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
/>
<span className="text-xs text-surface-500">MB</span>
</div>
{errorMsg('storageMax')}
<p className="mt-1.5 text-[11px] text-surface-500">
Maximum storage the IPFS node should use. Applied server-side.
</p>
</div>
),
},
{
title: 'Auto-Refresh',
icon: RefreshCw,
iconColor: 'text-accent-green',
fields: (
<div>
<label className="text-xs text-surface-400 mb-1.5 block">
Dashboard Refresh: <span className="text-surface-200 font-medium">{form.refreshInterval}s</span>
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={5}
max={120}
step={5}
value={form.refreshInterval}
onChange={e => update('refreshInterval', Number(e.target.value))}
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
style={{ accentColor: '#14b8a6' }}
/>
<input
type="number"
min={5}
max={120}
value={form.refreshInterval}
onChange={e => update('refreshInterval', Math.max(5, Math.min(120, Number(e.target.value) || 0)))}
className="w-16 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
/>
<span className="text-xs text-surface-500">sec</span>
</div>
{errorMsg('refreshInterval')}
<p className="mt-1.5 text-[11px] text-surface-500">
How often the dashboard auto-refreshes peer/bandwidth data.
</p>
</div>
),
},
{
title: 'Theme',
icon: form.theme === 'dark' ? Moon : Sun,
iconColor: 'text-accent-amber',
fields: (
<div>
<label className="text-xs text-surface-400 mb-2 block">Appearance</label>
<div className="flex gap-2">
{(['dark', 'light'] as const).map(theme => (
<button
key={theme}
onClick={() => update('theme', theme)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
form.theme === theme
? 'bg-brand-500/20 text-brand-400 border border-brand-500/30'
: 'bg-surface-900 text-surface-400 border border-surface-700 hover:border-surface-500'
}`}
>
{theme === 'dark' ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
{theme.charAt(0).toUpperCase() + theme.slice(1)}
</button>
))}
</div>
<p className="mt-1.5 text-[11px] text-surface-500">
Theme preference is saved but only affects new sessions. Full theme switching coming soon.
</p>
</div>
),
},
];
return (
<PortalLayout>
{/* ── Header ── */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-sm text-surface-400 mt-1">IPFS Portal configuration</p>
</div>
{/* ── Settings grid ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{sections.map((s) => (
<div key={s.title} className="glass rounded-xl p-5 animate-fade-in">
<div className="flex items-center gap-2 mb-4">
<s.icon className={`w-4 h-4 ${s.iconColor}`} />
<h2 className="text-sm font-semibold text-white">{s.title}</h2>
</div>
{s.fields}
</div>
))}
</div>
{/* ── Actions + status ── */}
<div className="glass rounded-xl p-5 animate-fade-in">
<div className="flex flex-wrap items-center gap-3">
{/* Save */}
<button
onClick={handleSave}
disabled={!dirty && !error}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{saved ? (
<><Check className="w-4 h-4 text-accent-green" /> Saved!</>
) : (
<><Save className="w-4 h-4" /> Save Settings</>
)}
</button>
{/* Reset */}
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-800 hover:bg-surface-700 text-surface-300 text-sm font-medium transition-colors"
>
<RotateCcw className="w-4 h-4" />
Reset Defaults
</button>
{/* Dirty indicator */}
{dirty && !saved && (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
<AlertTriangle className="w-3 h-3" />
Unsaved changes
</span>
)}
{/* Error */}
{error && (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
<AlertTriangle className="w-3 h-3" />
{error}
</span>
)}
</div>
</div>
</PortalLayout>
);
}