Files
nixpanel-server/nixserver/src/pages/Packages.tsx
T
NixReaper 92d08d7b79 feat: rename WHM→NixServer, cPanel→NixClient, bump v0.2.0
- Rename frontend workspaces: whm/ → nixserver/, cpanel/ → nixclient/
- Rename server route dirs: routes/whm/ → routes/nixserver/, routes/cpanel/ → routes/nixclient/
- Update all API prefixes: /api/whm → /api/nixserver, /api/cpanel → /api/nixclient
- Rename env vars: WHM_PORT → NIXSERVER_PORT, CPANEL_PORT → NIXCLIENT_PORT
- Update config properties: whmPort → nixserverPort, cpanelPort → nixclientPort
- Update all UI labels, login pages, sidebar branding, and error messages
- Update installer and update scripts to match new names
- Add cpanel email route (missing from initial scaffold)
- Add .gitattributes to enforce LF line endings
- Bump version to 0.2.0 across all package.json files
2026-04-05 10:54:15 -05:00

145 lines
7.8 KiB
TypeScript

import React, { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, RefreshCw } from 'lucide-react'
import { api } from '../api/client'
interface Package {
id: number; name: string; diskMb: number; bandwidthMb: number
maxDatabases: number; maxEmailAccounts: number; maxSubdomains: number
phpVersion: string; sslEnabled: boolean
_count?: { accounts: number }
}
const blank = {
name: '', diskMb: 1024, bandwidthMb: 10240,
maxDatabases: 5, maxEmailAccounts: 10, maxSubdomains: 10,
maxAddonDomains: 5, maxParkedDomains: 5, maxFtpAccounts: 3,
maxCronJobs: 5, phpVersion: '8.3', sslEnabled: true, backupEnabled: true,
}
export default function Packages() {
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(false)
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<Package | null>(null)
const [form, setForm] = useState(blank)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const fetch = async () => {
setLoading(true)
try { const r = await api.get('/nixserver/packages'); setPackages(r.data.data) }
finally { setLoading(false) }
}
useEffect(() => { fetch() }, [])
const openCreate = () => { setEditing(null); setForm(blank); setError(''); setShowModal(true) }
const openEdit = (p: Package) => {
setEditing(p)
setForm({ ...blank, ...p })
setError('')
setShowModal(true)
}
const save = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setError('')
try {
if (editing) await api.put(`/nixserver/packages/${editing.id}`, form)
else await api.post('/nixserver/packages', form)
setShowModal(false); fetch()
} catch (err: unknown) {
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Save failed')
} finally { setSaving(false) }
}
const del = async (p: Package) => {
if (!confirm(`Delete package "${p.name}"?`)) return
try { await api.delete(`/nixserver/packages/${p.id}`); fetch() }
catch (err: unknown) {
alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Delete failed')
}
}
const field = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [k]: e.target.type === 'number' ? Number(e.target.value) : e.target.type === 'checkbox' ? (e.target as HTMLInputElement).checked : e.target.value }))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div><h1 className="text-white text-xl font-semibold">Packages</h1><p className="text-[#64748b] text-sm">{packages.length} hosting plans</p></div>
<div className="flex gap-2">
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
<button onClick={openCreate} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Package</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{packages.map(p => (
<div key={p.id} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-white font-medium">{p.name}</h3>
<p className="text-[#64748b] text-xs">{p._count?.accounts ?? 0} accounts</p>
</div>
<div className="flex gap-1">
<button onClick={() => openEdit(p)} className="p-1.5 text-[#64748b] hover:text-white rounded"><Pencil size={14} /></button>
<button onClick={() => del(p)} className="p-1.5 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{[
['Disk', p.diskMb === 0 ? 'Unlimited' : `${(p.diskMb / 1024).toFixed(0)} GB`],
['BW', p.bandwidthMb === 0 ? 'Unlimited' : `${(p.bandwidthMb / 1024).toFixed(0)} GB`],
['Databases', p.maxDatabases === 0 ? '∞' : p.maxDatabases],
['Emails', p.maxEmailAccounts === 0 ? '∞' : p.maxEmailAccounts],
['Subdomains', p.maxSubdomains === 0 ? '∞' : p.maxSubdomains],
['PHP', p.phpVersion],
].map(([l, v]) => (
<div key={String(l)} className="flex justify-between">
<span className="text-[#64748b]">{l}</span>
<span className="text-[#94a3b8]">{v}</span>
</div>
))}
</div>
{p.sslEnabled && <span className="mt-3 inline-block text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">SSL</span>}
</div>
))}
</div>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
<h2 className="text-white font-medium">{editing ? 'Edit Package' : 'New Package'}</h2>
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
</div>
<form onSubmit={save} className="p-4 space-y-3">
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
<div><label className="block text-[#94a3b8] text-xs mb-1">Name</label>
<input value={form.name} onChange={field('name')} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
{[
['Disk (MB)', 'diskMb'], ['Bandwidth (MB)', 'bandwidthMb'],
['Max Databases', 'maxDatabases'], ['Max Email Accounts', 'maxEmailAccounts'],
['Max Subdomains', 'maxSubdomains'], ['Max Addon Domains', 'maxAddonDomains'],
['Max FTP Accounts', 'maxFtpAccounts'], ['Max Cron Jobs', 'maxCronJobs'],
].map(([label, key]) => (
<div key={key}><label className="block text-[#94a3b8] text-xs mb-1">{label} (0 = unlimited)</label>
<input type="number" min={0} value={form[key as keyof typeof form] as number} onChange={field(key as keyof typeof form)} className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
))}
<div><label className="block text-[#94a3b8] text-xs mb-1">PHP Version</label>
<select value={form.phpVersion} onChange={field('phpVersion')} className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
{['7.4','8.0','8.1','8.2','8.3'].map(v => <option key={v} value={v}>{v}</option>)}
</select></div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Saving...' : 'Save'}</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}