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:
@@ -0,0 +1,196 @@
|
||||
'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 { 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.
|
||||
*/
|
||||
interface BugReportDialogProps {
|
||||
open: boolean;
|
||||
screenshot: string | null;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Status = 'ready' | 'sending' | 'sent' | 'failed';
|
||||
|
||||
export function BugReportDialog({ open, 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('');
|
||||
const [attach, setAttach] = useState(screenshot !== null);
|
||||
|
||||
// Bei jedem Oeffnen frisch beginnen.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStatus('ready');
|
||||
setFailedStatus(0);
|
||||
setDescription('');
|
||||
setAttach(screenshot !== null);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [open, screenshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && status !== 'sending') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, status, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSend = async () => {
|
||||
setStatus('sending');
|
||||
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,
|
||||
});
|
||||
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 border border-border bg-card 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="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{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) => setAttach(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="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? t('sending') : t('send')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user