Download api/templates/shared/admin_dashboard.html from mwask/anicove: direct link, hf CLI and curl.
- Browser
- Download file 26.6 kB
-
https://huggingface.co/spaces/mwask/anicove/resolve/main/api/templates/shared/admin_dashboard.html
- Command line
-
hf download hf://spaces/mwask/anicove/api/templates/shared/admin_dashboard.html
-
curl -L -o admin_dashboard.html https://huggingface.co/spaces/mwask/anicove/resolve/main/api/templates/shared/admin_dashboard.html
26.6 kB
| {% 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; | |
| } | |
| /* Stats grid */ | |
| .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); | |
| } | |
| /* Sections grid */ | |
| .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; } | |
| } | |
| /* Quick Admin Cards */ | |
| .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; | |
| } | |
| /* Live Activity Feed */ | |
| .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); } | |
| } | |
| /* Bug bar chart */ | |
| .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); | |
| } | |
| /* Toast */ | |
| .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; | |
| } | |
| /* Activity Trend */ | |
| .ad-trend-card { | |
| background: var(--ad-surface); | |
| border: 1px solid var(--ad-border); | |
| border-radius: 10px; | |
| margin-top: 12px; | |
| padding-bottom: 12px; | |
| } | |
| .ad-split-row { | |
| display: flex; | |
| gap: 8px; | |
| padding: 10px 16px 0; | |
| } | |
| .ad-split-chip { | |
| font-size: 0.72rem; | |
| padding: 4px 10px; | |
| border-radius: 999px; | |
| border: 1px solid var(--ad-border); | |
| color: #aaa; | |
| } | |
| .ad-split-chip.registered { border-color: rgba(76,175,80,0.4); color: var(--ad-success); } | |
| .ad-split-chip.guest { border-color: rgba(124,141,255,0.4); color: #7c8dff; } | |
| .ad-trend-chart { | |
| display: flex; | |
| align-items: flex-end; | |
| gap: 1px; | |
| height: 90px; | |
| padding: 12px 16px 0; | |
| } | |
| .ad-trend-bar { | |
| flex: 1; | |
| min-width: 1px; | |
| background: linear-gradient(180deg, var(--ad-accent), rgba(139,92,246,0.25)); | |
| border-radius: 1px 1px 0 0; | |
| } | |
| .ad-trend-bar:hover { filter: brightness(1.35); } | |
| .ad-trend-bar.zero { background: rgba(255,255,255,0.05); } | |
| .ad-trend-foot { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.62rem; | |
| color: #555; | |
| padding: 6px 16px 0; | |
| } | |
| .ad-top-pages { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| padding: 10px 16px 0; | |
| } | |
| .ad-page-chip { | |
| font-size: 0.7rem; | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid var(--ad-border); | |
| padding: 3px 8px; | |
| border-radius: 6px; | |
| color: #999; | |
| max-width: 240px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .ad-page-chip b { color: #ccc; font-weight: 600; } | |
| </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> | |
| <!-- Stats Cards --> | |
| <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> | |
| <!-- Quick Admin Actions --> | |
| <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> | |
| <!-- Main Grid --> | |
| <div class="ad-grid-2"> | |
| <!-- Live Activity Feed --> | |
| <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> | |
| <!-- Bug Reports Summary --> | |
| <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> | |
| <!-- Activity Trend & Split --> | |
| <div class="ad-trend-card"> | |
| <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-accent);"> | |
| <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> | |
| </svg> | |
| Activity Trend | |
| <span style="font-size:0.7rem;color:#666;font-weight:400;">unique visitors · last 24h</span> | |
| </h2> | |
| <span class="ad-activity-count"> | |
| <span class="ad-activity-dot"></span> | |
| <span id="ad-trend-split">0 online</span> | |
| </span> | |
| </div> | |
| <div class="ad-split-row"> | |
| <span class="ad-split-chip registered" id="ad-split-registered">Registered: 0</span> | |
| <span class="ad-split-chip guest" id="ad-split-guests">Guests: 0</span> | |
| </div> | |
| <div class="ad-trend-chart" id="ad-trend-chart"></div> | |
| <div class="ad-trend-foot"><span id="ad-trend-start">–</span><span>now</span></div> | |
| <div class="ad-top-pages" id="ad-top-pages"></div> | |
| </div> | |
| <!-- Toast --> | |
| <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'; | |
| // Clean up the URL for display | |
| 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(); | |
| // Stats | |
| document.getElementById('stat-total-users').textContent = data.users.total; | |
| document.getElementById('stat-online-users').textContent = data.users.active_count || 0; | |
| // Bug stats | |
| 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; | |
| // Uptime — calculate elapsed seconds since server started | |
| 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); | |
| // Bar chart | |
| 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); | |
| // Online count | |
| document.getElementById('ad-online-count').textContent = data.users.active_count || 0; | |
| // ── Activity summary: split, trend chart, top pages ── | |
| const activity = data.activity || {}; | |
| document.getElementById('ad-trend-split').textContent = (activity.active_count || 0) + ' online'; | |
| document.getElementById('ad-split-registered').textContent = 'Registered: ' + (activity.registered || 0); | |
| document.getElementById('ad-split-guests').textContent = 'Guests: ' + (activity.guests || 0); | |
| const trend = activity.trend || []; | |
| const trendChart = document.getElementById('ad-trend-chart'); | |
| const fmtBucket = (iso) => { | |
| if (!iso) return '–'; | |
| const d = new Date(iso); | |
| return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0'); | |
| }; | |
| if (trend.length === 0) { | |
| trendChart.innerHTML = '<div class="ad-empty" style="padding:14px;">No trend data yet — appears once visitors start browsing.</div>'; | |
| document.getElementById('ad-trend-start').textContent = '–'; | |
| } else { | |
| // Downsample to at most ~150 bars to keep the DOM light | |
| const stride = Math.max(1, Math.ceil(trend.length / 150)); | |
| const sampled = trend.filter((_, i) => i % stride === 0); | |
| const max = Math.max(...sampled.map(t => t.unique_users || 0), 1); | |
| trendChart.innerHTML = sampled.map(t => { | |
| const h = (t.unique_users || 0) > 0 ? Math.max(4, (t.unique_users / max) * 100) : 0; | |
| const cls = (t.unique_users || 0) === 0 ? 'ad-trend-bar zero' : 'ad-trend-bar'; | |
| return `<div class="${cls}" style="height:${h}%;" title="${fmtBucket(t.bucket)} — ${t.unique_users} active (${t.registered} registered, ${t.pings} pings)"></div>`; | |
| }).join(''); | |
| document.getElementById('ad-trend-start').textContent = fmtBucket(sampled[0] && sampled[0].bucket); | |
| } | |
| const topPages = activity.top_pages || []; | |
| const pagesEl = document.getElementById('ad-top-pages'); | |
| pagesEl.innerHTML = topPages.length | |
| ? topPages.map(p => { | |
| const label = (p.page_title || p.page_url || 'Home').substring(0, 40); | |
| return `<span class="ad-page-chip" title="${p.page_url || ''}"><b>${p.count}</b> · ${label}</span>`; | |
| }).join('') | |
| : ''; | |
| // Live Activity Feed | |
| 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>'; | |
| } | |
| } | |
| // Load immediately, then poll every 15 seconds | |
| loadDashboard(); | |
| setInterval(loadDashboard, 15000); | |
| })(); | |
| </script> | |
| {% endblock %} | |