mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
fix: add all server-only assets and panel files missing from repo
Previously missing from git (rsync --delete was wiping them on every deploy): - assets/css/nova.css - assets/js/nova.js, features.js, reseller.js, user.js - assets/img/*.svg (favicon, icons, logo, mark) - index.php, _branding.php, errors/404.php, errors/500.php - reseller/index.php, user/index.php Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* Server-side branding loader — injected into portal <head> before JS loads.
|
||||
* Reads session cookie → looks up user's reseller → returns branding row.
|
||||
*/
|
||||
function novacpx_get_branding(): array {
|
||||
static $cache = null;
|
||||
if ($cache !== null) return $cache;
|
||||
$cfg = @parse_ini_file('/etc/novacpx/config.ini', true);
|
||||
if (!$cfg) return $cache = [];
|
||||
try {
|
||||
$dbPath = $cfg['database']['path'] ?? '/var/lib/novacpx/panel.db';
|
||||
$pdo = new PDO(
|
||||
"sqlite:{$dbPath}", null, null,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
$token = $_COOKIE['ncpx_session'] ?? '';
|
||||
if (!$token || strlen($token) < 32) return $cache = [];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now') LIMIT 1");
|
||||
$stmt->execute([substr($token, 0, 128)]);
|
||||
$uid = (int)($stmt->fetchColumn() ?: 0);
|
||||
if (!$uid) return $cache = [];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT role, reseller_id FROM users WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$uid]);
|
||||
$u = $stmt->fetch();
|
||||
if (!$u) return $cache = [];
|
||||
|
||||
$resellerId = ($u['role'] === 'reseller') ? $uid : (int)($u['reseller_id'] ?? 0);
|
||||
if (!$resellerId) return $cache = [];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM reseller_branding WHERE user_id = ? LIMIT 1");
|
||||
$stmt->execute([$resellerId]);
|
||||
$row = $stmt->fetch();
|
||||
return $cache = ($row ?: []);
|
||||
} catch (Throwable $e) {
|
||||
return $cache = [];
|
||||
}
|
||||
}
|
||||
|
||||
function novacpx_branding_head(): void {
|
||||
$b = novacpx_get_branding();
|
||||
if (!$b) return;
|
||||
$pc = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['primary_color'] ?? '') ? $b['primary_color'] : null;
|
||||
$ac = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['accent_color'] ?? '') ? $b['accent_color'] : null;
|
||||
$css = $b['custom_css'] ?? '';
|
||||
echo '<style id="reseller-branding">' . "\n";
|
||||
echo ':root {' . "\n";
|
||||
if ($pc) echo " --primary: $pc;\n --primary-dark: $pc;\n";
|
||||
if ($ac) echo " --accent: $ac;\n";
|
||||
echo '}' . "\n";
|
||||
// Sanitize custom CSS — strip </style> tags
|
||||
echo preg_replace('/<\s*\/\s*style/i', '', $css) . "\n";
|
||||
echo '</style>' . "\n";
|
||||
if ($b['favicon_url'] ?? '') {
|
||||
$fav = htmlspecialchars($b['favicon_url']);
|
||||
echo "<link rel=\"icon\" href=\"$fav\">\n";
|
||||
}
|
||||
}
|
||||
|
||||
function novacpx_panel_name(string $default): string {
|
||||
$b = novacpx_get_branding();
|
||||
return htmlspecialchars($b['panel_name'] ?? $default);
|
||||
}
|
||||
|
||||
function novacpx_logo_html(string $default_svg): string {
|
||||
$b = novacpx_get_branding();
|
||||
if (!empty($b['logo_url'])) {
|
||||
$url = htmlspecialchars($b['logo_url']);
|
||||
$name = htmlspecialchars($b['panel_name'] ?? 'Panel');
|
||||
return "<img src=\"$url\" alt=\"$name\" style=\"max-height:36px;max-width:160px;object-fit:contain\">";
|
||||
}
|
||||
return $default_svg;
|
||||
}
|
||||
|
||||
function novacpx_powered_by(): bool {
|
||||
$b = novacpx_get_branding();
|
||||
return empty($b['hide_powered_by']);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/* NovaCPX Design System */
|
||||
:root {
|
||||
--bg: #0d0f17;
|
||||
--bg2: #131520;
|
||||
--bg3: #1a1d2e;
|
||||
--border: #252840;
|
||||
--text: #e2e4f0;
|
||||
--text-muted: #7c7f9a;
|
||||
--primary: #6366f1;
|
||||
--primary-h: #4f52e8;
|
||||
--sky: #0ea5e9;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--radius: 10px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,.4);
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { font-size: 15px; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Login Page ─────────────────────────────────────────────────────────────── */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,.15) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(14,165,233,.1) 0%, transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.login-wrap { width: 100%; max-width: 420px; padding: 1.5rem; }
|
||||
|
||||
.login-brand {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
justify-content: center; margin-bottom: 2rem;
|
||||
}
|
||||
.logo-icon { width: 42px; height: 42px; }
|
||||
.logo-text { font-size: 1.8rem; font-weight: 300; letter-spacing: -.5px; }
|
||||
.logo-text strong { font-weight: 700; background: linear-gradient(135deg, #6366f1, #0ea5e9); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
.login-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.login-card h1 { font-size: 1.4rem; margin-bottom: .25rem; }
|
||||
.login-sub { color: var(--text-muted); font-size: .875rem; margin-bottom: 1.5rem; }
|
||||
|
||||
.login-footer {
|
||||
text-align: center; margin-top: 1.25rem;
|
||||
font-size: .8rem; color: var(--text-muted);
|
||||
}
|
||||
.login-footer a { color: var(--primary); text-decoration: none; }
|
||||
|
||||
/* ── Forms ──────────────────────────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-muted); }
|
||||
|
||||
input[type="text"], input[type="password"], input[type="email"],
|
||||
input[type="number"], input[type="url"], input[type="search"],
|
||||
input[type="date"], input[type="time"], input[type="tel"],
|
||||
input:not([type]), .form-control, select, textarea {
|
||||
width: 100%; padding: .65rem .9rem;
|
||||
background: var(--bg3); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text);
|
||||
font-family: var(--font); font-size: .9rem;
|
||||
transition: border-color .15s;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus, .form-control:focus { border-color: var(--primary); }
|
||||
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacity(.5); }
|
||||
|
||||
.input-with-icon { position: relative; }
|
||||
.input-with-icon input { padding-right: 2.5rem; }
|
||||
.eye-toggle {
|
||||
position: absolute; right: .75rem; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer; color: var(--text-muted);
|
||||
padding: 0; display: flex; align-items: center;
|
||||
}
|
||||
.eye-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
padding: .6rem 1.25rem; border: none; border-radius: var(--radius);
|
||||
font-family: var(--font); font-size: .9rem; font-weight: 500;
|
||||
cursor: pointer; transition: all .15s; text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-h); }
|
||||
.btn-sky { background: var(--sky); color: #fff; }
|
||||
.btn-green { background: var(--green); color: #fff; }
|
||||
.btn-red { background: var(--red); color: #fff; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-full { width: 100%; justify-content: center; padding: .75rem; }
|
||||
.btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-sm { padding: .35rem .8rem; font-size: .82rem; }
|
||||
.btn-icon { padding: .5rem; border-radius: 8px; }
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────────────── */
|
||||
.alert { padding: .75rem 1rem; border-radius: var(--radius); font-size: .875rem; margin-bottom: 1rem; }
|
||||
.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #fca5a5; }
|
||||
.alert-success { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6ee7b7; }
|
||||
.alert-warning { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.3); color: #fcd34d; }
|
||||
.alert-info { background: rgba(99,102,241,.12); border: 1px solid rgba(99,102,241,.3); color: #a5b4fc; }
|
||||
|
||||
/* ── Panel Layout ────────────────────────────────────────────────────────────── */
|
||||
.panel-layout { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 240px; min-width: 240px; background: var(--bg2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: fixed; height: 100vh; overflow-y: auto; z-index: 100;
|
||||
}
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-brand .logo-text { font-size: 1.1rem; }
|
||||
.sidebar-brand .logo-icon { width: 28px; height: 28px; }
|
||||
|
||||
.sidebar-section { padding: .75rem 0; }
|
||||
.sidebar-section-label {
|
||||
font-size: .7rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--text-muted);
|
||||
padding: .25rem 1.25rem .5rem;
|
||||
}
|
||||
.sidebar-link {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
padding: .55rem 1.25rem; text-decoration: none;
|
||||
color: var(--text-muted); font-size: .88rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all .12s;
|
||||
}
|
||||
.sidebar-link:hover { color: var(--text); background: var(--bg3); }
|
||||
.sidebar-link.active { color: var(--primary); background: rgba(99,102,241,.1); border-left-color: var(--primary); }
|
||||
.sidebar-link svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
.sidebar-user {
|
||||
margin-top: auto; padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-user-info { display: flex; align-items: center; gap: .75rem; }
|
||||
.avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary), var(--sky));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: .9rem; flex-shrink: 0;
|
||||
}
|
||||
.user-name { font-size: .88rem; font-weight: 600; }
|
||||
.user-role { font-size: .75rem; color: var(--text-muted); text-transform: capitalize; }
|
||||
|
||||
.main-content { margin-left: 240px; flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.topbar {
|
||||
background: var(--bg2); border-bottom: 1px solid var(--border);
|
||||
padding: .75rem 1.5rem; display: flex; align-items: center;
|
||||
gap: 1rem; position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.topbar-title { font-size: 1rem; font-weight: 600; flex: 1; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: .5rem; }
|
||||
|
||||
.page-content { padding: 1.5rem; flex: 1; }
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
}
|
||||
.card-title { font-size: .95rem; font-weight: 600; flex: 1; }
|
||||
.card-body { padding: 1.25rem; }
|
||||
|
||||
/* ── Stats Cards ─────────────────────────────────────────────────────────────── */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.stat-card {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 1.25rem;
|
||||
}
|
||||
.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin-bottom: .5rem; }
|
||||
.stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1; }
|
||||
.stat-sub { font-size: .78rem; color: var(--text-muted); margin-top: .3rem; }
|
||||
.stat-green { color: var(--green); }
|
||||
.stat-red { color: var(--red); }
|
||||
.stat-yellow{ color: var(--yellow); }
|
||||
.stat-blue { color: var(--sky); }
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────────── */
|
||||
.progress { background: var(--bg3); border-radius: 999px; height: 6px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; border-radius: 999px; transition: width .3s; }
|
||||
.progress-bar.green { background: var(--green); }
|
||||
.progress-bar.yellow { background: var(--yellow); }
|
||||
.progress-bar.red { background: var(--red); }
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg3); }
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────────────────── */
|
||||
.badge { display: inline-block; padding: .2rem .55rem; border-radius: 999px; font-size: .72rem; font-weight: 600; }
|
||||
.badge-green { background: rgba(16,185,129,.15); color: #6ee7b7; }
|
||||
.badge-red { background: rgba(239,68,68,.15); color: #fca5a5; }
|
||||
.badge-yellow { background: rgba(245,158,11,.15); color: #fcd34d; }
|
||||
.badge-blue { background: rgba(99,102,241,.15); color: #a5b4fc; }
|
||||
.badge-sky { background: rgba(14,165,233,.15); color: #7dd3fc; }
|
||||
.badge-gray { background: rgba(148,163,184,.15); color: #94a3b8; }
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.7); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 14px; width: 100%; max-width: 500px;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.6);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.modal-title { font-size: 1rem; font-weight: 600; flex: 1; }
|
||||
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.25rem; }
|
||||
.modal-body { padding: 1.5rem; }
|
||||
.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
|
||||
.tab-btn {
|
||||
padding: .65rem 1.25rem; border: none; background: none;
|
||||
color: var(--text-muted); font-size: .88rem; cursor: pointer;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
transition: color .12s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
|
||||
/* ── Grid helpers ────────────────────────────────────────────────────────────── */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; }
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { transform: translateX(-100%); transition: transform .2s; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.main-content { margin-left: 0; }
|
||||
.grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Services status ─────────────────────────────────────────────────────────── */
|
||||
.service-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.service-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.service-dot.inactive { background: var(--red); }
|
||||
.service-dot.unknown { background: var(--text-muted); }
|
||||
|
||||
/* ── Code / Terminal ─────────────────────────────────────────────────────────── */
|
||||
code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em; background: var(--bg3); padding: .15em .4em; border-radius: 4px; }
|
||||
.terminal {
|
||||
background: #050508; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 1rem; font-family: monospace; font-size: .82rem; line-height: 1.7;
|
||||
color: #a6e22e; max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ───────────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────────────────────────── */
|
||||
.flex { display: flex; } .items-center { align-items: center; } .justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; } .gap-3 { gap: 1.5rem; }
|
||||
.mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; }
|
||||
.text-muted { color: var(--text-muted); } .text-sm { font-size: .82rem; }
|
||||
.text-right { text-align: right; } .font-bold { font-weight: 700; }
|
||||
.w-full { width: 100%; } .hidden { display: none; }
|
||||
|
||||
/* ── #26 Mobile Responsive Additions ────────────────────────────────────────── */
|
||||
#sidebar-toggle { display: none; }
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.5); z-index: 99;
|
||||
}
|
||||
.sidebar-overlay.open { display: block; }
|
||||
|
||||
/* page-header layout */
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 1.5rem; flex-wrap: wrap; gap: .75rem;
|
||||
}
|
||||
.page-title { font-size: 1.2rem; font-weight: 700; }
|
||||
.page-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* panel utility */
|
||||
.panel {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); margin-bottom: 1.5rem;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: .5rem;
|
||||
}
|
||||
.panel-title { font-size: .95rem; font-weight: 600; }
|
||||
.panel-body { padding: 1.25rem; }
|
||||
|
||||
/* table alias */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.table td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tr:hover td { background: var(--bg3); }
|
||||
|
||||
/* btn variants */
|
||||
.btn-success { background: var(--green); color: #fff; }
|
||||
.btn-success:hover { background: #0da271; }
|
||||
.btn-warning { background: var(--yellow); color: #000; }
|
||||
.btn-warning:hover { background: #d97706; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-secondary { background: var(--bg3); border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-xs { padding: .2rem .55rem; font-size: .75rem; border-radius: 6px; }
|
||||
|
||||
/* badge alias */
|
||||
.badge-muted { background: rgba(148,163,184,.15); color: #94a3b8; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar-toggle { display: flex; }
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform .25s ease;
|
||||
z-index: 200;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
|
||||
.main-content { margin-left: 0; }
|
||||
|
||||
.page-header { flex-direction: column; align-items: flex-start; }
|
||||
.page-actions { width: 100%; }
|
||||
|
||||
.stats-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
|
||||
.modal { max-width: calc(100vw - 2rem); margin: 1rem; }
|
||||
|
||||
.panel-header { flex-direction: column; align-items: flex-start; }
|
||||
|
||||
.topbar { padding: .65rem 1rem; }
|
||||
|
||||
/* hide non-essential table columns on mobile */
|
||||
.table th:nth-child(n+4),
|
||||
.table td:nth-child(n+4) { display: none; }
|
||||
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0d0f17"/>
|
||||
<polygon points="16,3 27,9 27,21 16,27 5,21 5,9" fill="none" stroke="#6366f1" stroke-width="2"/>
|
||||
<circle cx="16" cy="15" r="5" fill="none" stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="2 1.5"/>
|
||||
<circle cx="16" cy="15" r="2.5" fill="#6366f1"/>
|
||||
<circle cx="16" cy="9.5" r="1.2" fill="#6366f1"/>
|
||||
<circle cx="21" cy="18" r="1.2" fill="#0ea5e9"/>
|
||||
<circle cx="11" cy="18" r="1.2" fill="#10b981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 534 B |
@@ -0,0 +1,329 @@
|
||||
<!-- NovaCPX Custom Icon Sprite — inline <use href="#icon-name"/> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
|
||||
<!-- Dashboard / home -->
|
||||
<symbol id="ni-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="9" height="9" rx="1.5"/>
|
||||
<rect x="13" y="3" width="9" height="5" rx="1.5"/>
|
||||
<rect x="13" y="11" width="9" height="9" rx="1.5"/>
|
||||
<rect x="2" y="15" width="9" height="5" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Accounts / users -->
|
||||
<symbol id="ni-accounts" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M2 21c0-4 3-7 7-7h4c4 0 7 3 7 7"/>
|
||||
<circle cx="19" cy="9" r="2.5"/>
|
||||
<path d="M22 19c0-2.5-1.5-4.5-3.5-5.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Resellers -->
|
||||
<symbol id="ni-resellers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12,2 15,8.5 22,9.5 17,14 18.5,21 12,17.5 5.5,21 7,14 2,9.5 9,8.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Packages -->
|
||||
<symbol id="ni-packages" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- DNS -->
|
||||
<symbol id="ni-dns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20A14.5 14.5 0 0 0 12 2"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="12" y1="2" x2="12" y2="22"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Email -->
|
||||
<symbol id="ni-email" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
<line x1="12" y1="13" x2="12" y2="20"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Databases -->
|
||||
<symbol id="ni-databases" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- FTP -->
|
||||
<symbol id="ni-ftp" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="8" height="6" rx="1"/>
|
||||
<rect x="13" y="3" width="8" height="6" rx="1"/>
|
||||
<rect x="3" y="13" width="8" height="6" rx="1"/>
|
||||
<path d="M17 13v4m-2-2h4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- SSL / Lock -->
|
||||
<symbol id="ni-ssl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="11" width="14" height="11" rx="2"/>
|
||||
<path d="M8 11V7a4 4 0 1 1 8 0v4"/>
|
||||
<circle cx="12" cy="16" r="1.5" fill="currentColor" stroke="none"/>
|
||||
<line x1="12" y1="17.5" x2="12" y2="19.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- PHP -->
|
||||
<symbol id="ni-php" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="12" rx="10" ry="6"/>
|
||||
<path d="M9 10v4m0-4h2a1.5 1.5 0 0 1 0 3H9"/>
|
||||
<path d="M14 10v4m0-4h2a1.5 1.5 0 0 1 0 3H14"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Cron / clock -->
|
||||
<symbol id="ni-cron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
<path d="M16 2l2 3M8 2L6 5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- File Manager -->
|
||||
<symbol id="ni-files" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Domains / Web -->
|
||||
<symbol id="ni-domains" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="13" rx="2"/>
|
||||
<line x1="3" y1="7" x2="21" y2="7"/>
|
||||
<line x1="7" y1="21" x2="17" y2="21"/>
|
||||
<line x1="12" y1="16" x2="12" y2="21"/>
|
||||
<circle cx="6" cy="5" r="0.8" fill="currentColor" stroke="none"/>
|
||||
<circle cx="9" cy="5" r="0.8" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Server / System -->
|
||||
<symbol id="ni-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="7" rx="1.5"/>
|
||||
<rect x="2" y="13" width="20" height="7" rx="1.5"/>
|
||||
<circle cx="6" cy="6.5" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<circle cx="6" cy="16.5" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<line x1="10" y1="6.5" x2="16" y2="6.5"/>
|
||||
<line x1="10" y1="16.5" x2="16" y2="16.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Features / Plugin -->
|
||||
<symbol id="ni-features" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 2H9.5a1 1 0 0 0-.9.5l-4 7a1 1 0 0 0 0 1l4 7a1 1 0 0 0 .9.5h5a1 1 0 0 0 .9-.5l4-7a1 1 0 0 0 0-1l-4-7a1 1 0 0 0-.9-.5z"/>
|
||||
<circle cx="12" cy="12" r="2.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Updates / Git -->
|
||||
<symbol id="ni-updates" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
|
||||
<polyline points="12 7 12 12 15 15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Firewall / Shield -->
|
||||
<symbol id="ni-firewall" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="15" x2="12.01" y2="15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Backups -->
|
||||
<symbol id="ni-backups" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="21 15 21 21 15 21"/>
|
||||
<polyline points="3 9 3 3 9 3"/>
|
||||
<path d="M21 3L14 10"/>
|
||||
<path d="M3 21l7-7"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Settings / Gear -->
|
||||
<symbol id="ni-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.5"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Audit Log -->
|
||||
<symbol id="ni-audit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/>
|
||||
<line x1="8" y1="17" x2="12" y2="17"/>
|
||||
<circle cx="16" cy="17" r="2"/>
|
||||
<line x1="18" y1="19" x2="20" y2="21"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Webmail -->
|
||||
<symbol id="ni-webmail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10z"/>
|
||||
<path d="M12 8a4 4 0 0 1 0 8 4 4 0 0 1 0-8z"/>
|
||||
<path d="M16 12h6M2 12h6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- WordPress -->
|
||||
<symbol id="ni-wordpress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2.48 12s3.22 7 9.52 7 9.52-7 9.52-7"/>
|
||||
<path d="M12 2c2.8 2 5 5.8 5 10s-2.2 8-5 10"/>
|
||||
<path d="M12 2c-2.8 2-5 5.8-5 10s2.2 8 5 10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Docker -->
|
||||
<symbol id="ni-docker" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="7" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="12" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="7" y="4" width="4" height="4" rx="0.5"/>
|
||||
<path d="M18 11a4 4 0 0 1 4 4c0 3-3 5-7 5H6c-2 0-4-1.5-4-4 0-1.8 1-3.5 3-4.5"/>
|
||||
<path d="M20 7s-1-1-3-1"/>
|
||||
<circle cx="20" cy="6" r="1" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Node.js -->
|
||||
<symbol id="ni-nodejs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12,2 22,7.5 22,16.5 12,22 2,16.5 2,7.5"/>
|
||||
<path d="M12 7v5l4 2.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Redis -->
|
||||
<symbol id="ni-redis" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="8" rx="9" ry="3"/>
|
||||
<path d="M3 8v4c0 1.66 4.03 3 9 3s9-1.34 9-3V8"/>
|
||||
<path d="M3 12v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
|
||||
<path d="M8 6l2 1 4-2"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Cloudflare -->
|
||||
<symbol id="ni-cloudflare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17.5 16c.83-1.5.5-4-2.5-4.5l.3-1.5C18 9.5 21 11 21 14.5c0 1-.2 2-.8 2.5H17.5z"/>
|
||||
<path d="M6.5 16H17m-3-4.5C12.5 8 8 8 6 10.5c-1 1.5-1 3-0.5 4.5"/>
|
||||
<path d="M3 15c0 .55.45 1 1 1h1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Gitea -->
|
||||
<symbol id="ni-git" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="18" r="3"/>
|
||||
<circle cx="6" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<path d="M6 9v6"/>
|
||||
<path d="M15.7 5.7l-9.4 12.6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Suspend / pause -->
|
||||
<symbol id="ni-suspend" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="10" y1="8" x2="10" y2="16"/>
|
||||
<line x1="14" y1="8" x2="14" y2="16"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Terminate / X -->
|
||||
<symbol id="ni-terminate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Stats / Chart -->
|
||||
<symbol id="ni-stats" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Notifications / bell -->
|
||||
<symbol id="ni-notifications" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Hostname -->
|
||||
<symbol id="ni-hostname" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<line x1="12" y1="15" x2="12" y2="18"/>
|
||||
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Add / Plus -->
|
||||
<symbol id="ni-add" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- User profile -->
|
||||
<symbol id="ni-profile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Logout -->
|
||||
<symbol id="ni-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Search -->
|
||||
<symbol id="ni-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Copy -->
|
||||
<symbol id="ni-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Key / API -->
|
||||
<symbol id="ni-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Mail server -->
|
||||
<symbol id="ni-mailserver" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="6" width="20" height="14" rx="2"/>
|
||||
<path d="M22 6l-10 7L2 6"/>
|
||||
<path d="M2 6l4-4h12l4 4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Docs / book -->
|
||||
<symbol id="ni-docs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
<line x1="8" y1="7" x2="16" y2="7"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Contact / support -->
|
||||
<symbol id="ni-support" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="10" x2="9.01" y2="10"/>
|
||||
<line x1="12" y1="10" x2="12.01" y2="10"/>
|
||||
<line x1="15" y1="10" x2="15.01" y2="10"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ModSecurity / WAF -->
|
||||
<symbol id="ni-waf" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<polyline points="9 12 11 14 15 10"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Varnish / cache -->
|
||||
<symbol id="ni-cache" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Network / IP -->
|
||||
<symbol id="ni-network" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="2" width="6" height="4" rx="1"/>
|
||||
<rect x="2" y="18" width="6" height="4" rx="1"/>
|
||||
<rect x="16" y="18" width="6" height="4" rx="1"/>
|
||||
<rect x="9" y="18" width="6" height="4" rx="1"/>
|
||||
<line x1="12" y1="6" x2="12" y2="14"/>
|
||||
<path d="M5 20v-6h14v6"/>
|
||||
<line x1="12" y1="14" x2="5" y2="14"/>
|
||||
<line x1="12" y1="14" x2="19" y2="14"/>
|
||||
</symbol>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48">
|
||||
<defs>
|
||||
<linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="60%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#10b981"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ng2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Hexagon core mark -->
|
||||
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#ng)" stroke-width="2.5"/>
|
||||
<!-- Inner orbit ring -->
|
||||
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#ng)" stroke-width="1.5" stroke-dasharray="3 2"/>
|
||||
<!-- Central dot -->
|
||||
<circle cx="24" cy="22" r="3" fill="url(#ng)"/>
|
||||
<!-- Orbit node dots -->
|
||||
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
|
||||
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
|
||||
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
|
||||
<!-- Wordmark -->
|
||||
<text x="52" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="700" fill="url(#ng)" letter-spacing="-0.5">Nova</text>
|
||||
<text x="118" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="300" fill="#94a3b8" letter-spacing="2">CPX</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="mg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="60%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#10b981"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#mg)" stroke-width="2.5"/>
|
||||
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#mg)" stroke-width="1.5" stroke-dasharray="3 2"/>
|
||||
<circle cx="24" cy="22" r="3" fill="url(#mg)"/>
|
||||
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
|
||||
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
|
||||
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
@@ -1006,7 +1006,7 @@
|
||||
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
|
||||
Nova.loadingDone();
|
||||
if (res?.success) {
|
||||
window.location.href = res.data?.portal_url || location.origin + "/";
|
||||
window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
|
||||
} else {
|
||||
Nova.toast(res?.message || 'Impersonation failed', 'error');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* NovaCPX Feature Manager
|
||||
* Loaded by admin panel's features page
|
||||
*/
|
||||
window.FeaturesManager = {
|
||||
async load() {
|
||||
const res = await Nova.api('features', 'list');
|
||||
if (!res?.success) return '<p class="text-muted">Failed to load features.</p>';
|
||||
const grouped = res.data;
|
||||
|
||||
const categoryIcons = {
|
||||
'Web Server':'🌐', 'PHP':'⚙️', 'Database':'🗄️', 'Email':'📧',
|
||||
'DNS':'🔍', 'FTP':'📁', 'SSL':'🔒', 'Security':'🛡️', 'Containers':'🐳',
|
||||
'IP Management':'🌍', 'Monitoring':'📊', 'Backup':'💾', 'CDN & Performance':'⚡',
|
||||
'Development':'👨💻', 'One-Click Apps':'🚀', 'Applications':'📦',
|
||||
'Billing':'💳', 'Reseller':'🏪', 'Notifications':'🔔', 'Compliance':'✅',
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 style="font-size:1.1rem;font-weight:700">Feature Manager</h2>
|
||||
<div class="flex gap-1">
|
||||
<input type="text" id="feat-search" placeholder="Search features…" style="width:220px;padding:.45rem .85rem;font-size:.85rem">
|
||||
<select id="feat-cat-filter" style="padding:.45rem .7rem;font-size:.85rem">
|
||||
<option value="">All Categories</option>
|
||||
${Object.keys(grouped).map(c => `<option value="${c}">${c}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="features-container">
|
||||
${Object.entries(grouped).map(([cat, feats]) => `
|
||||
<div class="feat-category" data-cat="${cat}">
|
||||
<div class="flex items-center gap-1 mb-2 mt-3">
|
||||
<span style="font-size:1.1rem">${categoryIcons[cat] || '🔧'}</span>
|
||||
<h3 style="font-size:.9rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)">${cat}</h3>
|
||||
<span class="badge badge-gray" style="margin-left:.5rem">${feats.length}</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.75rem">
|
||||
${feats.map(f => `
|
||||
<div class="feat-card card" data-id="${f.id}" data-name="${f.name.toLowerCase()}" data-cat="${cat}">
|
||||
<div class="card-body" style="padding:1rem">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:.88rem">${f.name}</div>
|
||||
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.15rem;line-height:1.4">${f.description}</div>
|
||||
${f.min_ram_mb > 0 ? `<div style="font-size:.72rem;color:var(--text-muted);margin-top:.2rem">Requires ${f.min_ram_mb}MB RAM</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:.4rem;margin-left:1rem;flex-shrink:0">
|
||||
${f.installed
|
||||
? `<label class="toggle-switch" title="${f.enabled ? 'Disable' : 'Enable'}">
|
||||
<input type="checkbox" ${f.enabled ? 'checked' : ''} onchange="FeaturesManager.toggle(${f.id}, this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>`
|
||||
: `<button class="btn btn-primary btn-sm" onclick="FeaturesManager.install(${f.id})">Install</button>`
|
||||
}
|
||||
${f.installed ? `<span class="badge badge-green" style="font-size:.65rem">Installed</span>` : `<span class="badge badge-gray" style="font-size:.65rem">Not installed</span>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toggle-switch { position:relative; display:inline-block; width:40px; height:22px; }
|
||||
.toggle-switch input { opacity:0; width:0; height:0; }
|
||||
.toggle-slider {
|
||||
position:absolute; cursor:pointer; inset:0;
|
||||
background:var(--border); border-radius:999px; transition:.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content:''; position:absolute; width:16px; height:16px;
|
||||
left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.2s;
|
||||
}
|
||||
input:checked + .toggle-slider { background:var(--primary); }
|
||||
input:checked + .toggle-slider:before { transform:translateX(18px); }
|
||||
</style>`;
|
||||
},
|
||||
|
||||
async toggle(id, enable) {
|
||||
const res = await Nova.api('features', 'toggle', { method: 'POST', body: { id, enable } });
|
||||
if (res?.data?.action === 'install_required') {
|
||||
Nova.confirm(`"${res.data.feature.name}" must be installed first. Install now?`, () => this.install(id));
|
||||
return;
|
||||
}
|
||||
Nova.toast(res?.message || 'Updated', res?.success ? 'success' : 'error');
|
||||
},
|
||||
|
||||
async install(id) {
|
||||
const res = await Nova.api('features', 'install', { method: 'POST', body: { id } });
|
||||
if (!res?.success) { Nova.toast(res?.message || 'Install failed', 'error'); return; }
|
||||
|
||||
const logDiv = `<div class="terminal" id="install-log-${id}" style="min-height:100px">Starting installation…</div>`;
|
||||
const ov = Nova.modal('Installing Feature', logDiv);
|
||||
const poll = setInterval(async () => {
|
||||
const logRes = await Nova.api('features', 'install-log', { params: { id } });
|
||||
const logEl = document.getElementById(`install-log-${id}`);
|
||||
if (logEl) logEl.innerHTML = (logRes?.data?.log || '').split('\n').map(l => `<div>${l}</div>`).join('');
|
||||
if (!logRes?.data?.running) {
|
||||
clearInterval(poll);
|
||||
if (logRes?.data?.installed) {
|
||||
Nova.toast('Feature installed successfully', 'success');
|
||||
ov.remove();
|
||||
document.getElementById('page-content').innerHTML = await this.load();
|
||||
this.bindSearch();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
bindSearch() {
|
||||
const search = document.getElementById('feat-search');
|
||||
const catFilter = document.getElementById('feat-cat-filter');
|
||||
if (!search || !catFilter) return;
|
||||
|
||||
const filter = () => {
|
||||
const q = search.value.toLowerCase();
|
||||
const cat = catFilter.value;
|
||||
document.querySelectorAll('.feat-card').forEach(c => {
|
||||
const match = (!q || c.dataset.name.includes(q)) && (!cat || c.dataset.cat === cat);
|
||||
c.closest('div').style.display = match ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.feat-category').forEach(sec => {
|
||||
const visible = [...sec.querySelectorAll('.feat-card')].some(c => c.closest('div').style.display !== 'none');
|
||||
sec.style.display = visible ? '' : 'none';
|
||||
});
|
||||
};
|
||||
search.addEventListener('input', filter);
|
||||
catFilter.addEventListener('change', filter);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* NovaCPX — Shared JS utilities
|
||||
*/
|
||||
|
||||
window.Nova = (() => {
|
||||
// ── Activity bar (thin top-of-page progress stripe for every API call) ────
|
||||
let _barEl = null, _barPct = 0, _barTimer = null, _barActive = 0;
|
||||
function _barShow() {
|
||||
_barActive++;
|
||||
if (!_barEl) {
|
||||
_barEl = document.createElement('div');
|
||||
_barEl.style.cssText = [
|
||||
'position:fixed;top:0;left:0;z-index:999999',
|
||||
'height:3px;width:0%;background:var(--primary,#6366f1)',
|
||||
'transition:width .2s ease,opacity .3s ease',
|
||||
'box-shadow:0 0 8px var(--primary,#6366f1)',
|
||||
'pointer-events:none',
|
||||
].join(';');
|
||||
document.body.appendChild(_barEl);
|
||||
}
|
||||
_barEl.style.opacity = '1';
|
||||
_barPct = 10;
|
||||
_barEl.style.width = _barPct + '%';
|
||||
clearInterval(_barTimer);
|
||||
_barTimer = setInterval(() => {
|
||||
if (_barEl && _barPct < 85) { _barPct += (_barPct < 50 ? 8 : _barPct < 70 ? 4 : 1); _barEl.style.width = _barPct + '%'; }
|
||||
}, 200);
|
||||
}
|
||||
function _barDone() {
|
||||
_barActive = Math.max(0, _barActive - 1);
|
||||
if (_barActive > 0) return;
|
||||
clearInterval(_barTimer);
|
||||
if (_barEl) {
|
||||
_barEl.style.width = '100%';
|
||||
setTimeout(() => { if (_barEl) { _barEl.style.opacity = '0'; setTimeout(() => { _barEl?.remove(); _barEl = null; }, 300); } }, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────
|
||||
async function api(endpoint, action, opts = {}) {
|
||||
const { method = 'GET', body, params } = opts;
|
||||
let url = `/api/${endpoint}/${action}`;
|
||||
if (params) url += '?' + new URLSearchParams(params);
|
||||
_barShow();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
_barDone();
|
||||
console.error(`Nova.api network error [${endpoint}/${action}]:`, e);
|
||||
return { success: false, message: 'Network error — check your connection' };
|
||||
}
|
||||
_barDone();
|
||||
if (res.status === 401) { return { success: false, message: 'Session expired — please log in again' }; }
|
||||
if (res.status === 429) {
|
||||
const reset = res.headers.get('X-RateLimit-Reset');
|
||||
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
||||
return { success: false, message: `Rate limited — try again in ${wait}s` };
|
||||
}
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
console.error(`Nova.api non-JSON from [${endpoint}/${action}] (HTTP ${res.status}):`, text.slice(0, 500));
|
||||
return { success: false, message: `Server error (HTTP ${res.status}) — see browser console` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────
|
||||
let toastEl = null;
|
||||
function toast(msg, type = 'info', duration = 3500) {
|
||||
if (!toastEl) {
|
||||
toastEl = document.createElement('div');
|
||||
toastEl.style.cssText = 'position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;max-width:380px';
|
||||
document.body.appendChild(toastEl);
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = `alert alert-${type}`;
|
||||
el.style.cssText = 'animation:fadeIn .2s;cursor:pointer;box-shadow:var(--shadow)';
|
||||
el.textContent = msg;
|
||||
el.addEventListener('click', () => el.remove());
|
||||
toastEl.appendChild(el);
|
||||
setTimeout(() => el.remove(), duration);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────
|
||||
function modal(title, bodyHtml, footerHtml = '') {
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'modal-overlay open';
|
||||
ov.innerHTML = `<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">${title}</span>
|
||||
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">${bodyHtml}</div>
|
||||
${footerHtml ? `<div class="modal-footer">${footerHtml}</div>` : ''}
|
||||
</div>`;
|
||||
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
|
||||
document.body.appendChild(ov);
|
||||
return ov;
|
||||
}
|
||||
|
||||
// ── Confirm dialog ────────────────────────────────────────────────────────
|
||||
function confirm(msg, onYes, danger = false) {
|
||||
const ov = modal('Confirm', `<p>${msg}</p>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-${danger ? 'red' : 'primary'}" id="confirm-yes">Confirm</button>`
|
||||
);
|
||||
ov.querySelector('#confirm-yes').onclick = () => { ov.remove(); onYes(); };
|
||||
}
|
||||
|
||||
// ── Sidebar navigation ────────────────────────────────────────────────────
|
||||
function initNav(pages) {
|
||||
document.querySelectorAll('[data-page]').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const page = link.dataset.page;
|
||||
document.querySelectorAll('[data-page]').forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl) titleEl.textContent = link.textContent.trim();
|
||||
loadPage(page, pages);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadPage(page, pages) {
|
||||
const content = document.getElementById('page-content');
|
||||
if (!content) return;
|
||||
const fn = pages[page];
|
||||
if (fn) {
|
||||
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
|
||||
Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; });
|
||||
} else {
|
||||
content.innerHTML = `<div class="card"><div class="card-body"><p class="text-muted">Page "${page}" coming soon.</p></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress bar helper ───────────────────────────────────────────────────
|
||||
function progressBar(pct) {
|
||||
const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green';
|
||||
return `<div class="progress"><div class="progress-bar ${color}" style="width:${pct}%"></div></div>`;
|
||||
}
|
||||
|
||||
// ── Format helpers ────────────────────────────────────────────────────────
|
||||
function bytes(n) {
|
||||
if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB';
|
||||
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
|
||||
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return n + ' B';
|
||||
}
|
||||
function relTime(dateStr) {
|
||||
const diff = (Date.now() - new Date(dateStr)) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
function badge(text, type = 'blue') {
|
||||
return `<span class="badge badge-${type}">${text}</span>`;
|
||||
}
|
||||
function serviceDot(status) {
|
||||
const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown';
|
||||
return `<span class="service-dot ${cls}"></span>`;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// ── Loading overlay ───────────────────────────────────────────────────────
|
||||
let _loadingEl = null;
|
||||
let _loadingCount = 0;
|
||||
function loading(msg = 'Working…') {
|
||||
_loadingCount++;
|
||||
if (!_loadingEl) {
|
||||
_loadingEl = document.createElement('div');
|
||||
_loadingEl.id = 'nova-loading-overlay';
|
||||
_loadingEl.style.cssText = [
|
||||
'position:fixed;inset:0;z-index:99999',
|
||||
'background:rgba(0,0,0,.55)',
|
||||
'display:flex;flex-direction:column;align-items:center;justify-content:center',
|
||||
'gap:1rem;animation:fadeIn .15s',
|
||||
].join(';');
|
||||
_loadingEl.innerHTML = `
|
||||
<div style="width:48px;height:48px;border:4px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;animation:ncpxSpin 0.7s linear infinite"></div>
|
||||
<div id="nova-loading-msg" style="color:#fff;font-size:1rem;font-weight:500;text-shadow:0 1px 3px rgba(0,0,0,.6)">${escHtml(msg)}</div>`;
|
||||
document.body.appendChild(_loadingEl);
|
||||
} else {
|
||||
document.getElementById('nova-loading-msg').textContent = msg;
|
||||
}
|
||||
}
|
||||
function loadingDone() {
|
||||
_loadingCount = Math.max(0, _loadingCount - 1);
|
||||
if (_loadingCount === 0 && _loadingEl) {
|
||||
_loadingEl.remove();
|
||||
_loadingEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject global CSS animations
|
||||
const style = document.createElement('style');
|
||||
style.textContent = [
|
||||
'@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}',
|
||||
'@keyframes ncpxSpin{to{transform:rotate(360deg)}}',
|
||||
].join('');
|
||||
document.head.appendChild(style);
|
||||
|
||||
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone };
|
||||
})();
|
||||
|
||||
// #26 Mobile sidebar toggle — shared across all panels
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggle = document.getElementById('sidebar-toggle');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
if (!toggle || !sidebar) return;
|
||||
|
||||
const open = () => { sidebar.classList.add('open'); overlay?.classList.add('open'); document.body.style.overflow = 'hidden'; };
|
||||
const close = () => { sidebar.classList.remove('open'); overlay?.classList.remove('open'); document.body.style.overflow = ''; };
|
||||
|
||||
toggle.addEventListener('click', () => sidebar.classList.contains('open') ? close() : open());
|
||||
overlay?.addEventListener('click', close);
|
||||
|
||||
// Close when a nav link is clicked on mobile
|
||||
sidebar.querySelectorAll('.sidebar-link').forEach(link =>
|
||||
link.addEventListener('click', () => { if (window.innerWidth <= 768) close(); })
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* NovaCPX Reseller Panel JS
|
||||
*/
|
||||
|
||||
let _rUser = null;
|
||||
|
||||
async function initReseller() {
|
||||
const res = await Nova.api('auth', 'me');
|
||||
if (!res?.success || !['admin','reseller'].includes(res.data?.role)) {
|
||||
document.getElementById('auth-check').innerHTML = renderLogin();
|
||||
document.getElementById('main-layout').style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
_rUser = res.data;
|
||||
document.getElementById('user-name').textContent = _rUser.username || 'Reseller';
|
||||
document.getElementById('auth-check').style.display = 'none';
|
||||
document.getElementById('main-layout').style.display = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
return `<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div style="text-align:center;margin-bottom:2rem">
|
||||
<img src="/assets/img/nova-logo.svg" style="height:42px;margin-bottom:.5rem">
|
||||
<div style="color:var(--muted);font-size:.85rem">Reseller Portal · Port 8881</div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="li-user" type="text" class="form-control" autocomplete="username"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="li-pass" type="password" class="form-control" autocomplete="current-password"></div>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Sign In</button>
|
||||
<div id="li-err" style="color:var(--red);margin-top:.75rem;text-align:center;display:none"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
Nova.loading('Signing in…');
|
||||
const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }});
|
||||
Nova.loadingDone();
|
||||
if (res?.success) {
|
||||
if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url;
|
||||
else location.reload();
|
||||
} else {
|
||||
const err = document.getElementById('li-err');
|
||||
if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; }
|
||||
}
|
||||
}
|
||||
window.doLogin = doLogin;
|
||||
|
||||
/* ── Pages ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function rDashboard(el) {
|
||||
el.innerHTML = `<div class="page-header"><h2 class="page-title">Reseller Dashboard</h2></div>
|
||||
<div id="r-stats" class="stats-grid"><div class="loading">Loading…</div></div>
|
||||
<div style="margin-top:1.5rem" class="card">
|
||||
<div class="card-header"><span class="card-title">Recent Accounts</span>
|
||||
<button class="btn btn-sm btn-primary" onclick="resellerNav('accounts')">View All</button>
|
||||
</div>
|
||||
<div id="r-recent"><div class="loading">Loading…</div></div>
|
||||
</div>`;
|
||||
|
||||
const res = await Nova.api('accounts', 'list', { params:{ limit:5 }});
|
||||
const accts = res?.data || [];
|
||||
|
||||
document.getElementById('r-stats').innerHTML = [
|
||||
{ label: 'Total Accounts', val: res?.meta?.total || accts.length, icon: 'ni-accounts' },
|
||||
{ label: 'Active', val: accts.filter(a=>a.status==='active').length, icon: 'ni-stats' },
|
||||
{ label: 'Suspended', val: accts.filter(a=>a.status==='suspended').length, icon: 'ni-suspend' },
|
||||
].map(s => `<div class="stat-card" style="display:flex;align-items:center;gap:1rem">
|
||||
<svg width="32" height="32" style="color:var(--primary);flex-shrink:0"><use href="/assets/img/nova-icons.svg#${s.icon}"/></svg>
|
||||
<div><div class="stat-value">${s.val}</div><div class="stat-label">${s.label}</div></div>
|
||||
</div>`).join('');
|
||||
|
||||
document.getElementById('r-recent').innerHTML = accts.length
|
||||
? `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Status</th></tr></thead><tbody>
|
||||
${accts.map(a => `<tr>
|
||||
<td>${a.username}</td><td>${a.domain}</td><td>${a.package_name||'—'}</td>
|
||||
<td>${Nova.badge(a.status, a.status==='active'?'green':'yellow')}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
: '<div class="empty">No accounts yet.</div>';
|
||||
}
|
||||
|
||||
async function rAccounts(el) {
|
||||
el.innerHTML = `<div class="page-header">
|
||||
<h2 class="page-title">Hosting Accounts</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="resellerNav('createAccount')">+ Create Account</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="padding:.75rem;border-bottom:1px solid var(--border)">
|
||||
<input id="r-search" class="form-control" placeholder="Search accounts…" oninput="rSearchAccounts(this.value)" style="max-width:300px">
|
||||
</div>
|
||||
<div id="r-accounts-list"><div class="loading">Loading…</div></div>
|
||||
</div>`;
|
||||
loadRAccounts();
|
||||
}
|
||||
|
||||
async function loadRAccounts(search = '') {
|
||||
const el = document.getElementById('r-accounts-list');
|
||||
if (!el) return;
|
||||
const res = await Nova.api('accounts', 'list', { params: search ? { search } : {}});
|
||||
const acctRows = res?.data || [];
|
||||
if (!res?.success || !acctRows.length) { el.innerHTML = '<div class="empty">No accounts found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Disk</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${acctRows.map(a => `<tr>
|
||||
<td><strong>${Nova.escHtml(a.username)}</strong></td>
|
||||
<td>${Nova.escHtml(a.domain)}</td>
|
||||
<td>${a.package_name ? Nova.escHtml(a.package_name) : '—'}</td>
|
||||
<td>${a.disk_usage_mb || 0} MB</td>
|
||||
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
||||
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
||||
<button class="btn btn-xs btn-primary" onclick="rLoginAs(${a.user_id},'${Nova.escHtml(a.username)}')">Login As</button>
|
||||
${a.status === 'active'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="rSuspend(${a.id},'${a.username}')">Suspend</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="rUnsuspend(${a.id},'${a.username}')">Unsuspend</button>`}
|
||||
<button class="btn btn-xs" onclick="rChangePass(${a.id},'${a.username}')">Passwd</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="rTerminate(${a.id},'${a.username}')">Terminate</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
window.loadRAccounts = loadRAccounts;
|
||||
window.rSearchAccounts = (v) => loadRAccounts(v);
|
||||
|
||||
window.rLoginAs = async (userId, username) => {
|
||||
Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => {
|
||||
Nova.loading(`Switching to ${username}…`);
|
||||
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
|
||||
Nova.loadingDone();
|
||||
if (res?.success) {
|
||||
window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
|
||||
} else {
|
||||
Nova.toast(res?.message || 'Impersonation failed', 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.rSuspend = async (id, user) => {
|
||||
Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => {
|
||||
const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }});
|
||||
if (res?.success) { Nova.toast('Account suspended','success'); loadRAccounts(); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
});
|
||||
};
|
||||
window.rUnsuspend = async (id, user) => {
|
||||
const res = await Nova.api('accounts', 'unsuspend', { method:'POST', body:{ account_id: id }});
|
||||
if (res?.success) { Nova.toast('Account unsuspended','success'); loadRAccounts(); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
};
|
||||
window.rTerminate = (id, user) => {
|
||||
Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, databases, DNS, and email. THIS CANNOT BE UNDONE.`, async () => {
|
||||
const res = await Nova.api('accounts', 'terminate', { method:'POST', body:{ account_id: id }});
|
||||
if (res?.success) { Nova.toast('Account terminated','success'); loadRAccounts(); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
}, true);
|
||||
};
|
||||
window.rChangePass = (id, user) => {
|
||||
Nova.modal(`Change Password — ${user}`, `<div class="form-group"><label class="form-label">New Password</label><input id="rp-pass" type="password" class="form-control"></div>`,
|
||||
`<button class="btn btn-primary" onclick="Nova.api('accounts','change-password',{method:'POST',body:{account_id:${id},password:document.getElementById('rp-pass').value}}).then(r=>{if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Update</button>`);
|
||||
};
|
||||
|
||||
async function rCreateAccount(el) {
|
||||
el.innerHTML = `<div class="page-header"><h2 class="page-title">Create Hosting Account</h2></div>
|
||||
<div class="card" style="max-width:600px">
|
||||
<div style="padding:1.5rem">
|
||||
<div class="form-group"><label class="form-label">Username <span style="color:var(--red)">*</span></label><input id="ca-user" class="form-control" placeholder="lowercase letters, numbers"></div>
|
||||
<div class="form-group"><label class="form-label">Password <span style="color:var(--red)">*</span></label><input id="ca-pass" type="password" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ca-email" type="email" class="form-control" placeholder="user@example.com"></div>
|
||||
<div class="form-group"><label class="form-label">Primary Domain <span style="color:var(--red)">*</span></label><input id="ca-domain" class="form-control" placeholder="example.com"></div>
|
||||
<div class="form-group"><label class="form-label">Package</label><select id="ca-pkg" class="form-control"><option value="">Loading…</option></select></div>
|
||||
<div style="margin-top:1.5rem;display:flex;gap:.75rem">
|
||||
<button class="btn btn-primary" onclick="submitCreateAccount()">Create Account</button>
|
||||
<button class="btn" onclick="resellerNav('accounts')">Cancel</button>
|
||||
</div>
|
||||
<div id="ca-result" style="margin-top:1rem"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
Nova.api('packages', 'list').then(res => {
|
||||
const sel = document.getElementById('ca-pkg');
|
||||
if (sel && res?.success) {
|
||||
sel.innerHTML = res.data.map(p => `<option value="${p.id}">${p.name} — ${p.disk_mb}MB disk</option>`).join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.submitCreateAccount = async () => {
|
||||
const btn = document.querySelector('#ca-result');
|
||||
if (btn) btn.textContent = '';
|
||||
Nova.loading('Creating hosting account…');
|
||||
const res = await Nova.api('accounts', 'create', { method:'POST', body:{
|
||||
username: document.getElementById('ca-user')?.value,
|
||||
password: document.getElementById('ca-pass')?.value,
|
||||
email: document.getElementById('ca-email')?.value,
|
||||
domain: document.getElementById('ca-domain')?.value,
|
||||
package_id: document.getElementById('ca-pkg')?.value,
|
||||
}});
|
||||
Nova.loadingDone();
|
||||
if (res?.success) {
|
||||
Nova.toast('Account created successfully!','success');
|
||||
if (btn) btn.innerHTML = `<div class="alert alert-success">Account created! <a href="#" onclick="resellerNav('accounts')">View accounts →</a></div>`;
|
||||
} else {
|
||||
Nova.toast(res?.message || 'Failed to create account','error');
|
||||
if (btn) btn.innerHTML = `<div class="alert alert-error">${res?.message || 'Error'}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
async function rPackages(el) {
|
||||
el.innerHTML = `<div class="page-header">
|
||||
<h2 class="page-title">Packages</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="rAddPackage()">+ Add Package</button>
|
||||
</div>
|
||||
<div class="card"><div id="pkg-list"><div class="loading">Loading…</div></div></div>`;
|
||||
|
||||
const res = await Nova.api('packages', 'list');
|
||||
const plist = document.getElementById('pkg-list');
|
||||
if (!res?.success || !res.data.length) { plist.innerHTML = '<div class="empty">No packages yet.</div>'; return; }
|
||||
plist.innerHTML = `<table class="table"><thead><tr><th>Name</th><th>Disk</th><th>BW</th><th>DBs</th><th>Emails</th><th>Domains</th><th>Price</th><th>Actions</th></tr></thead><tbody>
|
||||
${res.data.map(p => `<tr>
|
||||
<td><strong>${p.name}</strong></td>
|
||||
<td>${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'}</td>
|
||||
<td>${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'}</td>
|
||||
<td>${p.databases || '∞'}</td>
|
||||
<td>${p.email_accounts || '∞'}</td>
|
||||
<td>${p.addon_domains || '∞'}</td>
|
||||
<td>${p.price ? '$'+p.price : 'Free'}</td>
|
||||
<td style="display:flex;gap:.25rem">
|
||||
<button class="btn btn-xs" onclick="rEditPackage(${p.id})">Edit</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="rDeletePackage(${p.id},'${p.name}')">Del</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
window.rAddPackage = () => showPackageModal();
|
||||
window.rEditPackage = async (id) => {
|
||||
const res = await Nova.api('packages', 'get', { params:{ id }});
|
||||
if (res?.success) showPackageModal(res.data);
|
||||
};
|
||||
function showPackageModal(pkg = null) {
|
||||
const p = pkg || {};
|
||||
Nova.modal(pkg ? 'Edit Package' : 'Add Package', `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||
<div class="form-group" style="grid-column:1/-1"><label class="form-label">Package Name</label><input id="pk-name" class="form-control" value="${p.name||''}"></div>
|
||||
<div class="form-group"><label class="form-label">Disk (MB, 0=∞)</label><input id="pk-disk" type="number" class="form-control" value="${p.disk_mb||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Bandwidth (MB)</label><input id="pk-bw" type="number" class="form-control" value="${p.bandwidth_mb||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Databases</label><input id="pk-db" type="number" class="form-control" value="${p.databases||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Email Accounts</label><input id="pk-email" type="number" class="form-control" value="${p.email_accounts||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Addon Domains</label><input id="pk-adom" type="number" class="form-control" value="${p.addon_domains||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Subdomains</label><input id="pk-sub" type="number" class="form-control" value="${p.subdomains||0}"></div>
|
||||
<div class="form-group"><label class="form-label">FTP Accounts</label><input id="pk-ftp" type="number" class="form-control" value="${p.ftp_accounts||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Price ($/mo)</label><input id="pk-price" type="number" step="0.01" class="form-control" value="${p.price||0}"></div>
|
||||
</div>`,
|
||||
`<button class="btn btn-primary" onclick="submitPackage(${p.id||'null'})">Save</button>`);
|
||||
}
|
||||
window.submitPackage = async (id) => {
|
||||
const body = { name:document.getElementById('pk-name')?.value, disk_mb:parseInt(document.getElementById('pk-disk')?.value), bandwidth_mb:parseInt(document.getElementById('pk-bw')?.value), databases:parseInt(document.getElementById('pk-db')?.value), email_accounts:parseInt(document.getElementById('pk-email')?.value), addon_domains:parseInt(document.getElementById('pk-adom')?.value), subdomains:parseInt(document.getElementById('pk-sub')?.value), ftp_accounts:parseInt(document.getElementById('pk-ftp')?.value), price:parseFloat(document.getElementById('pk-price')?.value) };
|
||||
const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body});
|
||||
if (res?.success) { Nova.toast(id ? 'Package updated' : 'Package created','success'); document.querySelector('.modal-overlay')?.remove(); rPackages(document.getElementById('page-content')); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
};
|
||||
window.rDeletePackage = (id, name) => {
|
||||
Nova.confirm(`Delete package "${name}"? Cannot delete if accounts are using it.`, async () => {
|
||||
const res = await Nova.api('packages','delete',{method:'POST',body:{id}});
|
||||
if (res?.success) { Nova.toast('Deleted','success'); rPackages(document.getElementById('page-content')); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
async function rDNS(el) {
|
||||
el.innerHTML = `<div class="page-header"><h2 class="page-title">DNS Zones</h2></div>
|
||||
<div class="card"><div id="r-dns-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const res = await Nova.api('dns', 'zones');
|
||||
const list = document.getElementById('r-dns-list');
|
||||
if (!res?.success || !res.data.length) { list.innerHTML = '<div class="empty">No DNS zones.</div>'; return; }
|
||||
list.innerHTML = `<table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Records</th><th>Actions</th></tr></thead><tbody>
|
||||
${res.data.map(z => `<tr>
|
||||
<td>${z.domain}</td>
|
||||
<td>${z.username||'—'}</td>
|
||||
<td>${z.record_count||0}</td>
|
||||
<td><button class="btn btn-xs" onclick="rViewZone(${z.id},'${z.domain}')">Edit Records</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
window.rViewZone = async (zoneId, domain) => {
|
||||
const res = await Nova.api('dns', 'records', { params:{ zone_id: zoneId }});
|
||||
if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
|
||||
const rows = res.data.map(r => `<tr>
|
||||
<td>${r.name}</td><td>${Nova.badge(r.type,'default')}</td><td><code style="font-size:.8rem">${r.value}</code></td><td>${r.ttl}</td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="rDeleteRecord(${r.id},${zoneId},'${domain}')">Del</button></td>
|
||||
</tr>`).join('');
|
||||
Nova.modal(`DNS Records — ${domain}`,
|
||||
`<button class="btn btn-sm btn-primary" style="margin-bottom:.75rem" onclick="rAddRecord(${zoneId},'${domain}')">+ Add Record</button>
|
||||
<table class="table"><thead><tr><th>Name</th><th>Type</th><th>Value</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`);
|
||||
};
|
||||
window.rAddRecord = (zoneId, domain) => {
|
||||
Nova.modal('Add DNS Record', `
|
||||
<div class="form-group"><label class="form-label">Name</label><input id="dns-name" class="form-control" placeholder="@ or subdomain"></div>
|
||||
<div class="form-group"><label class="form-label">Type</label><select id="dns-type" class="form-control"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>NS</option><option>SRV</option></select></div>
|
||||
<div class="form-group"><label class="form-label">Value</label><input id="dns-val" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">TTL</label><input id="dns-ttl" type="number" class="form-control" value="3600"></div>
|
||||
<div class="form-group"><label class="form-label">Priority (MX)</label><input id="dns-pri" type="number" class="form-control" value="10"></div>`,
|
||||
`<button class="btn btn-primary" onclick="Nova.api('dns','add-record',{method:'POST',body:{zone_id:${zoneId},name:document.getElementById('dns-name').value,type:document.getElementById('dns-type').value,value:document.getElementById('dns-val').value,ttl:parseInt(document.getElementById('dns-ttl').value),priority:parseInt(document.getElementById('dns-pri').value)}}).then(r=>{if(r?.success){Nova.toast('Record added','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Add Record</button>`);
|
||||
};
|
||||
window.rDeleteRecord = async (id, zoneId, domain) => {
|
||||
Nova.confirm('Delete this DNS record?', async () => {
|
||||
const res = await Nova.api('dns', 'delete-record', { method:'POST', body:{id, zone_id: zoneId }});
|
||||
if (res?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); rViewZone(zoneId, domain); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
/* ── Nav ────────────────────────────────────────────────────────────────── */
|
||||
const rNavGroups = [
|
||||
{ label: 'Overview', items: [
|
||||
{ id: 'dashboard', label: 'Dashboard',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>' },
|
||||
]},
|
||||
{ label: 'Accounts', items: [
|
||||
{ id: 'accounts', label: 'All Accounts',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
|
||||
{ id: 'createAccount', label: 'New Account',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="17" y1="11" x2="23" y2="11"/></svg>' },
|
||||
{ id: 'packages', label: 'Packages',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>' },
|
||||
]},
|
||||
{ label: 'DNS', items: [
|
||||
{ id: 'dns', label: 'DNS Zones',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>' },
|
||||
]},
|
||||
{ label: 'Tools', items: [
|
||||
{ id: 'docker', label: 'Docker',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
|
||||
{ id: 'whitelabel', label: 'White Label',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>' },
|
||||
]},
|
||||
];
|
||||
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel };
|
||||
|
||||
let _rActivePage = 'dashboard';
|
||||
|
||||
function renderRNav() {
|
||||
const nav = document.getElementById('sidebar-nav');
|
||||
if (!nav) return;
|
||||
nav.innerHTML = rNavGroups.map(g => `
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">${g.label}</div>
|
||||
${g.items.map(n => `
|
||||
<a href="#" class="sidebar-link${n.id === _rActivePage ? ' active' : ''}" data-page="${n.id}">
|
||||
${n.svg}
|
||||
${n.label}
|
||||
</a>`).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
nav.querySelectorAll('[data-page]').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar')?.classList.remove('open');
|
||||
document.getElementById('sidebar-overlay')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
resellerNav(link.dataset.page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.resellerNav = (page) => {
|
||||
_rActivePage = page;
|
||||
renderRNav();
|
||||
const allItems = rNavGroups.flatMap(g => g.items);
|
||||
const item = allItems.find(n => n.id === page);
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl && item) titleEl.textContent = item.label;
|
||||
const content = document.getElementById('page-content');
|
||||
if (!content) return;
|
||||
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
|
||||
if (rPages[page]) rPages[page](content);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ok = await initReseller();
|
||||
if (!ok) return;
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
await Nova.api('auth', 'logout', { method: 'POST' });
|
||||
location.href = '/';
|
||||
});
|
||||
renderRNav();
|
||||
window.resellerNav('dashboard');
|
||||
});
|
||||
|
||||
/* ── Docker (Reseller #33) ────────────────────────────────────────────────── */
|
||||
async function rDocker(el) {
|
||||
el.innerHTML = '<div class="loading">Loading…</div>';
|
||||
const [stRes, acctRes] = await Promise.all([
|
||||
Nova.api('docker', 'stacks'),
|
||||
Nova.api('accounts', 'list', { params: { limit: 200 } }),
|
||||
]);
|
||||
const stacks = stRes?.data?.stacks || [];
|
||||
const accts = acctRes?.data || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
||||
<p class="text-muted" style="margin-bottom:1.5rem">Manage Docker containers and quotas for your customers. Contact the server admin to change your own Docker allocation.</p>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<button class="btn btn-sm ${_rDockerTab==='containers'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('containers')">Containers</button>
|
||||
<button class="btn btn-sm ${_rDockerTab==='quotas'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('quotas')">Customer Quotas</button>
|
||||
<button class="btn btn-sm ${_rDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('catalog')">App Catalog</button>
|
||||
</div>
|
||||
<div id="rdocker-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
window._rDockerAccts = accts;
|
||||
window._rDockerTab = window._rDockerTab || 'containers';
|
||||
|
||||
window.rDockerTab = async (tab) => {
|
||||
window._rDockerTab = tab;
|
||||
document.querySelectorAll('[onclick^="rDockerTab"]').forEach(b => {
|
||||
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
|
||||
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
|
||||
});
|
||||
await rDockerLoadTab(tab);
|
||||
};
|
||||
|
||||
await rDockerLoadTab(window._rDockerTab);
|
||||
}
|
||||
|
||||
window._rDockerTab = 'containers';
|
||||
|
||||
async function rDockerLoadTab(tab) {
|
||||
const tc = document.getElementById('rdocker-content');
|
||||
if (!tc) return;
|
||||
tc.innerHTML = '<div class="loading">Loading…</div>';
|
||||
|
||||
if (tab === 'containers') {
|
||||
const r = await Nova.api('docker', 'containers');
|
||||
const rows = r?.data?.containers || [];
|
||||
tc.innerHTML = rows.length === 0
|
||||
? '<div class="text-muted" style="padding:2rem;text-align:center">No containers for your accounts</div>'
|
||||
: `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.map(c=>`<tr>
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||||
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
|
||||
<td>${Nova.badge(c.status,c.status==='running'?'green':'red')}</td>
|
||||
<td>${c.account_id||'—'}</td>
|
||||
<td>
|
||||
${c.status==='running'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="rDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
|
||||
} else if (tab === 'quotas') {
|
||||
const accts = window._rDockerAccts || [];
|
||||
tc.innerHTML = accts.length === 0
|
||||
? '<div class="text-muted" style="padding:2rem;text-align:center">No accounts</div>'
|
||||
: `<p class="text-muted" style="margin-bottom:1rem">Set Docker limits for each of your customers.</p>
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
||||
${accts.map(u=>`<tr>
|
||||
<td>${Nova.escHtml(u.username)}</td>
|
||||
<td>2</td><td>512 MB</td><td>1.0</td>
|
||||
<td><button class="btn btn-xs btn-primary" onclick="rDockerQuotaModal(${u.user_id},'${Nova.escHtml(u.username)}')">Edit</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
|
||||
} else if (tab === 'catalog') {
|
||||
const r = await Nova.api('docker', 'catalog');
|
||||
const catalog = r?.data?.catalog || {};
|
||||
const accts = window._rDockerAccts || [];
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">Pre-install app stacks for your customers.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem">
|
||||
${Object.entries(catalog).map(([key,app])=>`
|
||||
<div class="card" style="cursor:pointer" onclick="rDockerLaunchModal('${key}','${Nova.escHtml(app.name)}')">
|
||||
<div class="card-body" style="text-align:center;padding:1.5rem">
|
||||
<div style="font-size:1.5rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
||||
<div style="font-weight:600">${Nova.escHtml(app.name)}</div>
|
||||
<div style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">${Nova.escHtml(app.description)}</div>
|
||||
<button class="btn btn-sm btn-primary" style="margin-top:1rem">Deploy</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.rDockerAct = async (cid, action) => {
|
||||
Nova.loading(`${action.charAt(0).toUpperCase()+action.slice(1)}ing container…`);
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) rDockerLoadTab('containers');
|
||||
};
|
||||
|
||||
window.rDockerLogs = async (cid, name) => {
|
||||
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } });
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.logs||'')}</pre>`);
|
||||
};
|
||||
|
||||
window.rDockerQuotaModal = (userId, username) => {
|
||||
const ov = Nova.modal(`Docker Quota: ${username}`,
|
||||
`<div class="form-group"><label>Max Containers</label><input id="rdq-cnt" type="number" class="form-control" value="2" min="0"></div>
|
||||
<div class="form-group"><label>Max Memory (MB)</label><input id="rdq-mem" type="number" class="form-control" value="512" min="64"></div>
|
||||
<div class="form-group"><label>Max CPUs</label><input id="rdq-cpus" type="number" step="0.5" class="form-control" value="1.0" min="0.1"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="rDockerQuotaSubmit(${userId})">Save</button>`
|
||||
);
|
||||
window.rDockerQuotaSubmit = async (uid) => {
|
||||
ov.remove();
|
||||
const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{
|
||||
user_id: uid,
|
||||
max_containers: parseInt(document.getElementById('rdq-cnt').value)||2,
|
||||
max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512,
|
||||
max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0,
|
||||
}});
|
||||
Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error');
|
||||
};
|
||||
};
|
||||
|
||||
window.rDockerLaunchModal = async (appKey, appName) => {
|
||||
const catRes = await Nova.api('docker', 'catalog');
|
||||
const app = catRes?.data?.catalog?.[appKey];
|
||||
if (!app) return;
|
||||
const accts = window._rDockerAccts || [];
|
||||
const acctOpts = accts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)}</option>`).join('');
|
||||
const paramFields = (app.params||[]).map(p=>`
|
||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||
<input id="rl-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||
const ov = Nova.modal(`Deploy ${appName}`,
|
||||
`<div class="form-group"><label>Account</label><select id="rl-acct" class="form-control"><option value="">Select account</option>${acctOpts}</select></div>${paramFields}`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="rDockerLaunchSubmit('${appKey}')">Deploy</button>`
|
||||
);
|
||||
window.rDockerLaunchSubmit = async (key) => {
|
||||
const acctId = parseInt(document.getElementById('rl-acct').value)||0;
|
||||
if (!acctId) { Nova.toast('Select an account','error'); return; }
|
||||
const params = {};
|
||||
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
|
||||
ov.remove();
|
||||
Nova.loading(`Deploying ${appName}… this may take a minute`);
|
||||
const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }});
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error');
|
||||
if (r?.success) rDockerLoadTab('containers');
|
||||
};
|
||||
};
|
||||
|
||||
// ── White Label / Branding (#18) ────────────────────────────────────────────
|
||||
async function rWhiteLabel(el) {
|
||||
el.innerHTML = '<div class="page-loader">Loading…</div>';
|
||||
const res = await Nova.api('branding', 'get');
|
||||
const b = res?.data || {};
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1 class="page-title">White Label Branding</h1></div>
|
||||
<div class="grid-2" style="gap:1.5rem;align-items:start">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Panel Identity</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Panel Name</label>
|
||||
<input id="wl-name" class="form-control" value="${Nova.escHtml(b.panel_name||'NovaCPX')}" placeholder="NovaCPX">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Logo</label>
|
||||
${b.logo_url ? `<div style="margin-bottom:.5rem"><img src="${Nova.escHtml(b.logo_url)}" style="max-height:50px;max-width:200px;border-radius:6px;background:var(--bg2);padding:.5rem"></div>` : ''}
|
||||
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||||
<label class="btn btn-ghost btn-sm" style="cursor:pointer">
|
||||
Upload Logo <input type="file" id="wl-logo-file" accept="image/*" style="display:none" onchange="rWlUploadLogo()">
|
||||
</label>
|
||||
${b.logo_url ? `<button class="btn btn-ghost btn-sm" onclick="rWlDeleteLogo()" style="color:var(--danger)">Remove</button>` : ''}
|
||||
<span class="text-muted text-sm">PNG/SVG/JPG · max 512 KB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Custom CSS <span class="text-muted text-sm">(advanced)</span></label>
|
||||
<textarea id="wl-css" class="form-control" rows="4" style="font-family:monospace;font-size:.8rem" placeholder="/* e.g. .sidebar { background: #1a1a2e; } */">${Nova.escHtml(b.custom_css||'')}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:1.5rem">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Colors</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Primary Color</label>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<input type="color" id="wl-primary" value="${Nova.escHtml(b.primary_color||'#6366f1')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
|
||||
<input type="text" id="wl-primary-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.primary_color||'#6366f1')}" maxlength="7">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Accent Color</label>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<input type="color" id="wl-accent" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
|
||||
<input type="text" id="wl-accent-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" maxlength="7">
|
||||
</div>
|
||||
</div>
|
||||
<div id="wl-color-preview" style="height:40px;border-radius:8px;background:linear-gradient(135deg,${Nova.escHtml(b.primary_color||'#6366f1')},${Nova.escHtml(b.accent_color||'#0ea5e9')});transition:background .3s"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Support</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Support Email</label>
|
||||
<input id="wl-email" class="form-control" type="email" value="${Nova.escHtml(b.support_email||'')}" placeholder="support@yourdomain.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Support URL</label>
|
||||
<input id="wl-url" class="form-control" type="url" value="${Nova.escHtml(b.support_url||'')}" placeholder="https://support.yourdomain.com">
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="wl-hide-powered" ${b.hide_powered_by ? 'checked' : ''}>
|
||||
Hide "Powered by NovaCPX" in panel footer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" onclick="rWhiteLabel(document.getElementById('page-content'))">Reset</button>
|
||||
<button class="btn btn-primary" onclick="rWlSave()">Save Branding</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Sync color pickers ↔ hex inputs ↔ preview
|
||||
['primary','accent'].forEach(k => {
|
||||
const picker = document.getElementById('wl-'+k);
|
||||
const hex = document.getElementById('wl-'+k+'-hex');
|
||||
const sync = () => {
|
||||
if (picker) hex.value = picker.value;
|
||||
rWlUpdatePreview();
|
||||
};
|
||||
const syncBack = () => {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; rWlUpdatePreview(); }
|
||||
};
|
||||
picker?.addEventListener('input', sync);
|
||||
hex?.addEventListener('input', syncBack);
|
||||
});
|
||||
}
|
||||
|
||||
function rWlUpdatePreview() {
|
||||
const p = document.getElementById('wl-primary-hex')?.value || '#6366f1';
|
||||
const a = document.getElementById('wl-accent-hex')?.value || '#0ea5e9';
|
||||
const el = document.getElementById('wl-color-preview');
|
||||
if (el) el.style.background = `linear-gradient(135deg,${p},${a})`;
|
||||
// Live-preview CSS vars
|
||||
const style = document.getElementById('reseller-branding') || (() => {
|
||||
const s = document.createElement('style'); s.id = 'reseller-branding'; document.head.appendChild(s); return s;
|
||||
})();
|
||||
style.textContent = `:root { --primary: ${p}; --primary-dark: ${p}; --accent: ${a}; }`;
|
||||
}
|
||||
|
||||
window.rWlUploadLogo = async () => {
|
||||
const file = document.getElementById('wl-logo-file')?.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 512 * 1024) { Nova.toast('Logo must be under 512 KB', 'error'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('logo', file);
|
||||
Nova.toast('Uploading…', 'info', 5000);
|
||||
try {
|
||||
const res = await fetch('/api/branding/upload-logo', {
|
||||
method: 'POST', credentials: 'include', body: fd
|
||||
});
|
||||
const data = await res.json();
|
||||
Nova.toast(data?.success ? 'Logo uploaded' : (data?.message || 'Upload failed'),
|
||||
data?.success ? 'success' : 'error');
|
||||
if (data?.success) rWhiteLabel(document.getElementById('page-content'));
|
||||
} catch (e) { Nova.toast('Upload failed', 'error'); }
|
||||
};
|
||||
|
||||
window.rWlDeleteLogo = async () => {
|
||||
const r = await Nova.api('branding', 'delete-logo', { method: 'POST' });
|
||||
Nova.toast(r?.success ? 'Logo removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) rWhiteLabel(document.getElementById('page-content'));
|
||||
};
|
||||
|
||||
window.rWlSave = async () => {
|
||||
const body = {
|
||||
panel_name: document.getElementById('wl-name')?.value?.trim() || 'NovaCPX',
|
||||
primary_color: document.getElementById('wl-primary-hex')?.value || '#6366f1',
|
||||
accent_color: document.getElementById('wl-accent-hex')?.value || '#0ea5e9',
|
||||
support_email: document.getElementById('wl-email')?.value?.trim() || '',
|
||||
support_url: document.getElementById('wl-url')?.value?.trim() || '',
|
||||
hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0,
|
||||
custom_css: document.getElementById('wl-css')?.value || '',
|
||||
};
|
||||
Nova.loading('Saving branding…');
|
||||
const r = await Nova.api('branding', 'save', { method: 'POST', body });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
|
||||
r?.success ? 'success' : 'error');
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
<?php http_response_code(404); ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — Page Not Found · NovaCPX</title>
|
||||
<style>
|
||||
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||
background-image:radial-gradient(ellipse at 30% 20%,rgba(99,102,241,.12) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(239,68,68,.07) 0%,transparent 60%)}
|
||||
.wrap{text-align:center;padding:2rem;max-width:480px}
|
||||
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,var(--primary),#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
|
||||
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
|
||||
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
|
||||
.btn:hover{opacity:.85}
|
||||
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
|
||||
.logo svg{width:28px;height:28px}
|
||||
.logo-text{font-size:1.1rem;font-weight:300}
|
||||
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
|
||||
<div class="logo-text">Nova<strong>CPX</strong></div>
|
||||
</div>
|
||||
<div class="code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="javascript:history.back()" class="btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
Go Back
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php http_response_code(500); ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>500 — Server Error · NovaCPX</title>
|
||||
<style>
|
||||
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||
background-image:radial-gradient(ellipse at 30% 20%,rgba(239,68,68,.1) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(99,102,241,.07) 0%,transparent 60%)}
|
||||
.wrap{text-align:center;padding:2rem;max-width:480px}
|
||||
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,#ef4444,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
|
||||
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
|
||||
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
|
||||
.btn:hover{opacity:.85}
|
||||
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
|
||||
.logo svg{width:28px;height:28px}
|
||||
.logo-text{font-size:1.1rem;font-weight:300}
|
||||
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
|
||||
<div class="logo-text">Nova<strong>CPX</strong></div>
|
||||
</div>
|
||||
<div class="code">500</div>
|
||||
<h1>Internal Server Error</h1>
|
||||
<p>Something went wrong on our end. The issue has been logged. Please try again in a moment.</p>
|
||||
<a href="javascript:history.back()" class="btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
Go Back
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
// NovaCPX entry point — redirect based on role or show login
|
||||
session_start();
|
||||
$redirect = $_GET['redirect'] ?? '';
|
||||
$safeRedirect = preg_match('#^/(user|reseller|admin)#', $redirect) ? $redirect : '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NovaCPX — Login</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
|
||||
<div class="login-wrap">
|
||||
<div class="login-brand">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="logo-text">Nova<strong>CPX</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<h1>Sign In</h1>
|
||||
<p class="login-sub">Linux Web Hosting Control Panel</p>
|
||||
|
||||
<div id="login-error" class="alert alert-error" style="display:none"></div>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username or Email</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
<button type="button" class="eye-toggle" data-target="password">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" id="login-btn">
|
||||
<span class="btn-text">Sign In</span>
|
||||
<span class="btn-spinner" style="display:none">Signing in…</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
NovaCPX v<span id="panel-version">1.0.0</span> |
|
||||
<a href="/api/system/version" target="_blank">System Info</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const REDIRECT = <?= json_encode($safeRedirect) ?>;
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('login-btn');
|
||||
const err = document.getElementById('login-error');
|
||||
btn.querySelector('.btn-text').style.display = 'none';
|
||||
btn.querySelector('.btn-spinner').style.display = '';
|
||||
btn.disabled = true;
|
||||
err.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message || 'Login failed');
|
||||
|
||||
// Each role redirects to its dedicated port
|
||||
const dest = REDIRECT || data.data.portal_url || '/';
|
||||
location.href = dest;
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.style.display = '';
|
||||
btn.querySelector('.btn-text').style.display = '';
|
||||
btn.querySelector('.btn-spinner').style.display = 'none';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Password toggle
|
||||
document.querySelectorAll('.eye-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const inp = document.getElementById(btn.dataset.target);
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch version
|
||||
fetch('/api/auth/me', {credentials:'include'}).then(r => r.json()).then(d => {
|
||||
if (d.success) {
|
||||
const role = d.data.role;
|
||||
location.href = role === 'admin' ? '/admin/' : role === 'reseller' ? '/reseller/' : '/user/';
|
||||
}
|
||||
});
|
||||
fetch('/api/system/version', {credentials:'include'})
|
||||
.then(r=>r.json()).then(d=>{ if(d.data?.installed_version) document.getElementById('panel-version').textContent=d.data.installed_version; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
// NovaCPX Reseller Panel — port 8881
|
||||
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
|
||||
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
$_pname = novacpx_panel_name('NovaCPX');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="NovaCPX Reseller Panel — manage your reseller hosting accounts, packages, clients, and branding from one dashboard.">
|
||||
<meta name="keywords" content="reseller hosting panel, reseller control panel, manage hosting clients, white label hosting, NovaCPX reseller">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $_pname ?> — Reseller</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
<?php novacpx_branding_head() ?>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="main-layout" style="display:none">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<?= novacpx_logo_html('<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#rlg1)" stroke-width="2"/><path d="M12 28 L20 8 L28 28" stroke="url(#rlg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22 H26" stroke="url(#rlg2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="rlg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient><linearGradient id="rlg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient></defs></svg>') ?>
|
||||
<span class="logo-text"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">Reseller</small>
|
||||
<span style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= NOVACPX_VERSION ?></span>
|
||||
</span>
|
||||
</div>
|
||||
<nav id="sidebar-nav"></nav>
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-info">
|
||||
<div class="avatar" id="user-avatar">R</div>
|
||||
<div><div class="user-name" id="user-name">Reseller</div><div class="user-role">Reseller Account</div></div>
|
||||
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
<div class="topbar-title" id="page-title">Reseller Dashboard</div>
|
||||
</header>
|
||||
<div class="page-content" id="page-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth guard / Inline login -->
|
||||
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)">
|
||||
<div style="width:100%;max-width:400px;padding:1.5rem">
|
||||
<div style="text-align:center;margin-bottom:1.5rem">
|
||||
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#rlga)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#rlgb)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#rlgb)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="rlga" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="rlgb" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="li-err" class="alert alert-error" style="display:none"></div>
|
||||
<form id="login-form" onsubmit="event.preventDefault();doLogin()">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="li-user" autofocus autocomplete="username"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="li-pass" autocomplete="current-password"></div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Sign In to Reseller Panel</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NOVACPX_BRANDING = <?= json_encode([
|
||||
'panel_name' => novacpx_panel_name('NovaCPX'),
|
||||
'support_email' => novacpx_get_branding()['support_email'] ?? null,
|
||||
'support_url' => novacpx_get_branding()['support_url'] ?? null,
|
||||
'hide_powered_by' => !novacpx_powered_by(),
|
||||
]) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/reseller.js<?= $_v('/assets/js/reseller.js') ?>"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
// NovaCPX User Panel — End-user hosting dashboard
|
||||
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
|
||||
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
$_pname = novacpx_panel_name('NovaCPX');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Your hosting control panel — manage your website, domains, email accounts, databases, FTP, SSL certificates, and files in one place.">
|
||||
<meta name="keywords" content="hosting control panel, manage website, email hosting, domain management, database management, SSL certificate, FTP, web hosting dashboard">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $_pname ?> — My Hosting</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
<?php novacpx_branding_head() ?>
|
||||
<style>
|
||||
/* ── User panel specific ─────────────────────────────── */
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.feature-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
transition: border-color .15s, transform .1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.feature-card:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.feature-icon {
|
||||
width: 44px; height: 44px; flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.fi-purple { background: rgba(99,102,241,.15); color: var(--primary); }
|
||||
.fi-sky { background: rgba(14,165,233,.15); color: var(--sky); }
|
||||
.fi-green { background: rgba(16,185,129,.15); color: var(--green); }
|
||||
.fi-yellow { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||||
.fi-red { background: rgba(239,68,68,.15); color: var(--red); }
|
||||
.fi-pink { background: rgba(236,72,153,.15); color: #f472b6; }
|
||||
.fi-teal { background: rgba(20,184,166,.15); color: #2dd4bf; }
|
||||
.fi-orange { background: rgba(249,115,22,.15); color: #fb923c; }
|
||||
.feature-icon svg { width: 22px; height: 22px; }
|
||||
.feature-info { flex: 1; min-width: 0; }
|
||||
.feature-name { font-weight: 600; font-size: .9rem; margin-bottom: .2rem; }
|
||||
.feature-desc { font-size: .78rem; color: var(--text-muted); line-height: 1.4; }
|
||||
.feature-meta { font-size: .75rem; color: var(--primary); margin-top: .3rem; }
|
||||
|
||||
/* Usage ring */
|
||||
.usage-rings {
|
||||
display: flex; gap: 2rem; align-items: center;
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 1.25rem 1.5rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
.ring-item { text-align: center; }
|
||||
.ring-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-top: .5rem; }
|
||||
.ring-val { font-size: .85rem; font-weight: 600; margin-top: .15rem; }
|
||||
svg.ring { transform: rotate(-90deg); }
|
||||
svg.ring circle { transition: stroke-dashoffset .5s; }
|
||||
|
||||
/* Breadcrumb / section tabs */
|
||||
.section-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem; }
|
||||
.section-header h2 { font-size: 1rem; font-weight: 700; flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="main-layout" style="display:none">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<?= novacpx_logo_html('<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#ulg1)" stroke-width="2"/><path d="M12 28 L20 8 L28 28" stroke="url(#ulg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22 H26" stroke="url(#ulg2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="ulg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient><linearGradient id="ulg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient></defs></svg>') ?>
|
||||
<span class="logo-text"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">My Hosting</small>
|
||||
<span style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= NOVACPX_VERSION ?></span>
|
||||
</span>
|
||||
</div>
|
||||
<nav id="sidebar-nav"></nav>
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-info">
|
||||
<div class="avatar" id="user-avatar">U</div>
|
||||
<div><div class="user-name" id="user-name">User</div><div class="user-role" id="user-domain">example.com</div></div>
|
||||
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
<div class="topbar-title" id="page-title">My Hosting</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="account-domain" class="text-muted text-sm"></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page-content" id="page-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)">
|
||||
<div style="width:100%;max-width:400px;padding:1.5rem">
|
||||
<div style="text-align:center;margin-bottom:1.5rem">
|
||||
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#ulg3)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#ulg4)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#ulg4)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="ulg3" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="ulg4" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="li-err" class="alert alert-error" style="display:none"></div>
|
||||
<form id="login-form" onsubmit="event.preventDefault();doLogin()">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="li-user" autofocus autocomplete="username"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="li-pass" autocomplete="current-password"></div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NOVACPX_BRANDING = <?= json_encode([
|
||||
'panel_name' => novacpx_panel_name('NovaCPX'),
|
||||
'support_email' => novacpx_get_branding()['support_email'] ?? null,
|
||||
'support_url' => novacpx_get_branding()['support_url'] ?? null,
|
||||
'hide_powered_by' => !novacpx_powered_by(),
|
||||
]) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/user.js<?= $_v('/assets/js/user.js') ?>"></script>
|
||||
<!-- user.js boots via DOMContentLoaded and handles all auth/init -->
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user