Fehler melden mit Screenshot und Markierung, Sammlung für Bearbeiter

Käfer-Button unten rechts: nimmt den sichtbaren Bildschirm mit html2canvas
(selbst gehostet) auf, Vorschau mit roten Rechtecken, Beschreibung, Name
optional, Senden ohne Anmeldung. Aus offenen Dialogen heraus wird der
Dialog selbst aufgenommen. Bearbeiter sehen die Meldungen unter „Fehler"
mit Zähler, kopieren einen fertigen Claude-Prompt und den Screenshot.

Serverseitig: Pfade nur in exakter Schreibweise, /api standardmäßig nur
für Bearbeiter mit ausdrücklicher Freigabeliste, Bremse zählt jeden
Versuch vor dem Lesen großer Bodies, X-Forwarded-For nur von vertrauten
Proxys, Nutzertext im Prompt eingezäunt. HTTP-Tests für die Sperren.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-10-15 08:19:46 +00:00
1 parent 6a3c68aaac
commit 4ec23ccd25
15 files changed
+877 -36

No files matched your search

+322 -2
View File
@@ -51,7 +51,7 @@ async function api(path, { method = 'GET', body, raw } = {}) {
}
// ---------- Anmeldung zum Bearbeiten ----------
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'anfragen', 'einstellungen'];
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'anfragen', 'fehler', 'einstellungen'];
function isEditor() { return !state.data || state.data.auth?.editor !== false; }
function applyAuthUI() {
const a = state.data?.auth || { protected: false, editor: true };
@@ -67,6 +67,10 @@ function applyAuthUI() {
const badge = $('#requests-count');
badge.hidden = !a.editor || open === 0;
badge.textContent = open;
const openBugs = (state.data?.bugs || []).filter((b) => b.status === 'offen').length;
const bugBadge = $('#bugs-count');
bugBadge.hidden = !a.editor || openBugs === 0;
bugBadge.textContent = openBugs;
if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender';
}
function openLogin() {
@@ -144,7 +148,7 @@ function avatarEl(p, cls = '') {
// ---------- Navigation ----------
function showView(name) {
const known = ['kalender', 'personen', 'abwesenheiten', 'anfragen', 'einstellungen'];
const known = ['kalender', 'personen', 'abwesenheiten', 'anfragen', 'fehler', 'einstellungen'];
if (!known.includes(name)) name = 'kalender';
if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender';
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
@@ -1068,6 +1072,7 @@ function renderSettings() {
f.historyWindowDays.value = s.historyWindowDays;
f.calendarDisplayName.value = s.calendarDisplayName;
f.inviteAttendees.checked = Boolean(s.inviteAttendees);
f.bugPromptTemplate.value = s.bugPromptTemplate || '';
if (!$('#holiday-generate').elements.year.value) $('#holiday-generate').elements.year.value = new Date().getFullYear();
const list = $('#holiday-list');
@@ -1111,6 +1116,7 @@ settingsForm.addEventListener('submit', async (e) => {
await api('api/settings', { method: 'PUT', body: {
meetingTime: f.meetingTime.value, durationMinutes: Number(f.durationMinutes.value), bundesland: f.bundesland.value,
historyWindowDays: Number(f.historyWindowDays.value), calendarDisplayName: f.calendarDisplayName.value, inviteAttendees: f.inviteAttendees.checked,
bugPromptTemplate: f.bugPromptTemplate.value,
} });
await load();
toast('Einstellungen gespeichert.');
@@ -1134,6 +1140,319 @@ $('#holiday-add').addEventListener('submit', async (e) => {
} catch (err) { toast(err.message, true); }
});
// ---------- Fehler melden (für alle) ----------
const bugPanel = $('#bug-panel');
const bugCanvas = $('#bug-canvas');
const bugState = { base: null, rects: [], draft: null, sending: false, capture: null, closeTimer: null, target: null };
function viewName() {
const v = location.hash.slice(1) || 'kalender';
const names = { kalender: 'Kalender', personen: 'Personen', abwesenheiten: 'Abwesenheiten', anfragen: 'Anfragen', fehler: 'Fehler', einstellungen: 'Einstellungen' };
const month = v === 'kalender' ? ` (${MONTHS[state.month.getMonth()]} ${state.month.getFullYear()})` : '';
return `${names[v] || v}${month}${isEditor() ? ', angemeldet' : ''}`;
}
/**
* Sichtbaren Bildschirm als Canvas aufnehmen – das Meldefenster selbst bleibt außen vor.
* html2canvas zeichnet die Seite aus dem DOM nach. Zwei Dinge kann es nicht: position: sticky (die
* Kopfzeile) und box-shadow-Ringe (sie übermalen das Kennzeichen). Beides wird in der Kopie des
* Dokuments korrigiert, die html2canvas rendert – die echte Seite bleibt unberührt.
*/
async function captureScreenshot(target = null) {
if (typeof window.html2canvas !== 'function') throw new Error('Die Screenshot-Funktion ist nicht geladen.');
// Auflösung begrenzen: höchstens 1,5-fach und höchstens 2560 px breit, damit das Bild unter 6 MB bleibt
const scale = Math.min(window.devicePixelRatio || 1, 1.5, Math.max(1, 2560 / window.innerWidth));
// Schatten malt html2canvas als Fläche über das Element – in der Kopie weglassen
const extraStyle = '.slot .badge, .req-mark, dialog, .chip, .card { box-shadow: none; } .logo .arc { animation: none; }';
if (target) {
// Ein offener Dialog: nur ihn aufnehmen, er ist das, was der Melder gerade sieht
return window.html2canvas(target, {
backgroundColor: '#ffffff',
scale,
logging: false,
useCORS: true,
ignoreElements: (el) => el.id === 'bug-widget' || el.id === 'toast' || el.classList?.contains('dialog-bug'),
onclone: (doc) => { const style = doc.createElement('style'); style.textContent = extraStyle; doc.head.appendChild(style); },
});
}
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const topbarHeight = $('.topbar').offsetHeight;
return window.html2canvas(document.documentElement, {
backgroundColor: '#ffffff',
scale,
logging: false,
useCORS: true,
ignoreElements: (el) => el.id === 'bug-widget' || el.id === 'toast',
x: scrollX,
y: scrollY,
width: window.innerWidth,
height: window.innerHeight,
scrollX: 0,
scrollY: 0,
windowWidth: document.documentElement.clientWidth,
windowHeight: document.documentElement.clientHeight,
onclone: (doc) => {
const bar = doc.querySelector('.topbar');
if (bar) {
// Kopfzeile dorthin setzen, wo sie gerade klebt; der freigewordene Platz oben wird aufgefüllt
bar.style.position = 'absolute';
bar.style.top = `${scrollY}px`;
bar.style.left = '0';
bar.style.right = '0';
doc.body.style.paddingTop = `${topbarHeight}px`;
}
const style = doc.createElement('style');
style.textContent = extraStyle;
doc.head.appendChild(style);
},
});
}
function drawBugCanvas() {
const c = bugCanvas;
if (!bugState.base) return;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, c.width, c.height);
ctx.drawImage(bugState.base, 0, 0);
const lw = Math.max(3, Math.round(c.width / 400));
ctx.lineWidth = lw;
ctx.strokeStyle = '#B42318';
ctx.lineJoin = 'miter';
for (const r of [...bugState.rects, ...(bugState.draft ? [bugState.draft] : [])]) {
ctx.strokeRect(r.x + lw / 2, r.y + lw / 2, Math.max(0, r.w - lw), Math.max(0, r.h - lw));
}
$('#bug-clear-marks').hidden = bugState.rects.length === 0;
}
function canvasPoint(e) {
const rect = bugCanvas.getBoundingClientRect();
const x = Math.min(Math.max(0, (e.clientX - rect.left) / rect.width * bugCanvas.width), bugCanvas.width);
const y = Math.min(Math.max(0, (e.clientY - rect.top) / rect.height * bugCanvas.height), bugCanvas.height);
return { x, y };
}
let bugDrawStart = null;
bugCanvas.addEventListener('pointerdown', (e) => {
if (!bugState.base) return;
e.preventDefault();
bugCanvas.setPointerCapture(e.pointerId);
bugDrawStart = canvasPoint(e);
bugState.draft = { x: bugDrawStart.x, y: bugDrawStart.y, w: 0, h: 0 };
});
bugCanvas.addEventListener('pointermove', (e) => {
if (!bugDrawStart) return;
const p = canvasPoint(e);
bugState.draft = { x: Math.min(p.x, bugDrawStart.x), y: Math.min(p.y, bugDrawStart.y), w: Math.abs(p.x - bugDrawStart.x), h: Math.abs(p.y - bugDrawStart.y) };
drawBugCanvas();
});
const endBugDraw = () => {
if (!bugDrawStart) return;
const d = bugState.draft;
const min = bugCanvas.width / 150;
if (d && d.w > min && d.h > min) bugState.rects.push(d);
bugState.draft = null;
bugDrawStart = null;
drawBugCanvas();
};
bugCanvas.addEventListener('pointerup', endBugDraw);
bugCanvas.addEventListener('pointercancel', endBugDraw);
$('#bug-clear-marks').addEventListener('click', () => { bugState.rects = []; drawBugCanvas(); });
function closeBugPanel() {
if (bugPanel.open) bugPanel.close(); // löst 'close' aus, dort wird aufgeräumt
}
// Aufräumen bei jedem Schließen: Escape, Abbrechen, automatisch nach dem Senden
bugPanel.addEventListener('close', () => {
clearTimeout(bugState.closeTimer);
bugState.closeTimer = null;
bugState.capture = null;
bugState.base = null;
bugState.rects = [];
bugState.draft = null;
bugState.target = null;
$('#bug-button').setAttribute('aria-expanded', 'false');
});
/** Panel öffnen und aufnehmen. target = offener Dialog, der stattdessen fotografiert wird. */
async function openBugPanel(target = null) {
if (bugPanel.open) return;
clearTimeout(bugState.closeTimer);
bugState.closeTimer = null;
bugState.target = target;
$('#bug-button').setAttribute('aria-expanded', 'true');
$('#bug-shot').hidden = true;
$('#bug-shot-hint').hidden = true;
$('#bug-result').hidden = true;
$('#bug-submit').disabled = false;
$('#bug-text').value = '';
try { $('#bug-reporter').value = localStorage.getItem('rotatory.reporter') || ''; } catch { /* kein Speicher */ }
const status = $('#bug-status');
status.hidden = false;
status.textContent = 'Bildschirm wird aufgenommen …';
bugState.base = null;
bugState.rects = [];
bugPanel.showModal(); // auch über anderen offenen Dialogen bedienbar
$('#bug-text').focus();
const capture = captureScreenshot(target);
bugState.capture = capture;
try {
const shot = await capture;
if (bugState.capture !== capture) return; // inzwischen geschlossen oder neu geöffnet
bugState.base = shot;
bugCanvas.width = shot.width;
bugCanvas.height = shot.height;
drawBugCanvas();
status.hidden = true;
$('#bug-shot').hidden = false;
$('#bug-shot-hint').hidden = false;
} catch (err) {
if (bugState.capture !== capture) return;
status.textContent = `Screenshot nicht möglich (${err.message}). Beschreibe den Fehler bitte trotzdem.`;
} finally {
if (bugState.capture === capture) bugState.capture = null;
}
}
$('#bug-button').addEventListener('click', () => { if (bugPanel.open) closeBugPanel(); else openBugPanel(); });
$('#bug-cancel').addEventListener('click', closeBugPanel);
// Käfer im Kopf jedes Dialogs: nimmt den Dialog auf und öffnet das Panel darüber
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.dialog-bug');
if (trigger) openBugPanel(trigger.closest('dialog'));
});
$('#bug-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (bugState.sending) return;
const out = $('#bug-result');
const text = $('#bug-text').value.trim();
if (!text) { out.className = 'result error'; out.textContent = 'Bitte beschreibe kurz, was falsch läuft.'; out.hidden = false; return; }
bugState.sending = true;
$('#bug-submit').disabled = true;
out.className = 'result';
out.textContent = 'Wird gesendet …';
out.hidden = false;
const reporter = $('#bug-reporter').value.trim();
try { localStorage.setItem('rotatory.reporter', reporter); } catch { /* kein Speicher */ }
// Läuft die Aufnahme noch, erst abwarten – sonst ginge die Meldung ohne Bild raus
if (bugState.capture) {
out.textContent = 'Bildschirm wird noch aufgenommen …';
try { await bugState.capture; } catch { /* dann eben ohne Screenshot */ }
out.textContent = 'Wird gesendet …';
}
const page = bugState.target ? `${viewName()}, Dialog „${bugState.target.querySelector('h2')?.textContent.trim() || ''}"` : viewName();
try {
let screenshot = bugState.base ? bugCanvas.toDataURL('image/png') : '';
if (screenshot.length > 8_000_000) screenshot = bugCanvas.toDataURL('image/jpeg', 0.85); // großes Display: kleiner als JPEG
await api('api/bugs', { method: 'POST', body: { text, reporter, page, url: location.href, viewport: `${window.innerWidth}×${window.innerHeight}`, userAgent: navigator.userAgent, screenshot } });
} catch (err) {
out.className = 'result error';
out.textContent = err.message;
$('#bug-submit').disabled = false;
bugState.sending = false;
return;
}
bugState.sending = false;
out.textContent = 'Danke, der Fehler ist gemeldet.';
bugState.closeTimer = setTimeout(() => { bugState.closeTimer = null; closeBugPanel(); }, 2000);
if (isEditor()) load().catch((err) => toast(err.message, true));
});
// ---------- Fehlermeldungen bearbeiten (nur Bearbeiter) ----------
const canCopyImages = () => Boolean(window.isSecureContext && navigator.clipboard && navigator.clipboard.write && window.ClipboardItem);
async function copyText(text) {
if (window.isSecureContext && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; }
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
if (!ok) throw new Error('Der Browser hat das Kopieren abgelehnt.');
}
async function copyImage(url) {
const blob = await (await fetch(url)).blob();
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
}
function browserLabel(ua) {
const u = String(ua || '');
const m = u.match(/(Edg|OPR|Firefox|Chrome|Safari|Version)\/(\d+)/g) || [];
const pick = (name) => m.find((x) => x.startsWith(name + '/'));
const b = pick('Edg') ? `Edge ${pick('Edg').split('/')[1]}` : pick('OPR') ? `Opera ${pick('OPR').split('/')[1]}` : pick('Firefox') ? `Firefox ${pick('Firefox').split('/')[1]}` : pick('Chrome') ? `Chrome ${pick('Chrome').split('/')[1]}` : pick('Version') && /Safari/.test(u) ? `Safari ${pick('Version').split('/')[1]}` : 'Browser';
const os = /Windows/.test(u) ? 'Windows' : /Mac OS X/.test(u) && !/iPhone|iPad/.test(u) ? 'macOS' : /iPhone|iPad/.test(u) ? 'iOS' : /Android/.test(u) ? 'Android' : /Linux/.test(u) ? 'Linux' : '';
return os ? `${b}, ${os}` : b;
}
function bugRow(b) {
const li = document.createElement('li');
if (b.hasScreenshot) {
const a = document.createElement('a');
a.className = 'bug-thumb';
a.href = `bugs/${b.id}.png`;
a.target = '_blank';
a.rel = 'noopener';
a.title = 'Screenshot in voller Größe öffnen';
const img = document.createElement('img');
img.src = `bugs/${b.id}.png`;
img.alt = 'Screenshot zur Fehlermeldung';
img.loading = 'lazy';
a.appendChild(img);
li.appendChild(a);
}
const grow = document.createElement('div');
grow.className = 'grow';
const text = document.createElement('div');
text.className = 'bug-text';
text.textContent = b.text;
grow.appendChild(text);
const meta = document.createElement('div');
meta.className = 'bug-meta';
meta.textContent = [b.reporter || 'anonym', new Date(b.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }), b.page, b.viewport, browserLabel(b.userAgent), b.version ? `Version ${b.version}` : '', b.status === 'erledigt' ? 'erledigt' : ''].filter(Boolean).join(' · ');
grow.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'bug-actions';
const btn = (label, cls, fn) => { const el = document.createElement('button'); el.type = 'button'; el.className = cls; el.textContent = label; el.addEventListener('click', fn); actions.appendChild(el); return el; };
btn('Prompt kopieren', 'primary', async () => {
try {
const res = await fetch(`api/bugs/${b.id}/prompt`);
if (!res.ok) throw new Error('Prompt konnte nicht geladen werden.');
await copyText(await res.text());
toast('Prompt kopiert. Jetzt in Claude Code einfügen, Screenshot dazu.');
} catch (err) { toast(err.message, true); }
});
if (b.hasScreenshot) {
if (canCopyImages()) {
btn('Screenshot kopieren', '', async () => {
try { await copyImage(`bugs/${b.id}.png`); toast('Screenshot kopiert.'); } catch (err) { toast(`Screenshot konnte nicht kopiert werden: ${err.message}`, true); }
});
} else {
btn('Screenshot öffnen', '', () => window.open(`bugs/${b.id}.png`, '_blank', 'noopener'));
}
}
if (b.status === 'offen') btn('Erledigt', 'ghost', () => run(() => api(`api/bugs/${b.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.'));
else btn('Wieder öffnen', 'ghost', () => run(() => api(`api/bugs/${b.id}`, { method: 'PUT', body: { status: 'offen' } })));
btn('Löschen', 'ghost danger', () => { if (confirm('Diese Fehlermeldung samt Screenshot löschen?')) run(() => api(`api/bugs/${b.id}`, { method: 'DELETE' }), 'Meldung gelöscht.'); });
grow.appendChild(actions);
li.appendChild(grow);
return li;
}
function renderBugs() {
const all = state.data.bugs || [];
const open = all.filter((b) => b.status === 'offen').sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const done = all.filter((b) => b.status !== 'offen').sort((a, b) => (b.resolvedAt || '').localeCompare(a.resolvedAt || ''));
$('#bugs-clipboard-hint').hidden = canCopyImages();
const list = $('#bug-list');
list.textContent = '';
if (open.length === 0) list.innerHTML = '<li class="hint">Keine offenen Fehlermeldungen.</li>';
for (const b of open) list.appendChild(bugRow(b));
const doneList = $('#bug-list-done');
doneList.textContent = '';
for (const b of done) doneList.appendChild(bugRow(b));
doneList.hidden = !$('#bugs-show-done').checked || done.length === 0;
$('#bugs-toggle').hidden = done.length === 0;
}
$('#bugs-show-done').addEventListener('change', renderBugs);
// ---------- Start ----------
function renderAll() {
applyAuthUI();
@@ -1143,6 +1462,7 @@ function renderAll() {
renderPhotoField();
renderAbsences();
renderRequests();
renderBugs();
renderSettings();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
} else {