bmc_hub/static/js/bug-report.js
Christian 770f822fc6 feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching optional files.
- Integrated automatic screenshot capture functionality when the bug report modal is opened.
- Created a new API endpoint for submitting bug reports, including validation and rate limiting.
- Added database migration for tracking bug report submissions.
- Updated frontend scripts to handle bug report submissions and display status messages.
- Enhanced contact search functionality with improved error handling and backward compatibility.
- Introduced a new button in the UI for accessing the bug report modal.
2026-05-06 07:01:43 +02:00

490 lines
17 KiB
JavaScript

(function () {
const logs = [];
const MAX_LOGS = 200;
let bugModal = null;
let screenshotDataUrl = null;
let pendingScreenshotPromise = null;
let isCapturingDisplayMedia = false;
const pushLog = (type, args) => {
try {
logs.push({
type,
message: (args || []).map((x) => {
if (typeof x === 'string') return x;
try {
return JSON.stringify(x);
} catch (_) {
return String(x);
}
}).join(' '),
timestamp: new Date().toISOString()
});
if (logs.length > MAX_LOGS) {
logs.splice(0, logs.length - MAX_LOGS);
}
} catch (_) {
// no-op
}
};
['log', 'warn', 'error'].forEach((type) => {
const original = console[type];
console[type] = function (...args) {
pushLog(type, args);
original.apply(console, args);
};
});
window.addEventListener('error', (event) => {
logs.push({
type: 'error',
message: event.message,
url: event.filename,
line: event.lineno,
col: event.colno,
timestamp: new Date().toISOString()
});
if (logs.length > MAX_LOGS) {
logs.splice(0, logs.length - MAX_LOGS);
}
});
function getCurrentUser() {
const metaUser = document.querySelector('meta[name="user-id"]');
const userFromMeta = metaUser ? metaUser.getAttribute('content') : null;
let userFromToken = null;
const token = localStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
userFromToken = payload.sub || payload.user_id || null;
} catch (_) {
userFromToken = null;
}
}
return userFromToken || userFromMeta || null;
}
function getMetadata() {
return {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
screenSize: `${window.innerWidth}x${window.innerHeight}`,
user: getCurrentUser(),
};
}
async function toDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function isCrossOriginUrl(url) {
try {
if (!url) return false;
const parsed = new URL(url, window.location.href);
return parsed.origin !== window.location.origin;
} catch (_) {
return false;
}
}
function shouldIgnoreInScreenshot(el) {
if (!el || !el.tagName) return false;
const tag = String(el.tagName).toUpperCase();
if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
return true;
}
if (tag === 'IMG') {
const src = el.getAttribute('src') || '';
return isCrossOriginUrl(src);
}
return false;
}
async function renderScreenshot(target, opts) {
const canvas = await window.html2canvas(target, opts);
return canvas.toDataURL('image/png');
}
async function takeScreenshotViaDisplayMedia() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
throw new Error('Display media API not supported');
}
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
try {
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
await new Promise((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error('Kunne ikke læse videostream'));
});
await video.play();
const width = Math.max(1, video.videoWidth || window.innerWidth);
const height = Math.max(1, video.videoHeight || window.innerHeight);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas context unavailable');
}
ctx.drawImage(video, 0, 0, width, height);
return canvas.toDataURL('image/png');
} finally {
stream.getTracks().forEach((t) => t.stop());
}
}
async function takeScreenshot() {
if (!window.html2canvas) {
throw new Error('Screenshot library not loaded');
}
const doc = document.documentElement;
const body = document.body;
const fullWidth = Math.max(
doc ? doc.scrollWidth : 0,
doc ? doc.offsetWidth : 0,
doc ? doc.clientWidth : 0,
body ? body.scrollWidth : 0,
body ? body.offsetWidth : 0,
window.innerWidth || 0
);
const fullHeight = Math.max(
doc ? doc.scrollHeight : 0,
doc ? doc.offsetHeight : 0,
doc ? doc.clientHeight : 0,
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
window.innerHeight || 0
);
const common = {
useCORS: true,
allowTaint: false,
logging: false,
scale: 1,
backgroundColor: '#ffffff',
imageTimeout: 3000,
ignoreElements: shouldIgnoreInScreenshot,
removeContainer: true,
};
// Strategy 1: Full page (most useful when it works)
try {
return await renderScreenshot(document.documentElement, {
...common,
width: fullWidth,
height: fullHeight,
windowWidth: fullWidth,
windowHeight: fullHeight,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
});
} catch (e1) {
console.warn('Bug report screenshot strategy 1 failed', e1);
if (String(e1?.message || '').toLowerCase().includes('unsupported color function "color"')) {
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
}
}
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
const contentRoot =
document.querySelector('.container-fluid.px-4.py-4') ||
document.querySelector('[data-bugreport-root]') ||
document.querySelector('#main-content') ||
document.querySelector('#content') ||
document.querySelector('main') ||
document.querySelector('.content-wrapper') ||
document.documentElement;
try {
return await renderScreenshot(contentRoot, {
...common,
width: Math.max(contentRoot.scrollWidth || 0, contentRoot.clientWidth || 0, window.innerWidth || 0),
height: Math.max(contentRoot.scrollHeight || 0, contentRoot.clientHeight || 0, window.innerHeight || 0),
scrollX: 0,
scrollY: 0,
});
} catch (e2) {
console.warn('Bug report screenshot strategy 2 failed', e2);
if (String(e2?.message || '').toLowerCase().includes('unsupported color function "color"')) {
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
}
}
throw new Error('Automatic screenshot failed');
}
function setStatus(text, isError) {
const el = document.getElementById('bugReportStatus');
if (!el) return;
el.textContent = text || '';
el.className = isError ? 'small text-danger mt-2' : 'small text-muted mt-2';
}
function setPreview(dataUrl) {
const img = document.getElementById('bugScreenshotPreview');
const placeholder = document.getElementById('bugScreenshotPreviewPlaceholder');
if (!img || !placeholder) return;
if (dataUrl) {
img.src = dataUrl;
img.style.display = '';
placeholder.style.display = 'none';
} else {
img.removeAttribute('src');
img.style.display = 'none';
placeholder.style.display = '';
}
}
async function handlePasteScreenshot(event) {
const modalVisible = document.getElementById('bugReportModal')?.classList.contains('show');
if (!modalVisible) return;
const items = Array.from(event.clipboardData?.items || []);
const imageItem = items.find((item) => item.kind === 'file' && item.type.startsWith('image/'));
if (!imageItem) return;
const blob = imageItem.getAsFile();
if (!blob) return;
event.preventDefault();
try {
const dataUrl = await toDataUrl(blob);
screenshotDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('Screenshot indsat fra clipboard.');
} catch (e) {
console.warn('Clipboard screenshot parse failed', e);
setStatus('Kunne ikke indsætte screenshot fra clipboard.', true);
}
}
async function openBugReportModal() {
if (!bugModal) {
const modalEl = document.getElementById('bugReportModal');
if (!modalEl || !window.bootstrap) return;
bugModal = new bootstrap.Modal(modalEl);
}
setStatus('Tager screenshot...');
screenshotDataUrl = null;
setPreview(null);
try {
screenshotDataUrl = pendingScreenshotPromise
? await pendingScreenshotPromise
: await takeScreenshot();
setPreview(screenshotDataUrl);
setStatus('Screenshot klar. Udfyld felterne og send.');
} catch (e) {
console.warn('Bug report screenshot failed', e);
setStatus('Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.', true);
} finally {
pendingScreenshotPromise = null;
}
bugModal.show();
}
function prepareScreenshotFromTrigger() {
pendingScreenshotPromise = takeScreenshot();
return pendingScreenshotPromise;
}
async function captureScreenshotViaDisplayMediaFromUserGesture() {
if (isCapturingDisplayMedia) return;
const btn = document.getElementById('bugCaptureDisplayMediaBtn');
const originalHtml = btn ? btn.innerHTML : '';
isCapturingDisplayMedia = true;
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Venter på skærmvalg...';
}
try {
const dataUrl = await takeScreenshotViaDisplayMedia();
screenshotDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('Screenshot taget via skærmdeling.');
} catch (e) {
console.warn('Bug report display media capture failed', e);
setStatus('Skærmdelings-screenshot mislykkedes. Prøv igen eller indsæt med Cmd+V.', true);
} finally {
isCapturingDisplayMedia = false;
if (btn) {
btn.disabled = false;
btn.innerHTML = originalHtml || '<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling';
}
}
}
async function submitBugReport() {
const actual = (document.getElementById('bugActualInput')?.value || '').trim();
const expected = (document.getElementById('bugExpectedInput')?.value || '').trim();
const fileInput = document.getElementById('bugExtraFileInput');
const submitBtn = document.getElementById('bugReportSubmitBtn');
if (!actual || !expected) {
setStatus('Udfyld både "Hvad gik galt" og "Hvad burde være sket".', true);
return;
}
let extraFileName = null;
let extraFileBase64 = null;
if (fileInput && fileInput.files && fileInput.files[0]) {
const f = fileInput.files[0];
extraFileName = f.name;
try {
extraFileBase64 = await toDataUrl(f);
} catch (_) {
setStatus('Kunne ikke læse ekstra fil.', true);
return;
}
}
const payload = {
actual,
expected,
screenshot_base64: screenshotDataUrl,
metadata: getMetadata(),
logs,
extra_file_name: extraFileName,
extra_file_base64: extraFileBase64,
};
const prevText = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender...';
}
try {
const endpoints = ['/api/v1/bug-reports', '/bug-reports'];
let res = null;
let data = {};
for (const endpoint of endpoints) {
res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
data = await res.json().catch(() => ({}));
// Try fallback endpoint only when path is missing.
if (res.status === 404) {
continue;
}
break;
}
if (!res || !res.ok) {
const detail = (data && (data.detail || data.message)) || 'Kunne ikke sende fejlrapport';
throw new Error(detail);
}
setStatus('Fejl rapporteret.');
const target = data.case_url || '/sag';
setTimeout(() => {
window.location.href = target;
}, 500);
} catch (e) {
setStatus(e.message || 'Kunne ikke sende fejlrapport', true);
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = prevText;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('bugReportBtn');
const submitBtn = document.getElementById('bugReportSubmitBtn');
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
const modalEl = document.getElementById('bugReportModal');
if (btn) {
btn.addEventListener('click', (e) => {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
openBugReportModal();
});
}
if (submitBtn) {
submitBtn.addEventListener('click', () => {
submitBugReport();
});
}
if (displayMediaBtn) {
displayMediaBtn.addEventListener('click', (e) => {
e.preventDefault();
captureScreenshotViaDisplayMediaFromUserGesture();
});
}
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
const actual = document.getElementById('bugActualInput');
const expected = document.getElementById('bugExpectedInput');
const fileInput = document.getElementById('bugExtraFileInput');
if (actual) actual.value = '';
if (expected) expected.value = '';
if (fileInput) fileInput.value = '';
screenshotDataUrl = null;
setPreview(null);
setStatus('');
});
}
document.addEventListener('paste', (e) => {
handlePasteScreenshot(e);
});
document.addEventListener('keydown', (e) => {
const isTyping = ['INPUT', 'TEXTAREA'].includes((e.target?.tagName || '').toUpperCase());
if (isTyping) return;
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
openBugReportModal();
}
});
});
})();