Files
tessera-ctl/apps/web/src/components/bug-report/bug-report-dialog.tsx
T
schalli 69d17302e1 fix(260928-ujj): Biome-Formatierung der mit Design Mosaik eingebrachten Dateien
- Nur Formatierung und Importreihenfolge, keine Verhaltensaenderung
- Betrifft ausschliesslich Befunde, die der Merge neu eingebracht hat

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:14:57 +02:00

196 lines
7.0 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { appVersion } from '@/lib/app-version';
import { dataUrlToBlob, sendBugReport } from '@/lib/bug-report-api';
import { getDesktopClientInfo, isDesktopClient } from '@/lib/desktop-client';
import { formatErrorsForReport } from '@/lib/error-buffer';
/**
* Dialog des Fehler-melden-Knopfs (quick-260914-m97). Muster:
* `marketplace/components/ActivationDialog.tsx` (Overlay, `role="dialog"`,
* Escape, Fokus). Bekommt das bereits aufgenommene Bild als Data-URL —
* die Aufnahme passiert im Knopf, BEVOR dieser Dialog erscheint.
*
* Der Dialog wird nur eingehaengt, solange er offen ist (quick-260921-ldf):
* jedes Oeffnen ist ein frischer Mount, damit gilt der Anfangszustand schon
* im ERSTEN Commit. Vorher zog ein Effekt den Zustand erst einen Commit
* spaeter nach — dazwischen stand sichtbar "Bild da, Haekchen aus" im DOM.
*/
interface BugReportDialogProps {
screenshot: string | null;
isAdmin: boolean;
onClose: () => void;
}
type Status = 'ready' | 'sending' | 'sent' | 'failed';
export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialogProps) {
const t = useTranslations('bugReport');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [status, setStatus] = useState<Status>('ready');
const [failedStatus, setFailedStatus] = useState(0);
const [description, setDescription] = useState('');
// Das Haekchen wird beim Rendern aus `screenshot` abgeleitet, nicht per
// Effekt nachgezogen: gibt es ein Bild, ist es an. `attachChoice` haelt
// allein die bewusste Abwahl des Nutzers.
const [attachChoice, setAttachChoice] = useState<boolean | null>(null);
const attach = screenshot !== null && (attachChoice ?? true);
useEffect(() => {
textareaRef.current?.focus();
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && status !== 'sending') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [status, onClose]);
const handleSend = async () => {
setStatus('sending');
// Herkunft (quick-260918-gza): tessera_desktop entscheidet Desktop/
// Browser, tessera_desktop_client liefert die Details dazu; fehlt es
// (alter Client), bleibt es bei Desktop ohne Details.
const desktop = isDesktopClient();
const info = desktop ? getDesktopClientInfo() : null;
const result = await sendBugReport({
description: description.trim(),
page: window.location.pathname + window.location.search,
webVersion: appVersion.version,
webChannel: appVersion.channel,
webCommit: appVersion.commit,
userAgent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
clientTime: new Date().toISOString(),
errors: formatErrorsForReport(),
screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null,
clientKind: desktop ? 'desktop' : 'browser',
clientOs: info?.os ?? '',
clientVersion: info?.version ?? '',
clientCommit: info?.commit ?? '',
});
if (result.ok) {
setStatus('sent');
} else {
setFailedStatus(result.status);
setStatus('failed');
}
};
const errorKey =
failedStatus === 409
? 'errorNotConfigured'
: failedStatus === 429
? 'errorTooMany'
: failedStatus === 413
? 'errorTooLarge'
: failedStatus === 502
? 'errorSendFailed'
: 'errorGeneric';
const busy = status === 'sending';
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
aria-labelledby="bug-report-title"
data-bug-report-ignore="true"
>
<div className="w-full max-w-lg rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
{t('title')}
</h2>
{status === 'sent' ? (
<>
<p className="text-sm text-foreground mb-4">{t('sent')}</p>
<div className="flex justify-end">
<button type="button" onClick={onClose} className="btn btn-primary">
{t('close')}
</button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground mb-4">{t('intro')}</p>
{screenshot ? (
<img
src={screenshot}
alt={t('screenshotAlt')}
className="max-h-48 w-auto rounded border border-border mb-3"
/>
) : (
<p className="text-sm text-muted-foreground mb-3">{t('screenshotUnavailable')}</p>
)}
<div className="flex items-center gap-2 mb-3">
<input
id="bug-report-attach"
type="checkbox"
className="h-4 w-4"
checked={attach}
disabled={screenshot === null || busy}
onChange={(e) => setAttachChoice(e.target.checked)}
/>
<label htmlFor="bug-report-attach" className="text-sm text-foreground">
{t('attachScreenshot')}
</label>
</div>
<label htmlFor="bug-report-description" className="mb-1 block text-sm text-foreground">
{t('descriptionLabel')}
</label>
<textarea
id="bug-report-description"
ref={textareaRef}
rows={4}
maxLength={4000}
placeholder={t('descriptionPlaceholder')}
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground"
value={description}
disabled={busy}
onChange={(e) => setDescription(e.target.value)}
/>
{status === 'failed' && (
<div className="mt-3 text-sm text-destructive">
<p>{t(errorKey)}</p>
{failedStatus === 409 && isAdmin && (
<p className="mt-1">
{t('errorNotConfiguredAdminHint')}{' '}
<Link href="/admin/smtp" className="underline">
{t('errorNotConfiguredAdminLink')}
</Link>
</p>
)}
</div>
)}
<div className="mt-4 flex justify-end gap-3">
<button type="button" onClick={onClose} disabled={busy} className="btn btn-secondary">
{t('cancel')}
</button>
<button
type="button"
onClick={handleSend}
disabled={busy}
className="btn btn-primary"
>
{busy ? t('sending') : t('send')}
</button>
</div>
</>
)}
</div>
</div>
);
}