anicove / api /templates /shared /admin_bug_reports.html
mwask's picture
tracking and nav
79d1711
Raw
History Blame Contribute Delete
23.8 kB
{% extends "shared/base.html" %}
{% block title %}Bug Reports Admin - AniCove{% endblock %}
{% block meta_description %}Admin panel for managing bug reports.{% endblock %}
{% block extra_css %}
<style>
:root {
--abg-bg: #080808;
--abg-surface: #111;
--abg-border: #1e1e1e;
--abg-text: #e0e0e0;
--abg-muted: #777;
--abg-accent: #3b82f6;
--abg-success: #22c55e;
--abg-warning: #f59e0b;
--abg-error: #ef4444;
}
.abg-page {
max-width: 1100px;
margin: 0 auto;
padding: 32px 20px;
}
.abg-header {
margin-bottom: 28px;
}
.abg-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 4px 0;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.abg-header p {
font-size: 0.85rem;
color: var(--abg-muted);
margin: 0;
}
/* Stats bar */
.abg-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 24px;
}
.abg-stat-card {
background: var(--abg-surface);
border: 1px solid var(--abg-border);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
}
.abg-stat-num {
font-size: 1.6rem;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.abg-stat-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--abg-muted);
margin-top: 2px;
}
/* Filters */
.abg-filters {
display: flex;
gap: 8px;
margin-bottom: 18px;
flex-wrap: wrap;
align-items: center;
}
.abg-filter-btn {
padding: 6px 16px;
border-radius: 999px;
border: 1px solid var(--abg-border);
background: transparent;
color: #999;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.abg-filter-btn:hover {
border-color: rgba(255,255,255,0.15);
color: #ddd;
}
.abg-filter-btn.active {
border-color: var(--abg-accent);
background: rgba(59,130,246,0.12);
color: var(--abg-accent);
}
.abg-filter-btn[data-status="open"].active {
border-color: #ef4444;
background: rgba(239,68,68,0.12);
color: #ef4444;
}
.abg-filter-btn[data-status="resolved"].active {
border-color: #22c55e;
background: rgba(34,197,94,0.12);
color: #22c55e;
}
/* Broadcast bar */
.abg-broadcast-bar {
display: none;
background: rgba(59,130,246,0.08);
border: 1px solid rgba(59,130,246,0.2);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 16px;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.abg-broadcast-bar.visible {
display: flex;
}
.abg-broadcast-count {
font-size: 0.82rem;
color: var(--abg-accent);
font-weight: 600;
white-space: nowrap;
}
.abg-broadcast-input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--abg-border);
border-radius: 8px;
color: #fff;
font-size: 0.85rem;
font-family: inherit;
}
.abg-broadcast-input:focus {
outline: none;
border-color: var(--abg-accent);
}
.abg-broadcast-btn {
padding: 8px 18px;
background: var(--abg-accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.2s;
white-space: nowrap;
}
.abg-broadcast-btn:hover {
background: #2563eb;
}
.abg-broadcast-cancel {
padding: 8px 14px;
background: transparent;
color: #999;
border: 1px solid var(--abg-border);
border-radius: 8px;
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
transition: color 0.2s;
}
.abg-broadcast-cancel:hover {
color: #fff;
}
/* Report list */
.abg-report-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.abg-report {
background: var(--abg-surface);
border: 1px solid var(--abg-border);
border-radius: 10px;
padding: 16px 18px;
transition: border-color 0.2s;
}
.abg-report:hover {
border-color: rgba(255,255,255,0.08);
}
.abg-report-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.abg-report-title-area {
flex: 1;
min-width: 0;
}
.abg-report-title {
font-size: 0.92rem;
font-weight: 600;
color: #ddd;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.abg-report-category {
font-size: 0.65rem;
font-weight: 600;
padding: 1px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.abg-report-category.bug {
background: rgba(239,68,68,0.12);
color: #ef4444;
}
.abg-report-category.feature {
background: rgba(34,197,94,0.12);
color: #22c55e;
}
.abg-report-category.other {
background: rgba(245,158,11,0.12);
color: #f59e0b;
}
.abg-report-author {
font-size: 0.75rem;
color: #555;
margin-top: 2px;
}
.abg-report-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
}
.abg-report-checkbox {
width: 18px;
height: 18px;
accent-color: var(--abg-accent);
cursor: pointer;
}
.abg-report-body {
font-size: 0.83rem;
color: #999;
line-height: 1.6;
margin-bottom: 10px;
white-space: pre-wrap;
}
.abg-report-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.abg-status-select {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--abg-border);
background: rgba(0,0,0,0.3);
color: #ccc;
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
}
.abg-status-select:focus {
outline: none;
border-color: var(--abg-accent);
}
.abg-report-date {
font-size: 0.72rem;
color: #555;
}
.abg-report-page-url {
font-size: 0.72rem;
color: #555;
background: rgba(255,255,255,0.03);
padding: 1px 6px;
border-radius: 4px;
}
/* Replies */
.abg-replies {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,0.04);
}
.abg-reply {
background: rgba(59,130,246,0.06);
border: 1px solid rgba(59,130,246,0.12);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 8px;
}
.abg-reply:last-child {
margin-bottom: 0;
}
.abg-reply-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
font-size: 0.72rem;
color: #555;
}
.abg-reply-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
background: rgba(59,130,246,0.15);
color: var(--abg-accent);
text-transform: uppercase;
}
.abg-reply-badge.broadcast {
background: rgba(245,158,11,0.15);
color: #f59e0b;
}
.abg-reply-body {
font-size: 0.83rem;
color: #bbb;
line-height: 1.5;
}
/* Reply form */
.abg-reply-form {
display: flex;
gap: 8px;
margin-top: 10px;
align-items: flex-end;
}
.abg-reply-input {
flex: 1;
padding: 8px 12px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--abg-border);
border-radius: 8px;
color: #fff;
font-size: 0.83rem;
font-family: inherit;
resize: vertical;
min-height: 36px;
}
.abg-reply-input:focus {
outline: none;
border-color: var(--abg-accent);
}
.abg-reply-send {
padding: 8px 14px;
background: var(--abg-accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: background 0.2s;
}
.abg-reply-send:hover {
background: #2563eb;
}
/* Pagination */
.abg-pagination {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 24px;
}
.abg-page-btn {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--abg-border);
background: transparent;
color: #999;
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
}
.abg-page-btn:hover {
border-color: rgba(255,255,255,0.15);
color: #fff;
}
.abg-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Toast */
.abg-toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--abg-surface);
border: 1px solid var(--abg-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;
}
.abg-toast.visible {
transform: translateY(0);
opacity: 1;
}
@media (max-width: 600px) {
.abg-page { padding: 20px 14px; }
.abg-report-header { flex-direction: column; }
.abg-report-actions { align-self: flex-end; }
}
</style>
{% endblock %}
{% block content %} <div class="abg-page">
<a href="/admin" style="display:inline-flex;align-items:center;gap:5px;font-size:0.78rem;color:#555;text-decoration:none;margin-bottom:14px;transition:color 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#555'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
Back to Dashboard
</a>
<div class="abg-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(--abg-error);">
<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>
Bug Reports
<span style="font-size: 0.75rem; font-weight: 400; color: #555;">Admin Panel</span>
</h1>
<p>Manage, reply to, and track bug reports from users.</p>
</div>
<!-- Stats -->
<div class="abg-stats" id="abg-stats">
<div class="abg-stat-card">
<div class="abg-stat-num" id="stat-total">-</div>
<div class="abg-stat-label">Total</div>
</div>
<div class="abg-stat-card">
<div class="abg-stat-num" id="stat-open" style="color:#ef4444;">-</div>
<div class="abg-stat-label">Open</div>
</div>
<div class="abg-stat-card">
<div class="abg-stat-num" id="stat-progress" style="color:#3b82f6;">-</div>
<div class="abg-stat-label">In Progress</div>
</div>
<div class="abg-stat-card">
<div class="abg-stat-num" id="stat-resolved" style="color:#22c55e;">-</div>
<div class="abg-stat-label">Resolved</div>
</div>
</div>
<!-- Filters -->
<div class="abg-filters">
<button class="abg-filter-btn active" data-status="" onclick="filterReports(this, '')">All</button>
<button class="abg-filter-btn" data-status="open" onclick="filterReports(this, 'open')">🐛 Open</button>
<button class="abg-filter-btn" data-status="in_progress" onclick="filterReports(this, 'in_progress')">🔧 In Progress</button>
<button class="abg-filter-btn" data-status="resolved" onclick="filterReports(this, 'resolved')">✅ Resolved</button>
<button class="abg-filter-btn" data-status="closed" onclick="filterReports(this, 'closed')">📁 Closed</button>
</div>
<!-- Broadcast Bar -->
<div class="abg-broadcast-bar" id="abg-broadcast-bar">
<span class="abg-broadcast-count" id="abg-broadcast-count">0 selected</span>
<textarea class="abg-broadcast-input" id="abg-broadcast-input" placeholder="Write a reply to send to all selected reports..." rows="1"></textarea>
<button class="abg-broadcast-btn" onclick="sendBroadcastReply()">Send to All</button>
<button class="abg-broadcast-cancel" onclick="clearSelection()">Cancel</button>
</div>
<!-- Reports -->
<div class="abg-report-list" id="abg-report-list">
<div style="text-align:center;padding:40px;color:#555;">
<div class="bug-spinner" style="display:inline-block;width:24px;height:24px;border:2px solid rgba(255,255,255,0.1);border-top-color:#3b82f6;border-radius:50%;animation:bugSpin 0.6s linear infinite;margin-bottom:10px;"></div>
<div>Loading reports...</div>
</div>
</div>
<!-- Pagination -->
<div class="abg-pagination" id="abg-pagination" style="display:none;"></div>
</div>
<style>
@keyframes bugSpin { to { transform: rotate(360deg); } }
</style>
{% endblock %}
{% block extra_js %}
<script>
let currentFilter = '';
let currentPage = 1;
let selectedReports = new Set();
function showToast(msg) {
let el = document.getElementById('abg-toast');
if (!el) {
el = document.createElement('div');
el.id = 'abg-toast';
el.className = 'abg-toast';
document.body.appendChild(el);
}
el.textContent = msg;
el.classList.add('visible');
setTimeout(() => el.classList.remove('visible'), 3000);
}
async function loadStats() {
try {
const res = await fetch('/api/bug-reports/admin/stats');
const data = await res.json();
if (data.success) {
document.getElementById('stat-total').textContent = data.stats.total;
document.getElementById('stat-open').textContent = data.stats.open;
document.getElementById('stat-progress').textContent = data.stats.in_progress;
document.getElementById('stat-resolved').textContent = data.stats.resolved;
}
} catch (e) {}
}
function filterReports(btn, status) {
document.querySelectorAll('.abg-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = status;
currentPage = 1;
selectedReports.clear();
updateBroadcastBar();
loadReports();
}
function toggleSelectReport(id, checkbox) {
if (checkbox.checked) {
selectedReports.add(id);
} else {
selectedReports.delete(id);
}
updateBroadcastBar();
}
function updateBroadcastBar() {
const bar = document.getElementById('abg-broadcast-bar');
const count = selectedReports.size;
document.getElementById('abg-broadcast-count').textContent = count + ' selected';
if (count > 0) {
bar.classList.add('visible');
} else {
bar.classList.remove('visible');
}
}
function clearSelection() {
selectedReports.clear();
document.querySelectorAll('.abg-report-checkbox').forEach(cb => cb.checked = false);
document.getElementById('abg-broadcast-input').value = '';
updateBroadcastBar();
}
async function sendBroadcastReply() {
const body = document.getElementById('abg-broadcast-input').value.trim();
if (!body) {
showToast('Please write a reply message.');
return;
}
if (selectedReports.size === 0) {
showToast('No reports selected.');
return;
}
const ids = Array.from(selectedReports);
try {
const res = await fetch('/api/bug-reports/broadcast-reply', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ report_ids: ids, body }),
});
const data = await res.json();
if (data.success) {
showToast(`Reply sent to ${data.replied_to} report(s)!`);
clearSelection();
loadReports();
loadStats();
} else {
showToast(data.message || 'Failed to send.');
}
} catch (e) {
showToast('Network error.');
}
}
async function sendIndividualReply(reportId) {
const input = document.getElementById('reply-input-' + reportId);
const body = input.value.trim();
if (!body) return;
try {
const res = await fetch(`/api/bug-reports/${reportId}/reply`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body }),
});
const data = await res.json();
if (data.success) {
input.value = '';
showToast('Reply sent!');
loadReports();
loadStats();
} else {
showToast(data.message || 'Failed to send.');
}
} catch (e) {
showToast('Network error.');
}
}
async function updateStatus(reportId, newStatus) {
try {
const res = await fetch(`/api/bug-reports/${reportId}/status`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ status: newStatus }),
});
const data = await res.json();
if (data.success) {
showToast('Status updated!');
loadReports();
loadStats();
}
} catch (e) {}
}
async function loadReports() {
const listEl = document.getElementById('abg-report-list');
listEl.innerHTML = '<div style="text-align:center;padding:40px;color:#555;"><div class="bug-spinner" style="display:inline-block;width:24px;height:24px;border:2px solid rgba(255,255,255,0.1);border-top-color:#3b82f6;border-radius:50%;animation:bugSpin 0.6s linear infinite;margin-bottom:10px;"></div><div>Loading...</div></div>';
try {
let url = `/api/bug-reports/admin/list?page=${currentPage}`;
if (currentFilter) url += `&status=${currentFilter}`;
const res = await fetch(url);
const data = await res.json();
if (!data.success) {
listEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--abg-error);">Failed to load reports.</div>';
return;
}
if (data.reports.length === 0) {
listEl.innerHTML = '<div style="text-align:center;padding:60px 20px;"><div style="font-size:2.5rem;margin-bottom:10px;opacity:0.5;">📭</div><div style="color:#555;">No reports found.</div></div>';
document.getElementById('abg-pagination').style.display = 'none';
return;
}
listEl.innerHTML = data.reports.map(r => {
const statusLabels = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' };
const date = r.created_at ? new Date(r.created_at).toLocaleDateString() : '';
const isSelected = selectedReports.has(r._id);
let repliesHtml = '';
const allMessages = [];
(r.admin_replies || []).forEach(rep => allMessages.push({ ...rep, type: 'admin' }));
(r.user_replies || []).forEach(rep => allMessages.push({ ...rep, type: 'user' }));
allMessages.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
if (allMessages.length > 0) {
repliesHtml = '<div class="abg-replies">' + allMessages.map(msg => {
const repDate = msg.created_at ? new Date(msg.created_at).toLocaleString() : '';
if (msg.type === 'admin') {
return `
<div class="abg-reply">
<div class="abg-reply-header">
<span>Admin ${repDate}</span>
${msg.is_broadcast ? '<span class="abg-reply-badge broadcast">Broadcast</span>' : '<span class="abg-reply-badge">Direct</span>'}
</div>
<div class="abg-reply-body">${msg.body}</div>
</div>
`;
} else {
return `
<div class="abg-reply" style="border-color: rgba(255,255,255,0.08); background: rgba(255,255,255,0.03);">
<div class="abg-reply-header">
<span>${r.author} (user) ${repDate}</span>
<span class="abg-reply-badge" style="background: rgba(255,255,255,0.08); color: #999;">User Reply</span>
</div>
<div class="abg-reply-body">${msg.body}</div>
</div>
`;
}
}).join('') + '</div>';
}
return `
<div class="abg-report">
<div class="abg-report-header">
<div class="abg-report-title-area">
<div class="abg-report-title">
${r.title}
<span class="abg-report-category ${r.category}">${r.category}</span>
</div>
<div class="abg-report-author">by ${r.author}</div>
</div>
<div class="abg-report-actions">
<input type="checkbox" class="abg-report-checkbox" ${isSelected ? 'checked' : ''} onchange="toggleSelectReport('${r._id}', this)" title="Select for broadcast reply">
</div>
</div>
<div class="abg-report-body">${r.body}</div>
<div class="abg-report-meta">
<select class="abg-status-select" onchange="updateStatus('${r._id}', this.value)">
<option value="open" ${r.status === 'open' ? 'selected' : ''}>🟢 Open</option>
<option value="in_progress" ${r.status === 'in_progress' ? 'selected' : ''}>🔧 In Progress</option>
<option value="resolved" ${r.status === 'resolved' ? 'selected' : ''}>✅ Resolved</option>
<option value="closed" ${r.status === 'closed' ? 'selected' : ''}>📁 Closed</option>
</select>
<span class="abg-report-date">${date}</span>
${r.page_url ? `<span class="abg-report-page-url">📍 ${r.page_url}</span>` : ''}
</div>
${repliesHtml}
<div class="abg-reply-form">
<textarea class="abg-reply-input" id="reply-input-${r._id}" placeholder="Write a reply..." rows="1"></textarea>
<button class="abg-reply-send" onclick="sendIndividualReply('${r._id}')">Reply</button>
</div>
</div>
`;
}).join('');
// Pagination
const pagEl = document.getElementById('abg-pagination');
const totalPages = Math.ceil(data.total / 30);
if (totalPages <= 1) {
pagEl.style.display = 'none';
} else {
pagEl.style.display = 'flex';
let pagHtml = '';
pagHtml += `<button class="abg-page-btn" onclick="goToPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>Previous</button>`;
pagHtml += `<span style="color:#555;font-size:0.82rem;align-self:center;">Page ${currentPage} of ${totalPages}</span>`;
pagHtml += `<button class="abg-page-btn" onclick="goToPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>Next</button>`;
pagEl.innerHTML = pagHtml;
}
} catch (e) {
document.getElementById('abg-report-list').innerHTML = '<div style="text-align:center;padding:40px;color:var(--abg-error);">Error loading reports.</div>';
}
}
function goToPage(page) {
currentPage = page;
loadReports();
}
loadStats();
loadReports();
</script>
{% endblock %}