Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>FriendlyBot — Dashboard</title> | |
| <link rel="icon" href="/logo.png"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { margin:0; padding:0; box-sizing:border-box; } | |
| :root { | |
| --bg: #0a0a1a; | |
| --surface: #12122a; | |
| --surface2: #1a1a3a; | |
| --border: rgba(255,255,255,0.06); | |
| --accent: #5865F2; | |
| --accent-glow: rgba(88,101,242,0.3); | |
| --green: #57F287; | |
| --red: #ED4245; | |
| --yellow: #FEE75C; | |
| --text: #ffffff; | |
| --text-dim: rgba(255,255,255,0.5); | |
| --text-mid: rgba(255,255,255,0.7); | |
| --radius: 16px; | |
| --radius-sm: 10px; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| /* ===== LOGIN ===== */ | |
| #login-screen { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 100vh; | |
| background: radial-gradient(ellipse at 50% 0%, rgba(88,101,242,0.15) 0%, transparent 60%); | |
| } | |
| .login-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 48px 40px; | |
| width: 420px; | |
| max-width: 90vw; | |
| text-align: center; | |
| backdrop-filter: blur(20px); | |
| box-shadow: 0 24px 80px rgba(0,0,0,0.5); | |
| animation: fadeUp 0.6s ease; | |
| } | |
| @keyframes fadeUp { | |
| from { opacity:0; transform:translateY(30px); } | |
| to { opacity:1; transform:translateY(0); } | |
| } | |
| .login-card img { width: 80px; border-radius: 50%; margin-bottom: 20px; box-shadow: 0 0 40px var(--accent-glow); } | |
| .login-card h1 { font-size: 24px; font-weight: 700; margin-bottom: 6px; } | |
| .login-card p { color: var(--text-dim); font-size: 14px; margin-bottom: 28px; } | |
| .login-card input { | |
| width: 100%; | |
| padding: 14px 18px; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| color: var(--text); | |
| font-size: 15px; | |
| font-family: 'Inter', sans-serif; | |
| outline: none; | |
| transition: border 0.2s; | |
| margin-bottom: 14px; | |
| } | |
| .login-card input:focus { border-color: var(--accent); } | |
| .login-card button { | |
| width: 100%; | |
| padding: 14px; | |
| background: var(--accent); | |
| color: white; | |
| border: none; | |
| border-radius: var(--radius-sm); | |
| font-size: 15px; | |
| font-weight: 600; | |
| font-family: 'Inter', sans-serif; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| } | |
| .login-card button:hover { background: #4752c4; transform: translateY(-1px); box-shadow: 0 8px 30px var(--accent-glow); } | |
| .login-card button svg { width: 20px; height: 20px; } | |
| .login-error { color: var(--red); font-size: 13px; margin-top: 14px; display: none; } | |
| .login-info { color: var(--text-dim); font-size: 12px; margin-top: 16px; line-height: 1.6; } | |
| .user-pill { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 8px 14px; | |
| background: var(--surface2); | |
| border-radius: 100px; | |
| } | |
| .user-pill img { width: 28px; height: 28px; border-radius: 50%; } | |
| .user-pill span { font-size: 13px; font-weight: 600; } | |
| /* ===== DASHBOARD ===== */ | |
| #dashboard { display: none; } | |
| .sidebar { | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 260px; | |
| height: 100vh; | |
| background: var(--surface); | |
| border-right: 1px solid var(--border); | |
| padding: 24px 16px; | |
| z-index: 100; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .sidebar-brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 0 8px 24px; | |
| border-bottom: 1px solid var(--border); | |
| margin-bottom: 20px; | |
| } | |
| .sidebar-brand img { width: 40px; border-radius: 50%; } | |
| .sidebar-brand span { font-weight: 700; font-size: 17px; } | |
| .sidebar-nav { flex: 1; display: flex; flex-direction: column; gap: 4px; } | |
| .nav-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 12px 14px; | |
| border-radius: var(--radius-sm); | |
| color: var(--text-mid); | |
| font-size: 14px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| text-decoration: none; | |
| } | |
| .nav-item:hover { background: var(--surface2); color: var(--text); } | |
| .nav-item.active { background: var(--accent); color: white; } | |
| .nav-item svg { width: 20px; height: 20px; flex-shrink: 0; } | |
| .sidebar-footer { | |
| padding: 16px 8px 0; | |
| border-top: 1px solid var(--border); | |
| } | |
| .sidebar-footer .nav-item { color: var(--red); } | |
| .sidebar-footer .nav-item:hover { background: rgba(237,66,69,0.1); } | |
| .main { margin-left: 260px; padding: 32px 40px; min-height: 100vh; } | |
| .topbar { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 32px; | |
| } | |
| .topbar h2 { font-size: 26px; font-weight: 700; } | |
| .topbar-right { display: flex; align-items: center; gap: 12px; } | |
| .status-pill { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: rgba(87,242,135,0.1); | |
| color: var(--green); | |
| padding: 8px 16px; | |
| border-radius: 100px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| } | |
| .status-dot { width: 8px; height: 8px; background: var(--green); border-radius: 50%; animation: pulse 2s infinite; } | |
| @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } } | |
| /* Stats Cards */ | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 32px; | |
| } | |
| .stat-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| position: relative; | |
| overflow: hidden; | |
| transition: all 0.25s; | |
| } | |
| .stat-card:hover { border-color: rgba(88,101,242,0.3); transform: translateY(-2px); box-shadow: 0 12px 40px rgba(0,0,0,0.3); } | |
| .stat-card::after { content:''; position:absolute; top:0; right:0; width:120px; height:120px; background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%); opacity:0.15; pointer-events: none; } | |
| .stat-card .label { font-size: 13px; color: var(--text-dim); font-weight: 500; text-transform: uppercase; letter-spacing: 1px; } | |
| .stat-card .value { font-size: 36px; font-weight: 800; margin-top: 8px; line-height: 1; } | |
| .stat-card .sub { font-size: 12px; color: var(--text-dim); margin-top: 8px; } | |
| /* Panels */ | |
| .panel { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| margin-bottom: 24px; | |
| } | |
| .panel-header { | |
| padding: 20px 24px; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .panel-header h3 { font-size: 16px; font-weight: 600; } | |
| .panel-body { padding: 0; } | |
| /* Server List */ | |
| .server-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 16px 24px; | |
| border-bottom: 1px solid var(--border); | |
| transition: background 0.15s; | |
| } | |
| .server-row:last-child { border-bottom: none; } | |
| .server-row:hover { background: var(--surface2); } | |
| .server-info { display: flex; align-items: center; gap: 14px; } | |
| .server-icon { | |
| width: 40px; height: 40px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| display: flex; align-items: center; justify-content: center; | |
| font-weight: 700; font-size: 16px; color: white; flex-shrink: 0; | |
| } | |
| .server-name { font-weight: 600; font-size: 14px; } | |
| .server-members { color: var(--text-dim); font-size: 12px; margin-top: 2px; } | |
| .server-rank { font-size: 13px; color: var(--text-dim); font-weight: 600; min-width: 30px; } | |
| .member-badge { | |
| background: var(--surface2); | |
| padding: 6px 14px; | |
| border-radius: 100px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--text-mid); | |
| } | |
| /* Command List */ | |
| .cmd-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 14px 24px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .cmd-row:last-child { border-bottom: none; } | |
| .cmd-row:hover { background: var(--surface2); } | |
| .cmd-name { font-family: 'Courier New', monospace; font-weight: 600; color: var(--accent); font-size: 14px; } | |
| .cmd-desc { color: var(--text-dim); font-size: 13px; margin-top: 2px; } | |
| .cmd-badge { | |
| padding: 4px 12px; | |
| border-radius: 100px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .cmd-badge.admin { background: rgba(237,66,69,0.15); color: var(--red); } | |
| .cmd-badge.public { background: rgba(87,242,135,0.15); color: var(--green); } | |
| .cmd-badge.staff { background: rgba(254,231,92,0.15); color: var(--yellow); } | |
| /* Pages */ | |
| .page { display: none; } | |
| .page.active { display: block; } | |
| /* Invite Section */ | |
| .invite-box { | |
| background: linear-gradient(135deg, #5865F2 0%, #4752c4 100%); | |
| border-radius: var(--radius); | |
| padding: 40px; | |
| text-align: center; | |
| margin-bottom: 24px; | |
| } | |
| .invite-box h3 { font-size: 22px; font-weight: 700; margin-bottom: 10px; } | |
| .invite-box p { color: rgba(255,255,255,0.8); margin-bottom: 24px; font-size: 15px; } | |
| .invite-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 14px 32px; | |
| background: white; | |
| color: var(--accent); | |
| border: none; | |
| border-radius: var(--radius-sm); | |
| font-size: 15px; | |
| font-weight: 700; | |
| font-family: 'Inter', sans-serif; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| text-decoration: none; | |
| } | |
| .invite-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 40px rgba(0,0,0,0.3); } | |
| /* Quick Actions */ | |
| .actions-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; padding: 24px; } | |
| .action-card { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| padding: 20px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .action-card:hover { border-color: var(--accent); transform: translateY(-2px); } | |
| .action-card h4 { font-size: 14px; font-weight: 600; margin-bottom: 6px; } | |
| .action-card p { font-size: 12px; color: var(--text-dim); } | |
| .action-icon { font-size: 24px; margin-bottom: 12px; } | |
| /* Logs */ | |
| .log-area { | |
| background: var(--bg); | |
| padding: 20px 24px; | |
| font-family: 'Courier New', monospace; | |
| font-size: 13px; | |
| color: var(--green); | |
| max-height: 400px; | |
| overflow-y: auto; | |
| line-height: 1.8; | |
| } | |
| .log-line { opacity: 0.85; } | |
| .log-line.error { color: var(--red); } | |
| .log-line.warn { color: var(--yellow); } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .sidebar { display: none; } | |
| .main { margin-left: 0; padding: 20px; } | |
| } | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); } | |
| /* Loading shimmer */ | |
| .shimmer { | |
| background: linear-gradient(90deg, var(--surface) 25%, var(--surface2) 50%, var(--surface) 75%); | |
| background-size: 200% 100%; | |
| animation: shimmer 1.5s infinite; | |
| } | |
| @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } | |
| /* Switch Toggle */ | |
| .switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 48px; | |
| height: 24px; | |
| } | |
| .switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background-color: var(--surface2); | |
| transition: .4s cubic-bezier(0.4, 0, 0.2, 1); | |
| border-radius: 24px; | |
| border: 1px solid var(--border); | |
| } | |
| .slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 18px; | |
| width: 18px; | |
| left: 2px; | |
| bottom: 2px; | |
| background-color: white; | |
| transition: .4s cubic-bezier(0.4, 0, 0.2, 1); | |
| border-radius: 50%; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.3); | |
| } | |
| input:checked + .slider { | |
| background-color: var(--accent); | |
| border-color: transparent; | |
| box-shadow: 0 0 15px var(--accent-glow); | |
| } | |
| input:checked + .slider:before { | |
| transform: translateX(24px); | |
| } | |
| /* Refresh Button */ | |
| .refresh-btn { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| color: var(--text-mid); | |
| padding: 8px; | |
| border-radius: var(--radius-sm); | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-right: 8px; | |
| } | |
| .refresh-btn:hover { | |
| color: var(--text); | |
| border-color: var(--accent); | |
| background: var(--surface); | |
| transform: translateY(-1px); | |
| } | |
| .refresh-btn svg { width: 18px; height: 18px; } | |
| .refresh-btn.loading svg { | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ===== LOGIN SCREEN ===== --> | |
| <div id="login-screen"> | |
| <div class="login-card"> | |
| <img src="/logo.png" alt="FriendlyBot"> | |
| <h1>FriendlyBot</h1> | |
| <p>Sign in with your Discord account to access the dashboard</p> | |
| <button onclick="window.location.href='/auth/login'"> | |
| <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.73 4.87a18.2 18.2 0 0 0-4.6-1.44c-.21.4-.4.8-.58 1.21-1.69-.25-3.4-.25-5.1 0-.18-.4-.37-.8-.58-1.21-1.64.4-3.2 0.88-4.6 1.44C1.3 9.4 0.2 13.9 0.7 18.3a18.4 18.4 0 0 0 5.5 2.8c.4-.6.8-1.2 1.1-1.8-1.9-.7-3.7-1.6-5.4-2.8.4.3.9.6 1.3.9 3.5 2.1 7.4 2.1 10.9 0 .4-.3.9-.6 1.3-.9-1.7 1.2-3.5 2.1-5.4 2.8.3.6.7 1.2 1.1 1.8a18.4 18.4 0 0 0 5.5-2.8c.5-4.4-.6-8.9-3.7-13.43zM8.02 15.33c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm7.96 0c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg> | |
| Login with Discord | |
| </button> | |
| <div class="login-error" id="login-error"></div> | |
| <div class="login-info">Server Administrators can access the dashboard to configure bot settings for their respective guilds.</div> | |
| </div> | |
| </div> | |
| <!-- ===== DASHBOARD ===== --> | |
| <div id="dashboard"> | |
| <!-- Sidebar --> | |
| <div class="sidebar"> | |
| <div class="sidebar-brand"> | |
| <img src="/logo.png" alt=""> | |
| <span>FriendlyBot</span> | |
| </div> | |
| <div class="sidebar-nav"> | |
| <div class="nav-item active" onclick="showPage('overview', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg> | |
| Overview | |
| </div> | |
| <div class="nav-item" onclick="showPage('servers', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> | |
| Servers | |
| </div> | |
| <div class="nav-item" onclick="showPage('commands', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> | |
| Commands | |
| </div> | |
| <div class="nav-item" onclick="showPage('league', this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l3 6 6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1z"/></svg> | |
| League | |
| </div> | |
| <div class="nav-item" onclick="showPage('invite', this)"> | |
| <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="23" y1="11" x2="17" y2="11"/></svg> | |
| Invite Bot | |
| </div> | |
| <a class="nav-item" href="/premium" aria-label="Premium" style="color:var(--gold, #FFD700);"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> | |
| Premium | |
| </a> | |
| </div> | |
| </div> | |
| <div class="sidebar-footer"> | |
| <div class="nav-item" onclick="logout()"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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> | |
| Sign Out | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="main"> | |
| <!-- ===== OVERVIEW PAGE ===== --> | |
| <div class="page active" id="page-overview"> | |
| <div class="topbar"> | |
| <h2>Dashboard</h2> | |
| <div class="topbar-right"> | |
| <button class="refresh-btn" onclick="refreshAll()" title="Refresh Data"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> | |
| </button> | |
| <div class="user-pill" id="user-pill" style="display:none"> | |
| <img id="user-avatar" src="" alt=""> | |
| <span id="user-name"></span> | |
| </div> | |
| <div class="status-pill"><div class="status-dot"></div> Online</div> | |
| </div> | |
| </div> | |
| <div class="stats-grid"> | |
| <div class="stat-card"> | |
| <div class="label">Servers</div> | |
| <div class="value" id="stat-servers">—</div> | |
| <div class="sub">Connected guilds</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="label">Total Members</div> | |
| <div class="value" id="stat-members">—</div> | |
| <div class="sub">Across all servers</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="label">Uptime</div> | |
| <div class="value" id="stat-uptime">—</div> | |
| <div class="sub">Since last restart</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="label">Commands</div> | |
| <div class="value" id="stat-commands">—</div> | |
| <div class="sub">Registered globally</div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-header"><h3>⚡ Quick Actions</h3></div> | |
| <div class="actions-grid"> | |
| <div class="action-card" onclick="window.open('/api/invite','_blank')"> | |
| <div class="action-icon">🤖</div> | |
| <h4>Add to Server</h4> | |
| <p>Generate a fresh invite link and add the bot to a new server.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== SERVERS PAGE ===== --> | |
| <div class="page" id="page-servers"> | |
| <div class="topbar"> | |
| <h2>Server Registry</h2> | |
| <div class="topbar-right"> | |
| <div class="status-pill"><div class="status-dot"></div> <span id="server-count-label">0 Servers</span></div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-header"><h3>🌐 All Connected Servers</h3><span style="color:var(--text-dim);font-size:13px">Sorted by members</span></div> | |
| <div class="panel-body" id="server-list"> | |
| <div class="server-row shimmer" style="height:72px"></div> | |
| <div class="server-row shimmer" style="height:72px"></div> | |
| <div class="server-row shimmer" style="height:72px"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== COMMANDS PAGE ===== --> | |
| <div class="page" id="page-commands"> | |
| <div class="topbar"><h2>Command Registry</h2></div> | |
| <div class="panel"> | |
| <div class="panel-header"><h3>📋 All Registered Commands</h3></div> | |
| <div class="panel-body" id="command-list"></div> | |
| </div> | |
| </div> | |
| <!-- ===== INVITE PAGE ===== --> | |
| <div class="page" id="page-invite"> | |
| <div class="topbar"><h2>Invite Bot</h2></div> | |
| <div class="invite-box"> | |
| <h3>Add FriendlyBot to Your Server</h3> | |
| <p>Give your server the ultimate match management experience with lineup boards, formations, match tracking, and more.</p> | |
| <a class="invite-btn" id="invite-link" href="#" target="_blank"> | |
| <svg width="20" height="20" 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="23" y1="11" x2="17" y2="11"/></svg> | |
| Invite to Discord | |
| </a> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-header"><h3>🔒 Required Permissions</h3></div> | |
| <div class="actions-grid"> | |
| <div class="action-card" style="cursor:default"> | |
| <div class="action-icon">💬</div> | |
| <h4>Send Messages</h4> | |
| <p>Post lineup boards, match polls, and announcements.</p> | |
| </div> | |
| <div class="action-card" style="cursor:default"> | |
| <div class="action-icon">👤</div> | |
| <h4>Manage Roles</h4> | |
| <p>Assign and manage staff whitelist roles.</p> | |
| </div> | |
| <div class="action-card" style="cursor:default"> | |
| <div class="action-icon">📌</div> | |
| <h4>Manage Messages</h4> | |
| <p>Clear messages, pin important updates, bulk delete.</p> | |
| </div> | |
| <div class="action-card" style="cursor:default"> | |
| <div class="action-icon">🔒</div> | |
| <h4>Manage Channels</h4> | |
| <p>Lock/unlock channels during matches or events.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== LEAGUE PAGE ===== --> | |
| <div class="page" id="page-league"> | |
| <div class="topbar"><h2>League Center</h2></div> | |
| <div class="invite-box" style="background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);"> | |
| <h3 style="color: #000;">Join the Official League</h3> | |
| <p style="color: rgba(0,0,0,0.8);">Compete against the best teams and players. Winner receives exclusive premium features for FriendlyBot!</p> | |
| <a class="invite-btn" href="https://discord.gg/vpHd4Xfm" target="_blank" style="color: #5865F2;"> | |
| <svg viewBox="0 0 24 24" fill="currentColor" style="width:20px; height:20px;"><path d="M19.73 4.87a18.2 18.2 0 0 0-4.6-1.44c-.21.4-.4.8-.58 1.21-1.69-.25-3.4-.25-5.1 0-.18-.4-.37-.8-.58-1.21-1.64.4-3.2 0.88-4.6 1.44C1.3 9.4 0.2 13.9 0.7 18.3a18.4 18.4 0 0 0 5.5 2.8c.4-.6.8-1.2 1.1-1.8-1.9-.7-3.7-1.6-5.4-2.8.4.3.9.6 1.3.9 3.5 2.1 7.4 2.1 10.9 0 .4-.3.9-.6 1.3-.9-1.7 1.2-3.5 2.1-5.4 2.8.3.6.7 1.2 1.1 1.8a18.4 18.4 0 0 0 5.5-2.8c.5-4.4-.6-8.9-3.7-13.43zM8.02 15.33c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm7.96 0c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg> | |
| Join Discord League | |
| </a> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-header"><h3>🏆 League Info</h3></div> | |
| <div class="panel-body" style="padding: 24px;"> | |
| <p style="margin-bottom: 20px; color: var(--text-mid); line-height: 1.6;">The FriendlyBot League is the premier competition for teams using our bot. Showcase your skills, climb the rankings, and earn special rewards.</p> | |
| <div class="actions-grid" style="padding: 0; margin-bottom: 30px;"> | |
| <div class="action-card" style="cursor:default"> | |
| <div class="action-icon">💎</div> | |
| <h4>Premium Features</h4> | |
| <p>Winners gain access to exclusive bot features, custom themes, and advanced analytics.</p> | |
| </div> | |
| <div class="action-card" style="cursor:default"> | |
| <div class="action-icon">📅</div> | |
| <h4>Weekly Matches</h4> | |
| <p>Scheduled matches every weekend with full stat tracking and automated lineups.</p> | |
| </div> | |
| </div> | |
| <div style="text-align: center; padding: 20px; border-top: 1px solid var(--border);"> | |
| <a href="/league" class="invite-btn" style="background: var(--accent); color: white;"> | |
| Visit Full League Website | |
| </a> | |
| <p style="font-size: 12px; color: var(--text-dim); margin-top: 12px;">View teams, rosters, and full standings on the official league page.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== SERVER SETTINGS PAGE ===== --> | |
| <div class="page" id="page-server-settings"> | |
| <div class="topbar"> | |
| <h2 id="settings-guild-name">Server Settings</h2> | |
| <button class="invite-btn" onclick="showPage('servers')" style="padding:8px 16px; font-size:13px">Back to List</button> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-header"><h3>⚙️ General Configuration</h3></div> | |
| <div class="panel-body" style="padding: 24px;"> | |
| <div style="margin-bottom: 32px;"> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Command Prefix</label> | |
| <div style="display:flex; gap:10px"> | |
| <input type="text" id="config-prefix" placeholder="!" style="width:100px; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; font-size:16px; outline:none"> | |
| </div> | |
| <p style="font-size:12px; color:var(--text-dim); margin-top:10px">The prefix used to trigger message-based commands (e.g. ! or . or ?)</p> | |
| </div> | |
| <div style="border-top:1px solid var(--border); padding-top:32px; margin-bottom: 32px;"> | |
| <h4 style="margin-bottom:16px; font-size:15px; color:var(--accent)">🤖 Auto Friendly System</h4> | |
| <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:24px"> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Feature Status</label> | |
| <div style="display:flex; align-items:center; gap:12px"> | |
| <label class="switch"> | |
| <input type="checkbox" id="config-auto-enabled"> | |
| <span class="slider"></span> | |
| </label> | |
| <span style="font-size:14px; color:var(--text-mid)">Enabled</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Interval (Minutes)</label> | |
| <input type="number" id="config-auto-interval" placeholder="40" style="width:100%; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; outline:none"> | |
| </div> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Target Channel</label> | |
| <select id="config-auto-channel" style="width:100%; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; outline:none"> | |
| <option value="">Select a channel...</option> | |
| </select> | |
| </div> | |
| </div> | |
| <p style="font-size:12px; color:var(--text-dim); margin-top:16px">When enabled, the bot will automatically post a friendly match poll in the selected channel at the specified interval.</p> | |
| </div> | |
| <div style="border-top:1px solid var(--border); padding-top:32px; margin-bottom: 32px;"> | |
| <h4 style="margin-bottom:16px; font-size:15px; color:var(--accent)">📢 Broadcast & Announcements</h4> | |
| <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:24px"> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Receive Broadcasts</label> | |
| <div style="display:flex; align-items:center; gap:12px"> | |
| <label class="switch"> | |
| <input type="checkbox" id="config-post-enabled"> | |
| <span class="slider"></span> | |
| </label> | |
| <span style="font-size:14px; color:var(--text-mid)">Enabled</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Broadcast Channel</label> | |
| <select id="config-post-channel" style="width:100%; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; outline:none"> | |
| <option value="">Default (System Channel)</option> | |
| </select> | |
| </div> | |
| </div> | |
| <p style="font-size:12px; color:var(--text-dim); margin-top:16px">Control where global announcements from the bot owner are posted. You can opt-out by disabling this feature.</p> | |
| </div> | |
| <div style="border-top:1px solid var(--border); padding-top:32px; margin-bottom: 32px;"> | |
| <h4 style="margin-bottom:16px; font-size:15px; color:var(--accent)">🧹 Auto Cleanup & Maintenance</h4> | |
| <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:24px"> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Poll Cleanup (Hours)</label> | |
| <input type="number" id="config-cleanup-hours" placeholder="2" style="width:100%; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; outline:none"> | |
| </div> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Lineup Cleanup (Hours)</label> | |
| <input type="number" id="config-lineup-cleanup-hours" placeholder="2" style="width:100%; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; outline:none"> | |
| </div> | |
| </div> | |
| <p style="font-size:12px; color:var(--text-dim); margin-top:16px">The bot will automatically delete friendly polls and lineup boards after the specified time to keep channels clean.</p> | |
| </div> | |
| <div style="border-top:1px solid var(--border); padding-top:32px; margin-bottom: 32px;"> | |
| <h4 style="margin-bottom:16px; font-size:15px; color:var(--accent)">⏰ Match Reminders</h4> | |
| <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:24px"> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Enable DM Reminders</label> | |
| <div style="display:flex; align-items:center; gap:12px"> | |
| <label class="switch"> | |
| <input type="checkbox" id="config-reminders-enabled"> | |
| <span class="slider"></span> | |
| </label> | |
| <span style="font-size:14px; color:var(--text-mid)">Enabled</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Lead Time (Minutes)</label> | |
| <input type="number" id="config-reminders-lead" placeholder="30" style="width:100%; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; outline:none"> | |
| </div> | |
| </div> | |
| <p style="font-size:12px; color:var(--text-dim); margin-top:16px">When enabled, players on the lineup board will receive a DM reminder before a scheduled league match starts.</p> | |
| </div> | |
| <div style="border-top:1px solid var(--border); padding-top:32px; margin-bottom: 32px;"> | |
| <h4 style="margin-bottom:16px; font-size:15px; color:var(--accent)">💬 Command Messages</h4> | |
| <div style="margin-bottom: 24px;"> | |
| <label style="display:block; font-size:13px; color:var(--text-dim); margin-bottom:8px">Friendly Command Text</label> | |
| <textarea id="config-friendly-text" oninput="autoResize(this)" style="width:100%; min-height:100px; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius-sm); color:white; font-family:monospace; font-size:13px; outline:none; resize:none; overflow:hidden; transition: height 0.2s ease;"></textarea> | |
| <p style="font-size:12px; color:var(--text-dim); margin-top:8px">Use <code>{needed}</code> as a placeholder for the number of players needed.</p> | |
| </div> | |
| </div> | |
| <div style="border-top:1px solid var(--border); padding-top:24px; text-align:right"> | |
| <button class="invite-btn" onclick="saveConfig()" style="background:var(--accent); color:white; padding:12px 32px">Save All Changes</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ===== AUTH (Discord OAuth2 via httpOnly cookie) ===== | |
| let currentUser = null; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Check URL params for errors | |
| const params = new URLSearchParams(window.location.search); | |
| if (params.get('error') === 'not_owner') { | |
| document.getElementById('login-error').textContent = '❌ Access denied. You must be an Administrator or have Manage Server permissions to access the dashboard.'; | |
| document.getElementById('login-error').style.display = 'block'; | |
| } else if (params.get('error')) { | |
| document.getElementById('login-error').textContent = '❌ Login failed. Please try again.'; | |
| document.getElementById('login-error').style.display = 'block'; | |
| } | |
| // Check for existing session | |
| fetch('/api/me') | |
| .then(r => r.json()) | |
| .then(data => { | |
| if (data.loggedIn) { | |
| currentUser = data.user; | |
| showDashboard(); | |
| // Clean URL | |
| if (params.has('login') || params.has('error')) { | |
| window.history.replaceState({}, '', '/'); | |
| } | |
| } | |
| }) | |
| .catch(() => {}); | |
| }); | |
| function showDashboard() { | |
| document.getElementById('login-screen').style.display = 'none'; | |
| document.getElementById('dashboard').style.display = 'block'; | |
| // Show user info | |
| if (currentUser) { | |
| document.getElementById('user-avatar').src = currentUser.avatar; | |
| document.getElementById('user-name').textContent = currentUser.username; | |
| document.getElementById('user-pill').style.display = 'flex'; | |
| } | |
| loadStats(); | |
| loadServers(); | |
| loadCommands(); | |
| loadLogs(); | |
| // Regular intervals | |
| setInterval(loadStats, 15000); | |
| setInterval(loadServers, 15000); | |
| setInterval(loadLogs, 10000); | |
| // Auto-refresh when tab becomes visible or focused | |
| // This solves the issue of not seeing a server after adding it | |
| window.addEventListener('focus', () => { | |
| console.log("Tab focused, refreshing dashboard data..."); | |
| refreshAll(); | |
| }); | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.visibilityState === 'visible') { | |
| refreshAll(); | |
| } | |
| }); | |
| } | |
| function refreshAll() { | |
| const btn = document.querySelector('.refresh-btn'); | |
| if (btn) btn.classList.add('loading'); | |
| return Promise.all([ | |
| loadStats(), | |
| loadServers(), | |
| loadLogs() | |
| ]).finally(() => { | |
| setTimeout(() => { | |
| if (btn) btn.classList.remove('loading'); | |
| }, 600); | |
| }); | |
| } | |
| function logout() { | |
| window.location.href = '/auth/logout'; | |
| } | |
| // ===== NAVIGATION ===== | |
| function showPage(name, el) { | |
| document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); | |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); | |
| document.getElementById('page-' + name).classList.add('active'); | |
| if (el) el.classList.add('active'); | |
| } | |
| // ===== DATA LOADERS ===== | |
| function loadStats() { | |
| return fetch('/api/stats') | |
| .then(r => r.json()) | |
| .then(data => { | |
| document.getElementById('stat-servers').textContent = data.servers || 0; | |
| document.getElementById('stat-members').textContent = (data.members || 0).toLocaleString(); | |
| document.getElementById('stat-uptime').textContent = data.uptime || '—'; | |
| document.getElementById('stat-commands').textContent = data.commands || 0; | |
| document.getElementById('server-count-label').textContent = (data.servers || 0) + ' Servers'; | |
| if (data.inviteUrl) document.getElementById('invite-link').href = data.inviteUrl; | |
| }).catch(() => {}); | |
| } | |
| function loadServers() { | |
| return fetch('/api/servers') | |
| .then(r => r.json()) | |
| .then(data => { | |
| const servers = Array.isArray(data) ? data : (Array.isArray(data.servers) ? data.servers : []); | |
| const container = document.getElementById('server-list'); | |
| container.innerHTML = ''; | |
| if (data && data.pending) { | |
| container.innerHTML = '<div class="server-row"><div class="server-info"><div><div class="server-name">Connecting to Discord...</div><div class="server-members">Bot is still starting up on Hugging Face. This list will auto-refresh.</div></div></div></div>'; | |
| return; | |
| } | |
| if (!servers.length) { | |
| container.innerHTML = '<div class="server-row"><div class="server-info"><div><div class="server-name">No servers found</div><div class="server-members">Make sure the bot is in your server and your account has Manage Server or Administrator permissions.</div></div></div></div>'; | |
| return; | |
| } | |
| servers.sort((a, b) => (b.members || 0) - (a.members || 0)); | |
| servers.forEach((s, i) => { | |
| const iconHtml = s.icon | |
| ? `<img class="server-icon" src="${s.icon}" style="object-fit:cover">` | |
| : `<div class="server-icon">${s.name.charAt(0).toUpperCase()}</div>`; | |
| container.innerHTML += ` | |
| <div class="server-row" onclick="openServerSettings('${s.id}', '${s.name.replace(/'/g, "\\'")}')" style="cursor:pointer"> | |
| <div class="server-info"> | |
| <span class="server-rank">#${i + 1}</span> | |
| ${iconHtml} | |
| <div> | |
| <div class="server-name">${escapeHtml(s.name)}</div> | |
| <div class="server-members">${s.id}</div> | |
| </div> | |
| </div> | |
| <div style="display:flex; align-items:center; gap:12px"> | |
| <div class="member-badge">${(s.members || 0).toLocaleString()} members</div> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="opacity:0.3"><polyline points="9 18 15 12 9 6"/></svg> | |
| </div> | |
| </div>`; | |
| }); | |
| }).catch(() => { | |
| const container = document.getElementById('server-list'); | |
| container.innerHTML = '<div class="server-row"><div class="server-info"><div><div class="server-name">Unable to load servers</div><div class="server-members">Please refresh the page and try again.</div></div></div></div>'; | |
| }); | |
| } | |
| let activeGuildId = null; | |
| function openServerSettings(id, name) { | |
| activeGuildId = id; | |
| document.getElementById('settings-guild-name').textContent = 'Settings: ' + name; | |
| showPage('server-settings'); | |
| // Load current config | |
| fetch('/api/config/' + id) | |
| .then(r => r.json()) | |
| .then(data => { | |
| document.getElementById('config-prefix').value = data.prefix || '!'; | |
| document.getElementById('config-auto-enabled').checked = !!data.autoFriendlyEnabled; | |
| document.getElementById('config-auto-interval').value = data.autoFriendlyInterval || 40; | |
| document.getElementById('config-post-enabled').checked = data.postEnabled !== false; | |
| document.getElementById('config-friendly-text').value = data.friendlyText || ""; | |
| // Auto-resize the textarea after setting value | |
| const friendlyTextarea = document.getElementById('config-friendly-text'); | |
| setTimeout(() => autoResize(friendlyTextarea), 50); | |
| document.getElementById('config-cleanup-hours').value = data.cleanupHours || 2; | |
| document.getElementById('config-lineup-cleanup-hours').value = data.lineupCleanupHours || 2; | |
| document.getElementById('config-reminders-enabled').checked = !!data.remindersEnabled; | |
| document.getElementById('config-reminders-lead').value = data.remindersLead || 30; | |
| // Load channels and set selected | |
| fetch('/api/channels/' + id) | |
| .then(cr => cr.json()) | |
| .then(channels => { | |
| const autoSelect = document.getElementById('config-auto-channel'); | |
| const postSelect = document.getElementById('config-post-channel'); | |
| autoSelect.innerHTML = '<option value="">Select a channel...</option>'; | |
| postSelect.innerHTML = '<option value="">Default (System Channel)</option>'; | |
| channels.forEach(c => { | |
| const optA = document.createElement('option'); | |
| optA.value = c.id; | |
| optA.textContent = '#' + c.name; | |
| if (c.id === data.autoFriendlyChannel) optA.selected = true; | |
| autoSelect.appendChild(optA); | |
| const optP = document.createElement('option'); | |
| optP.value = c.id; | |
| optP.textContent = '#' + c.name; | |
| if (c.id === data.postChannelId) optP.selected = true; | |
| postSelect.appendChild(optP); | |
| }); | |
| }); | |
| }); | |
| } | |
| function saveConfig() { | |
| if (!activeGuildId) return; | |
| const prefix = document.getElementById('config-prefix').value; | |
| const autoFriendlyEnabled = document.getElementById('config-auto-enabled').checked; | |
| const autoFriendlyInterval = document.getElementById('config-auto-interval').value; | |
| const autoFriendlyChannel = document.getElementById('config-auto-channel').value; | |
| const postEnabled = document.getElementById('config-post-enabled').checked; | |
| const postChannelId = document.getElementById('config-post-channel').value; | |
| const friendlyText = document.getElementById('config-friendly-text').value; | |
| const cleanupHours = document.getElementById('config-cleanup-hours').value; | |
| const lineupCleanupHours = document.getElementById('config-lineup-cleanup-hours').value; | |
| const remindersEnabled = document.getElementById('config-reminders-enabled').checked; | |
| const remindersLead = document.getElementById('config-reminders-lead').value; | |
| fetch('/api/config/' + activeGuildId, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| prefix, | |
| autoFriendlyEnabled, | |
| autoFriendlyInterval, | |
| autoFriendlyChannel, | |
| postEnabled, | |
| postChannelId, | |
| friendlyText, | |
| cleanupHours, | |
| lineupCleanupHours, | |
| remindersEnabled, | |
| remindersLead | |
| }) | |
| }) | |
| .then(r => r.json()) | |
| .then(data => { | |
| if (data.success) { | |
| alert('✅ Settings saved successfully!'); | |
| } else { | |
| alert('❌ Error: ' + data.error); | |
| } | |
| }).catch(() => alert('❌ Network error saving settings.')); | |
| } | |
| function loadCommands() { | |
| return fetch('/api/commands') | |
| .then(r => r.json()) | |
| .then(data => { | |
| const container = document.getElementById('command-list'); | |
| container.innerHTML = ''; | |
| data.forEach(c => { | |
| const badge = c.admin ? '<span class="cmd-badge admin">Admin</span>' | |
| : c.staff ? '<span class="cmd-badge staff">Staff</span>' | |
| : '<span class="cmd-badge public">Public</span>'; | |
| container.innerHTML += ` | |
| <div class="cmd-row"> | |
| <div> | |
| <div class="cmd-name">/${c.name}</div> | |
| <div class="cmd-desc">${escapeHtml(c.description)}</div> | |
| </div> | |
| ${badge} | |
| </div>`; | |
| }); | |
| }).catch(() => {}); | |
| } | |
| function loadLogs() { | |
| return fetch('/api/logs') | |
| .then(r => r.json()) | |
| .then(data => { | |
| const area = document.getElementById('log-area'); | |
| area.innerHTML = ''; | |
| (data.logs || []).forEach(line => { | |
| const cls = line.includes('Error') || line.includes('CRITICAL') ? 'error' | |
| : line.includes('Warning') || line.includes('Deprecation') ? 'warn' : ''; | |
| area.innerHTML += `<div class="log-line ${cls}">${escapeHtml(line)}</div>`; | |
| }); | |
| area.scrollTop = area.scrollHeight; | |
| }).catch(() => {}); | |
| } | |
| function triggerAction(action) { | |
| fetch('/api/action/' + action, { method: 'POST' }) | |
| .then(r => r.json()) | |
| .then(data => { | |
| alert(data.message || 'Action triggered!'); | |
| loadStats(); | |
| }).catch(() => alert('Failed to trigger action.')); | |
| } | |
| function escapeHtml(str) { | |
| const d = document.createElement('div'); | |
| d.textContent = str; | |
| return d.innerHTML; | |
| } | |
| function autoResize(el) { | |
| el.style.height = 'auto'; | |
| el.style.height = el.scrollHeight + 'px'; | |
| } | |
| </script> | |
| </body> | |
| </html> |