feat(quick-260914-m97): Fehler-melden-Knopf in der Kopfzeile — Bildschirmfoto vor dem Dialog (html-to-image 1.11.13), Fehlerpuffer, Dialog mit Vorschau, i18n bugReport

- Knopf (Kaefer-Symbol) unmittelbar vor dem Erscheinungsbild-Schalter; captureScreenshot() laeuft VOR dem Oeffnen, Test pinnt es im toPng-Mock
- computeCaptureSize (laengste Kante 1600 px, reine Funktion, getestet), dataUrlToBlob, sendBugReport als Multipart mit credentials und ohne Content-Type-Header
- error-buffer: Ringpuffer 20, window error/unhandledrejection, console.error (Original bleibt), fetch-Wrapper nur bei !ok ohne Anfrage-Rumpf/Suchteil/Kopfzeilen (T-M97-02), idempotent, SSR-sicher; installiert in app-shell
- Dialog mit Vorschau, Haekchen, Beschreibung, Meldungen je Status 409/413/429/502/allgemein, Admin-Link auf /admin/smtp
- i18n bugReport de/en, Allowlist um "passiert"; html-to-image exakt 1.11.13, Lockfile aktualisiert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 16:50:01 +02:00
parent 54121c1721
commit 60b0ee8409
13 changed files with 968 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
/**
* bug-report-api — Bildaufnahme und Versand des Fehler-melden-Knopfs
* (quick-260914-m97).
*
* `captureScreenshot` rastert `document.body` ueber `html-to-image`
* (SVG `foreignObject`, der Browser zeichnet selbst — deshalb stimmen die
* OKLCH-Farben von Tailwind 4, an denen `html2canvas` scheitert). Die
* Bibliothek wird erst beim Klick dynamisch geladen. Die laengste Kante
* ist auf 1600 px begrenzt (`computeCaptureSize`), `pixelRatio: 1`, keine
* Schrift-Einbettung (`skipFonts` — das Projekt hat keinen Webfont).
*
* `sendBugReport` schickt `multipart/form-data` an `POST /bug-reports`
* — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie
* (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die
* Multipart-Grenze setzt der Browser selbst.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/**
* Zielmass fuer die Aufnahme: laengste Kante hoechstens `maxEdge`,
* Seitenverhaeltnis bleibt, nie kleiner als 1x1 (kein 0-Canvas).
* Reine Funktion, direkt getestet.
*/
export function computeCaptureSize(
width: number,
height: number,
maxEdge = 1600,
): { width: number; height: number } {
const w = Math.max(1, Math.round(width || 0));
const h = Math.max(1, Math.round(height || 0));
const scale = Math.min(1, maxEdge / Math.max(w, h));
return {
width: Math.max(1, Math.round(w * scale)),
height: Math.max(1, Math.round(h * scale)),
};
}
/**
* Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu
* werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne.
*/
export async function captureScreenshot(): Promise<string | null> {
try {
const { toPng } = await import('html-to-image');
const node = document.body;
const size = computeCaptureSize(node.scrollWidth, node.scrollHeight);
return await toPng(node, {
pixelRatio: 1,
skipFonts: true,
cacheBust: true,
canvasWidth: size.width,
canvasHeight: size.height,
filter: (n: Node) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'),
});
} catch {
return null;
}
}
/** Base64-Teil einer Data-URL -> PNG-Blob (per `atob`, kein fetch). */
export function dataUrlToBlob(dataUrl: string): Blob {
const comma = dataUrl.indexOf(',');
const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: 'image/png' });
}
export interface BugReportPayload {
description: string;
page: string;
webVersion: string;
webChannel: string;
webCommit: string;
userAgent: string;
viewport: string;
clientTime: string;
errors: string[];
screenshot: Blob | null;
}
export type BugReportResult = { ok: true } | { ok: false; status: number };
export async function sendBugReport(p: BugReportPayload): Promise<BugReportResult> {
const body = new FormData();
body.append('description', p.description);
body.append('page', p.page);
body.append('webVersion', p.webVersion);
body.append('webChannel', p.webChannel);
body.append('webCommit', p.webCommit);
body.append('userAgent', p.userAgent);
body.append('viewport', p.viewport);
body.append('clientTime', p.clientTime);
for (const line of p.errors) body.append('errors', line);
if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png');
try {
const res = await fetch(`${API_URL}/bug-reports`, {
method: 'POST',
credentials: 'include',
body,
});
return res.ok ? { ok: true } : { ok: false, status: res.status };
} catch {
return { ok: false, status: 0 };
}
}