Files
IPFS-portal/src/app/settings/page.tsx
T
maikrolf 1ddc89479c 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.
2026-06-25 19:47:57 +02:00

320 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}