| {% extends "shared/base.html" %} |
|
|
| {% block title %}Admin Dashboard - AniCove{% endblock %} |
| {% block meta_description %}Admin dashboard for managing AniCove.{% endblock %} |
|
|
| {% block extra_css %} |
| <style> |
| :root { |
| --ad-bg: #080808; |
| --ad-surface: #111; |
| --ad-border: #1e1e1e; |
| --ad-muted: #777; |
| --ad-accent: #3b82f6; |
| --ad-success: #22c55e; |
| --ad-warning: #f59e0b; |
| --ad-error: #ef4444; |
| } |
| |
| .ad-page { |
| max-width: 1200px; |
| margin: 0 auto; |
| padding: 32px 20px; |
| } |
| |
| .ad-header { |
| margin-bottom: 24px; |
| } |
| |
| .ad-header h1 { |
| font-size: 1.5rem; |
| font-weight: 700; |
| margin: 0 0 6px 0; |
| color: #fff; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .ad-header p { |
| font-size: 0.85rem; |
| color: var(--ad-muted); |
| margin: 0; |
| } |
| |
| |
| .ad-stats-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); |
| gap: 10px; |
| margin-bottom: 20px; |
| } |
| |
| .ad-stat-card { |
| background: var(--ad-surface); |
| border: 1px solid var(--ad-border); |
| border-radius: 10px; |
| padding: 16px 18px; |
| transition: border-color 0.2s, transform 0.2s; |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .ad-stat-card:hover { |
| border-color: rgba(255,255,255,0.1); |
| transform: translateY(-1px); |
| } |
| |
| .ad-stat-card::before { |
| content: ''; |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 3px; |
| height: 100%; |
| border-radius: 0 3px 3px 0; |
| } |
| |
| .ad-stat-card.users::before { background: var(--ad-accent); } |
| .ad-stat-card.bugs::before { background: var(--ad-error); } |
| .ad-stat-card.open::before { background: var(--ad-warning); } |
| .ad-stat-card.resolved::before { background: var(--ad-success); } |
| .ad-stat-card.live::before { background: #a855f7; } |
| .ad-stat-card.online::before { background: #22c55e; } |
| |
| .ad-stat-number { |
| font-size: 1.8rem; |
| font-weight: 700; |
| color: #fff; |
| line-height: 1.2; |
| margin-bottom: 2px; |
| } |
| |
| .ad-stat-label { |
| font-size: 0.7rem; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| color: var(--ad-muted); |
| } |
| |
| |
| .ad-grid-2 { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 16px; |
| margin-bottom: 16px; |
| } |
| |
| @media (max-width: 900px) { |
| .ad-grid-2 { grid-template-columns: 1fr; } |
| } |
| |
| |
| .ad-quick-cards { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 10px; |
| margin-bottom: 16px; |
| } |
| |
| .ad-quick-card { |
| background: var(--ad-surface); |
| border: 1px solid var(--ad-border); |
| border-radius: 10px; |
| padding: 16px; |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| text-decoration: none; |
| transition: all 0.2s; |
| color: #ccc; |
| } |
| |
| .ad-quick-card:hover { |
| border-color: rgba(255,255,255,0.1); |
| transform: translateY(-2px); |
| color: #fff; |
| } |
| |
| .ad-quick-card-icon { |
| width: 40px; |
| height: 40px; |
| border-radius: 10px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| flex-shrink: 0; |
| } |
| |
| .ad-quick-card-icon.bugs { background: rgba(239,68,68,0.12); color: #ef4444; } |
| .ad-quick-card-icon.announce { background: rgba(245,158,11,0.12); color: #f59e0b; } |
| .ad-quick-card-icon.settings { background: rgba(59,130,246,0.12); color: var(--ad-accent); } |
| .ad-quick-card-icon.reports { background: rgba(168,85,247,0.12); color: #a855f7; } |
| |
| .ad-quick-card-body { |
| flex: 1; |
| min-width: 0; |
| } |
| |
| .ad-quick-card-title { |
| font-size: 0.9rem; |
| font-weight: 600; |
| margin-bottom: 2px; |
| } |
| |
| .ad-quick-card-desc { |
| font-size: 0.72rem; |
| color: #555; |
| } |
| |
| .ad-quick-card-arrow { |
| font-size: 1.1rem; |
| color: #444; |
| flex-shrink: 0; |
| } |
| |
| |
| .ad-activity-feed { |
| background: var(--ad-surface); |
| border: 1px solid var(--ad-border); |
| border-radius: 10px; |
| overflow: hidden; |
| } |
| |
| .ad-activity-header { |
| padding: 14px 16px; |
| border-bottom: 1px solid var(--ad-border); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| .ad-activity-header h2 { |
| font-size: 0.95rem; |
| font-weight: 600; |
| margin: 0; |
| color: #fff; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .ad-activity-count { |
| font-size: 0.78rem; |
| color: var(--ad-success); |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| } |
| |
| .ad-activity-dot { |
| width: 7px; |
| height: 7px; |
| border-radius: 50%; |
| background: var(--ad-success); |
| animation: adPulse 2s ease-in-out infinite; |
| display: inline-block; |
| } |
| |
| @keyframes adPulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.3; } |
| } |
| |
| .ad-activity-list { |
| max-height: 400px; |
| overflow-y: auto; |
| } |
| |
| .ad-activity-item { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 10px 16px; |
| border-bottom: 1px solid rgba(255,255,255,0.03); |
| transition: background 0.15s; |
| } |
| |
| .ad-activity-item:last-child { |
| border-bottom: none; |
| } |
| |
| .ad-activity-item:hover { |
| background: rgba(255,255,255,0.02); |
| } |
| |
| .ad-activity-avatar { |
| width: 30px; |
| height: 30px; |
| border-radius: 50%; |
| object-fit: cover; |
| flex-shrink: 0; |
| background: #222; |
| } |
| |
| .ad-activity-avatar-placeholder { |
| width: 30px; |
| height: 30px; |
| border-radius: 50%; |
| background: #222; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 0.75rem; |
| font-weight: 700; |
| color: #fff; |
| flex-shrink: 0; |
| } |
| |
| .ad-activity-info { |
| flex: 1; |
| min-width: 0; |
| } |
| |
| .ad-activity-username { |
| font-size: 0.82rem; |
| font-weight: 500; |
| color: #ddd; |
| } |
| |
| .ad-activity-page { |
| font-size: 0.7rem; |
| color: #555; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| max-width: 280px; |
| } |
| |
| .ad-activity-time { |
| font-size: 0.65rem; |
| color: #444; |
| white-space: nowrap; |
| flex-shrink: 0; |
| } |
| |
| .ad-empty { |
| padding: 30px; |
| text-align: center; |
| color: #555; |
| font-size: 0.85rem; |
| } |
| |
| .ad-loading { |
| padding: 30px; |
| text-align: center; |
| color: var(--ad-muted); |
| } |
| |
| .ad-spinner { |
| display: inline-block; |
| width: 22px; |
| height: 22px; |
| border: 2px solid rgba(255,255,255,0.1); |
| border-top-color: var(--ad-accent); |
| border-radius: 50%; |
| animation: adSpin 0.6s linear infinite; |
| } |
| |
| @keyframes adSpin { |
| to { transform: rotate(360deg); } |
| } |
| |
| |
| .ad-bug-stats { |
| background: var(--ad-surface); |
| border: 1px solid var(--ad-border); |
| border-radius: 10px; |
| padding: 16px; |
| } |
| |
| .ad-bug-stats h2 { |
| font-size: 0.9rem; |
| font-weight: 600; |
| margin: 0 0 12px 0; |
| color: #fff; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| .ad-bug-bar-chart { |
| display: flex; |
| gap: 12px; |
| align-items: flex-end; |
| } |
| |
| .ad-bug-bar { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 4px; |
| } |
| |
| .ad-bug-bar-fill { |
| width: 100%; |
| max-width: 60px; |
| border-radius: 5px 5px 0 0; |
| transition: height 0.5s ease; |
| min-height: 3px; |
| } |
| |
| .ad-bug-bar-fill.open { background: #ef4444; } |
| .ad-bug-bar-fill.in_progress { background: var(--ad-accent); } |
| .ad-bug-bar-fill.resolved { background: var(--ad-success); } |
| |
| .ad-bug-bar-count { |
| font-size: 0.85rem; |
| font-weight: 600; |
| color: #fff; |
| } |
| |
| .ad-bug-bar-label { |
| font-size: 0.65rem; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.04em; |
| color: var(--ad-muted); |
| } |
| |
| |
| .ad-toast { |
| position: fixed; |
| bottom: 24px; |
| right: 24px; |
| background: var(--ad-surface); |
| border: 1px solid var(--ad-border); |
| border-radius: 10px; |
| padding: 12px 20px; |
| color: #bbb; |
| font-size: 0.85rem; |
| box-shadow: 0 10px 30px rgba(0,0,0,0.5); |
| z-index: 9999; |
| transform: translateY(20px); |
| opacity: 0; |
| transition: all 0.3s; |
| pointer-events: none; |
| } |
| |
| .ad-toast.visible { |
| transform: translateY(0); |
| opacity: 1; |
| } |
| </style> |
| {% endblock %} |
|
|
| {% block content %} |
| <div class="ad-page"> |
| <div class="ad-header"> |
| <h1> |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--ad-accent);"> |
| <rect x="3" y="3" width="7" height="7"></rect> |
| <rect x="14" y="3" width="7" height="7"></rect> |
| <rect x="14" y="14" width="7" height="7"></rect> |
| <rect x="3" y="14" width="7" height="7"></rect> |
| </svg> |
| Admin Dashboard |
| </h1> |
| <p> |
| <span class="ad-activity-dot"></span> |
| Live overview — stats and activity update every 15s |
| </p> |
| </div> |
|
|
| |
| <div class="ad-stats-grid" id="ad-stats-grid"> |
| <div class="ad-stat-card online"> |
| <div class="ad-stat-number" id="stat-online-users">-</div> |
| <div class="ad-stat-label">Online Now</div> |
| </div> |
| <div class="ad-stat-card users"> |
| <div class="ad-stat-number" id="stat-total-users">-</div> |
| <div class="ad-stat-label">Total Users</div> |
| </div> |
| <div class="ad-stat-card bugs"> |
| <div class="ad-stat-number" id="stat-total-bugs">-</div> |
| <div class="ad-stat-label">Total Reports</div> |
| </div> |
| <div class="ad-stat-card open"> |
| <div class="ad-stat-number" id="stat-open-bugs">-</div> |
| <div class="ad-stat-label">Open Reports</div> |
| </div> |
| <div class="ad-stat-card resolved"> |
| <div class="ad-stat-number" id="stat-resolved-bugs">-</div> |
| <div class="ad-stat-label">Resolved</div> |
| </div> |
| <div class="ad-stat-card live"> |
| <div class="ad-stat-number" id="stat-live-time">--:--:--</div> |
| <div class="ad-stat-label">Uptime</div> |
| </div> |
| </div> |
|
|
| |
| <div class="ad-quick-cards"> |
| <a href="/admin/bug-reports" class="ad-quick-card"> |
| <div class="ad-quick-card-icon bugs"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path> |
| <line x1="4" y1="22" x2="4" y2="15"></line> |
| </svg> |
| </div> |
| <div class="ad-quick-card-body"> |
| <div class="ad-quick-card-title">Bug Reports</div> |
| <div class="ad-quick-card-desc">View, reply, and manage all bug reports</div> |
| </div> |
| <span class="ad-quick-card-arrow">→</span> |
| </a> |
| <a href="/admin/announcements" class="ad-quick-card"> |
| <div class="ad-quick-card-icon announce"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon> |
| </svg> |
| </div> |
| <div class="ad-quick-card-body"> |
| <div class="ad-quick-card-title">Announcements</div> |
| <div class="ad-quick-card-desc">Create and manage site-wide announcements</div> |
| </div> |
| <span class="ad-quick-card-arrow">→</span> |
| </a> |
| </div> |
|
|
| |
| <div class="ad-grid-2"> |
| |
| <div class="ad-activity-feed"> |
| <div class="ad-activity-header"> |
| <h2> |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--ad-success);"> |
| <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path> |
| <circle cx="9" cy="7" r="4"></circle> |
| <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path> |
| <path d="M16 3.13a4 4 0 0 1 0 7.75"></path> |
| </svg> |
| Live Activity |
| </h2> |
| <span class="ad-activity-count"> |
| <span class="ad-activity-dot"></span> |
| <span id="ad-online-count">0</span> online |
| </span> |
| </div> |
| <div class="ad-activity-list" id="ad-activity-list"> |
| <div class="ad-loading"><div class="ad-spinner"></div></div> |
| </div> |
| </div> |
|
|
| |
| <div style="display: flex; flex-direction: column; gap: 12px;"> |
| <div class="ad-bug-stats"> |
| <h2> |
| <span> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--ad-error); vertical-align: middle; margin-right: 5px;"> |
| <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path> |
| <line x1="4" y1="22" x2="4" y2="15"></line> |
| </svg> |
| Reports |
| </span> |
| <a href="/admin/bug-reports" style="font-size:0.75rem;color:var(--ad-accent);text-decoration:none;font-weight:500;">Manage →</a> |
| </h2> |
| <div class="ad-bug-bar-chart"> |
| <div class="ad-bug-bar"> |
| <div class="ad-bug-bar-count" id="bug-bar-open-count">0</div> |
| <div class="ad-bug-bar-fill open" id="bug-bar-open" style="height: 3px;"></div> |
| <div class="ad-bug-bar-label">Open</div> |
| </div> |
| <div class="ad-bug-bar"> |
| <div class="ad-bug-bar-count" id="bug-bar-progress-count">0</div> |
| <div class="ad-bug-bar-fill in_progress" id="bug-bar-progress" style="height: 3px;"></div> |
| <div class="ad-bug-bar-label">In Prog</div> |
| </div> |
| <div class="ad-bug-bar"> |
| <div class="ad-bug-bar-count" id="bug-bar-resolved-count">0</div> |
| <div class="ad-bug-bar-fill resolved" id="bug-bar-resolved" style="height: 3px;"></div> |
| <div class="ad-bug-bar-label">Resolved</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="ad-toast" id="ad-toast"></div> |
| {% endblock %} |
|
|
| {% block extra_js %} |
| <script> |
| (function() { |
| const toastEl = document.getElementById('ad-toast'); |
| |
| function showToast(msg) { |
| toastEl.textContent = msg; |
| toastEl.classList.add('visible'); |
| setTimeout(() => toastEl.classList.remove('visible'), 3000); |
| } |
| |
| function updateBar(elementId, count, maxCount) { |
| const el = document.getElementById(elementId); |
| if (!el) return; |
| const ratio = maxCount > 0 ? count / maxCount : 0; |
| el.style.height = Math.max(3, ratio * 80) + 'px'; |
| } |
| |
| function formatTime(seconds) { |
| const h = Math.floor(seconds / 3600).toString().padStart(2, '0'); |
| const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); |
| const s = (seconds % 60).toString().padStart(2, '0'); |
| return `${h}:${m}:${s}`; |
| } |
| |
| function timeAgo(isoString) { |
| if (!isoString) return ''; |
| const diff = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); |
| if (diff < 60) return 'just now'; |
| if (diff < 120) return '1m ago'; |
| if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; |
| return Math.floor(diff / 3600) + 'h ago'; |
| } |
| |
| function getPageLabel(url) { |
| if (!url || url === '/') return 'Home'; |
| |
| const parts = url.split('/').filter(Boolean); |
| if (parts.length === 0) return 'Home'; |
| const last = decodeURIComponent(parts[parts.length - 1]) |
| .replace(/-/g, ' ') |
| .replace(/\b\w/g, c => c.toUpperCase()); |
| return last.substring(0, 30); |
| } |
| |
| async function loadDashboard() { |
| try { |
| const res = await fetch('/api/admin/dashboard'); |
| const data = await res.json(); |
| |
| if (!data.success) { |
| showToast('Failed to load dashboard'); |
| return; |
| } |
| |
| const now = new Date(); |
| const timeStr = now.toLocaleTimeString(); |
| |
| |
| document.getElementById('stat-total-users').textContent = data.users.total; |
| document.getElementById('stat-online-users').textContent = data.users.active_count || 0; |
| |
| |
| const bugs = data.bugs; |
| document.getElementById('stat-total-bugs').textContent = bugs.total; |
| document.getElementById('stat-open-bugs').textContent = bugs.open; |
| document.getElementById('stat-resolved-bugs').textContent = bugs.resolved; |
| |
| |
| var uptimeSeconds = 0; |
| if (data.server_start_time) { |
| uptimeSeconds = Math.floor(Date.now() / 1000 - data.server_start_time); |
| } |
| document.getElementById('stat-live-time').textContent = formatTime(uptimeSeconds); |
| |
| |
| const maxCount = Math.max(bugs.open, bugs.in_progress, bugs.resolved, 1); |
| document.getElementById('bug-bar-open-count').textContent = bugs.open; |
| document.getElementById('bug-bar-progress-count').textContent = bugs.in_progress; |
| document.getElementById('bug-bar-resolved-count').textContent = bugs.resolved; |
| updateBar('bug-bar-open', bugs.open, maxCount); |
| updateBar('bug-bar-progress', bugs.in_progress, maxCount); |
| updateBar('bug-bar-resolved', bugs.resolved, maxCount); |
| |
| |
| document.getElementById('ad-online-count').textContent = data.users.active_count || 0; |
| |
| |
| const activityList = document.getElementById('ad-activity-list'); |
| const activeUsers = data.users.active || []; |
| |
| if (activeUsers.length === 0) { |
| activityList.innerHTML = '<div class="ad-empty">No users currently active.</div>'; |
| } else { |
| activityList.innerHTML = activeUsers.map(u => { |
| var isGuest = u.is_anonymous === true; |
| var initial = (u.username || '?')[0].toUpperCase(); |
| var avatarHtml; |
| if (isGuest) { |
| avatarHtml = `<div class="ad-activity-avatar-placeholder" style="background:#1a1a2e;color:#888;"> |
| <svg width="14" height="14" 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"></path> |
| <circle cx="9" cy="7" r="4"></circle> |
| </svg> |
| </div>`; |
| } else if (u.avatar) { |
| avatarHtml = `<img class="ad-activity-avatar" src="${u.avatar}" alt="${u.username}" onerror="this.style.display='none'">`; |
| } else { |
| avatarHtml = `<div class="ad-activity-avatar-placeholder">${initial}</div>`; |
| } |
| var usernameHtml = isGuest |
| ? `<span style="color:#666;">${u.username}</span>` |
| : `<span>${u.username}</span>`; |
| var pageLabel = u.page_title || getPageLabel(u.page_url); |
| var timeLabel = timeAgo(u.last_seen); |
| |
| return ` |
| <div class="ad-activity-item"> |
| ${avatarHtml} |
| <div class="ad-activity-info"> |
| <div class="ad-activity-username">${usernameHtml}</div> |
| <div class="ad-activity-page" title="${u.page_url || ''}">📍 ${pageLabel}</div> |
| </div> |
| <div class="ad-activity-time">${timeLabel}</div> |
| </div> |
| `; |
| }).join(''); |
| } |
| |
| } catch (e) { |
| showToast('Network error loading dashboard.'); |
| document.getElementById('ad-activity-list').innerHTML = '<div class="ad-empty">Network error</div>'; |
| } |
| } |
| |
| |
| loadDashboard(); |
| setInterval(loadDashboard, 15000); |
| })(); |
| </script> |
| {% endblock %} |
|
|