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:
2026-06-20 05:40:00 +00:00
parent 89c1deea5c
commit 6dd2e3a08d
16 changed files with 3647 additions and 1 deletions
+80
View File
@@ -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']);
}
+387
View File
@@ -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; }
}
+9
View File
@@ -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

+329
View File
@@ -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

+26
View File
@@ -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

+15
View File
@@ -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

+1 -1
View File
@@ -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');
}
+133
View File
@@ -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);
},
};
+234
View File
@@ -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()">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ── 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(); })
);
});
+700
View File
@@ -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
+39
View File
@@ -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>
+39
View File
@@ -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
View File
@@ -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> &nbsp;|&nbsp;
<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>
+94
View File
@@ -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>
+155
View File
@@ -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>