/**
* 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.
*
* Seit quick-260918-gza traegt die Nutzlast zusaetzlich vier Herkunftsfelder
* (`clientKind`, `clientOs`, `clientVersion`, `clientCommit`) — im Browser
* `clientKind=browser` mit leeren uebrigen Feldern, in der Desktop-App aus
* `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts). Die API
* leitet daraus das Betreff-Kuerzel und die Zeile `Herkunft:` ab.
*/
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)),
};
}
/** 1x1 transparentes PNG — Ersatz fuer Bilder, die sich nicht laden lassen. */
const TRANSPARENT_PIXEL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
function isIgnoredNode(n: Node): boolean {
return n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true';
}
/**
* Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu
* werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne.
*
* `html-to-image` laedt jedes `
` per `fetch` nach. Ein einziges Bild
* von fremder Adresse ohne CORS (z. B. ein direkt von der Website geholtes
* Favoriten-Symbol) liess bisher die GANZE Aufnahme scheitern — im Dialog
* blieb das Haekchen „Bildschirmfoto beifuegen“ dann gesperrt. Deshalb:
* nicht ladbare Bilder werden zum transparenten Pixel, und scheitert die
* Aufnahme trotzdem, folgt ein zweiter Versuch ganz ohne Bilder und Rahmen.
*/
export async function captureScreenshot(): Promise {
try {
const { toPng } = await import('html-to-image');
const node = document.body;
const size = computeCaptureSize(node.scrollWidth, node.scrollHeight);
const base = {
pixelRatio: 1,
skipFonts: true,
cacheBust: true,
canvasWidth: size.width,
canvasHeight: size.height,
imagePlaceholder: TRANSPARENT_PIXEL,
onImageErrorHandler: () => undefined,
};
try {
return await toPng(node, { ...base, filter: (n: Node) => !isIgnoredNode(n) });
} catch {
return await toPng(node, {
...base,
filter: (n: Node) =>
!isIgnoredNode(n) &&
!(n instanceof HTMLImageElement) &&
!(n instanceof HTMLIFrameElement),
});
}
} 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;
clientKind: 'desktop' | 'browser';
clientOs: string;
clientVersion: string;
clientCommit: string;
}
export type BugReportResult = { ok: true } | { ok: false; status: number };
export async function sendBugReport(p: BugReportPayload): Promise {
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);
body.append('clientKind', p.clientKind);
body.append('clientOs', p.clientOs);
body.append('clientVersion', p.clientVersion);
body.append('clientCommit', p.clientCommit);
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 };
}
}