453 lines
15 KiB
JavaScript
453 lines
15 KiB
JavaScript
|
|
(function () {
|
||
|
|
const logs = [];
|
||
|
|
const MAX_LOGS = 200;
|
||
|
|
let bugModal = null;
|
||
|
|
let screenshotDataUrl = null;
|
||
|
|
let pendingScreenshotPromise = null;
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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. Vedhæft et billede manuelt eller indsæt med Cmd+V.', true);
|
||
|
|
} finally {
|
||
|
|
pendingScreenshotPromise = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
bugModal.show();
|
||
|
|
}
|
||
|
|
|
||
|
|
function prepareScreenshotFromTrigger() {
|
||
|
|
pendingScreenshotPromise = (async () => {
|
||
|
|
try {
|
||
|
|
return await takeScreenshot();
|
||
|
|
} catch (e) {
|
||
|
|
console.warn('Bug report html2canvas capture failed, trying display media', e);
|
||
|
|
return await takeScreenshotViaDisplayMedia();
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
return pendingScreenshotPromise;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 modalEl = document.getElementById('bugReportModal');
|
||
|
|
|
||
|
|
if (btn) {
|
||
|
|
btn.addEventListener('click', (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!pendingScreenshotPromise) {
|
||
|
|
prepareScreenshotFromTrigger();
|
||
|
|
}
|
||
|
|
openBugReportModal();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (submitBtn) {
|
||
|
|
submitBtn.addEventListener('click', () => {
|
||
|
|
submitBugReport();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
})();
|