forked from maik/IPFS-portal
1ddc89479c
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.
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
'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 1–102400 MB';
|
||
valid = false;
|
||
}
|
||
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
|
||
errs.refreshInterval = 'Must be 5–300 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>
|
||
);
|
||
}
|