PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/components/BugReportModal.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:26:32 UTC 20,315 bytes, 483 lines download raw bytes

1// BugReportModal.js: the branded, in-archive report form (#509).
2//
3// This is the themed layer that sits between the header "Report a bug" button
4// and GitHub. Instead of dropping the reader on github.com's new-issue form
5// (which needs an account most readers do not have), it collects the report in
6// the archive's own look and POSTs it to the Apps Script web app, which files
7// the GitHub issue on the reader's behalf. The reader never leaves the site.
8//
9// Two intents share the form: report a problem, or suggest a record to add.
10// If no endpoint is configured yet (pre-deploy), submit falls back to the
11// existing GitHub deep link so the button always does something useful.
12//
13// Styling follows the archive design system (Special Elite display, Roboto Mono
14// body, paper background, stone palette, sky focus rings) and deliberately
15// avoids the AI-frontend tells: no eyebrow labels, no accent-sliver cards, no
16// decorative italics, one primary action.
17
18import { useEffect, useState, useRef, useCallback } from 'react';
19import { html } from '../html.js?v=3.8.36';
20import { X, Bug, Lightbulb, Send, CheckCircle2, AlertCircle, ExternalLink } from 'lucide-react';
21import { ARCHIVE_VERSION, openReportFallback } from '../utils/bugReport.js?v=3.8.36';
22import { createSubmitGate } from '../utils/submitGate.js?v=3.8.36';
23import { buildReportPayload, validateReport, submitReport, newReportKey } from '../utils/reportSubmit.js?v=3.8.36';
24import { acquireBodyScrollLock } from '../services/bodyScrollLock.js?v=3.8.36';
25
26const EMPTY_FIELDS = {
27  whatHappened: '',
28  expected: '',
29  steps: '',
30  url: '',
31  title: '',
32  why: '',
33  email: '',
34};
35
36const NO_INITIAL_FIELDS = Object.freeze({});
37
38// Read the debug context the reporter should not have to type. The modal
39// snapshots this once per opening so later history navigation cannot erase the
40// record or route the reader was reporting from.
41const captureContext = () => ({
42  page: typeof window !== 'undefined' ? window.location.href : '',
43  version: ARCHIVE_VERSION,
44  browser: typeof navigator !== 'undefined' ? navigator.userAgent : '',
45});
46
47const BugReportModal = ({ isOpen, onClose, endpoint = '', initialIntent = 'problem', initialFields = NO_INITIAL_FIELDS }) => {
48  const [intent, setIntent] = useState('problem');
49  const [fields, setFields] = useState({ ...EMPTY_FIELDS });
50  const [honeypot, setHoneypot] = useState('');
51  const [phase, setPhase] = useState('form'); // form | submitting | success | error | fallback
52  const [issueUrl, setIssueUrl] = useState('');
53  const [formError, setFormError] = useState('');
54  // One idempotency key per opened report, reused across retries so the server
55  // dedupes them; regenerated on each open (see the reset effect below).
56  const [reportKey, setReportKey] = useState('');
57
58  const modalRef = useRef(null);
59  const closeButtonRef = useRef(null);
60  const firstFieldRef = useRef(null);
61  const returnFocusRef = useRef(null);
62  const wasOpenRef = useRef(false);
63  const reportContextRef = useRef(captureContext());
64  // Owns the submit lifecycle: blocks a synchronous double-submit, allows a fresh
65  // submit after reopen, and drops a stale in-flight result. Pure + unit-tested
66  // in submitGate.js so these races cannot silently regress. useRef with an
67  // initializer function so the gate is created once per mount, not per render.
68  const gateRef = useRef(null);
69  if (gateRef.current === null) gateRef.current = createSubmitGate();
70
71  // Reset to a clean form each time the modal opens so a prior submission's
72  // state never leaks into a new report.
73  useEffect(() => {
74    if (isOpen) {
75      gateRef.current.reset();
76      reportContextRef.current = captureContext();
77      setIntent(initialIntent === 'record' ? 'record' : 'problem');
78      setFields({ ...EMPTY_FIELDS, ...initialFields });
79      setHoneypot('');
80      setPhase('form');
81      setIssueUrl('');
82      setFormError('');
83      // Fresh idempotency key per report: retries of this report reuse it (so
84      // the server dedupes), but a new report gets a new key.
85      setReportKey(newReportKey());
86    }
87  }, [isOpen, initialIntent, initialFields]);
88
89  // Focus the first field on open (or the close button on the result screens).
90  useEffect(() => {
91    if (!isOpen) return;
92    const t = setTimeout(() => {
93      if (phase === 'form' && firstFieldRef.current) firstFieldRef.current.focus();
94      else if (closeButtonRef.current) closeButtonRef.current.focus();
95    }, 80);
96    return () => clearTimeout(t);
97  }, [isOpen, phase, intent]);
98
99  // Remember the exact trigger so every close path returns keyboard focus,
100  // whether the report opened from the archive header, Start here, or a
101  // desktop shortcut/menu item.
102  useEffect(() => {
103    let frame = null;
104    if (isOpen && !wasOpenRef.current) {
105      returnFocusRef.current = document.activeElement;
106    } else if (!isOpen && wasOpenRef.current) {
107      const target = returnFocusRef.current;
108      frame = requestAnimationFrame(() => {
109        if (target?.isConnected && typeof target.focus === 'function') {
110          target.focus({ preventScroll: true });
111        }
112      });
113      returnFocusRef.current = null;
114    }
115    wasOpenRef.current = isOpen;
116    return () => { if (frame !== null) cancelAnimationFrame(frame); };
117  }, [isOpen]);
118
119  // A submit in flight is the single source of truth: block every dismissal path
120  // (Escape, backdrop click, the close button) until it settles. Otherwise a
121  // reader could close mid-request, reopen to a fresh form, and file the same
122  // report a second time while the first POST is still completing server-side.
123  // submitReport's own timeout guarantees this lock releases (the phase leaves
124  // 'submitting') within 15s, so the modal can never wedge shut.
125  const requestClose = useCallback(() => {
126    if (phase === 'submitting') return;
127    onClose();
128  }, [phase, onClose]);
129
130  // ESC closes. Tab and Shift+Tab stay inside the modal instead of moving
131  // focus into the visually obscured archive or desktop behind it.
132  useEffect(() => {
133    const onKey = (e) => {
134      if (!isOpen) return;
135      if (e.key === 'Escape') {
136        // This modal can sit above an open record reader. Consume Escape here
137        // so the same keystroke cannot bubble into and close the underlying
138        // record dialog after this report unmounts.
139        e.preventDefault();
140        e.stopPropagation();
141        requestClose();
142        return;
143      }
144      if (e.key !== 'Tab') return;
145
146      const focusable = [...(modalRef.current?.querySelectorAll(
147        'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]):not([type="hidden"]):not([tabindex="-1"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
148      ) || [])].filter((element) => element.getClientRects().length > 0);
149      if (focusable.length === 0) {
150        e.preventDefault();
151        closeButtonRef.current?.focus();
152        return;
153      }
154
155      const first = focusable[0];
156      const last = focusable[focusable.length - 1];
157      const focusOutside = !modalRef.current?.contains(document.activeElement);
158      if (e.shiftKey && (document.activeElement === first || focusOutside)) {
159        e.preventDefault();
160        last.focus();
161      } else if (!e.shiftKey && (document.activeElement === last || focusOutside)) {
162        e.preventDefault();
163        first.focus();
164      }
165    };
166    document.addEventListener('keydown', onKey);
167    return () => document.removeEventListener('keydown', onKey);
168  }, [isOpen, requestClose]);
169
170  useEffect(() => {
171    if (!isOpen) return undefined;
172    return acquireBodyScrollLock();
173  }, [isOpen]);
174
175  const setField = useCallback((name, value) => {
176    setFields((prev) => ({ ...prev, [name]: value }));
177    if (formError) setFormError('');
178  }, [formError]);
179
180  const handleBackdrop = (e) => { if (e.target === e.currentTarget) requestClose(); };
181
182  // Open the intent-aware GitHub fallback, carrying what the reader entered so
183  // a record suggestion does not land on the bug form and lose its url/title/why.
184  const openFallback = useCallback(
185    () => openReportFallback({ intent, fields, context: reportContextRef.current }),
186    [intent, fields],
187  );
188
189  const handleSubmit = useCallback(async (e) => {
190    if (e && e.preventDefault) e.preventDefault();
191
192    const payload = buildReportPayload({ intent, fields, context: reportContextRef.current, honeypot, idempotencyKey: reportKey });
193
194    const check = validateReport(payload);
195    if (!check.valid) { setFormError(check.error); return; }
196
197    // Claim the submit slot. begin() returns 0 if one is already in flight (a
198    // double-click before the disabled button renders), blocking a duplicate
199    // file. The gate also allows a fresh submit after reopen; see submitGate.js.
200    const seq = gateRef.current.begin();
201    if (!seq) return;
202    setPhase('submitting');
203    try {
204      const result = await submitReport({ endpoint, payload });
205
206      // The modal was closed/reopened while this was in flight: drop the stale
207      // result so it cannot overwrite the current session's state.
208      if (!gateRef.current.isCurrent(seq)) return;
209
210      if (result.fallback) {
211        // Do not window.open() here: the await above may have outlived the click
212        // gesture (a honeypot false-positive routes here after a round-trip), so
213        // a popup would be blocked and the report lost. Show a screen whose button
214        // opens GitHub from a fresh user gesture. Fires pre-deploy (no endpoint
215        // set) and on a honeypot false-positive.
216        setPhase('fallback');
217        return;
218      }
219      if (result.ok) {
220        setIssueUrl(result.issueUrl || '');
221        setPhase('success');
222        return;
223      }
224      setFormError(result.error || 'Your report could not be sent.');
225      setPhase('error');
226    } finally {
227      gateRef.current.end(seq);
228    }
229  }, [intent, fields, honeypot, endpoint, reportKey, openFallback, onClose]);
230
231  if (!isOpen) return null;
232
233  const isProblem = intent === 'problem';
234  const submitting = phase === 'submitting';
235
236  const intentTab = (value, Icon, label) => html`
237    <button
238      type="button"
239      onClick=${() => { setIntent(value); setFormError(''); }}
240      disabled=${submitting}
241      aria-pressed=${intent === value}
242      className=${`archive-action flex-1 ${
243        intent === value
244          ? 'archive-action--primary'
245          : 'archive-action--secondary'
246      }`}
247    >
248      <${Icon} className="w-4 h-4" aria-hidden="true" />
249      ${label}
250    </button>
251  `;
252
253  const labelledTextarea = (name, label, placeholder, rows, refEl) => html`
254    <label className="block">
255      <span className="block text-xs font-bold text-stone-700 mb-1">${label}</span>
256      <textarea
257        ref=${refEl || null}
258        value=${fields[name]}
259        onInput=${(e) => setField(name, e.target.value)}
260        disabled=${submitting}
261        rows=${rows}
262        placeholder=${placeholder}
263        className="archive-control"
264      ></textarea>
265    </label>
266  `;
267
268  const labelledInput = (name, label, placeholder, type = 'text', refEl) => html`
269    <label className="block">
270      <span className="block text-xs font-bold text-stone-700 mb-1">${label}</span>
271      <input
272        ref=${refEl || null}
273        type=${type}
274        value=${fields[name]}
275        onInput=${(e) => setField(name, e.target.value)}
276        disabled=${submitting}
277        placeholder=${placeholder}
278        className="archive-control"
279      />
280    </label>
281  `;
282
283  const body = () => {
284    if (phase === 'success') {
285      return html`
286        <div className="px-6 py-10 text-center">
287          <${CheckCircle2} className="w-10 h-10 text-green-600 mx-auto mb-4" aria-hidden="true" />
288          <h3 className="font-display text-xl text-stone-900 mb-2">Thank you</h3>
289          <p className="text-sm text-stone-600 max-w-sm mx-auto mb-6">
290            Your ${isProblem ? 'report' : 'suggestion'} reached the archive. We read every one.
291          </p>
292          ${issueUrl && html`
293            <a
294              href=${issueUrl}
295              target="_blank"
296              rel="noopener noreferrer"
297              className="inline-flex items-center gap-1.5 text-sm font-bold text-stone-700 hover:text-stone-900 underline"
298            >
299              View it on GitHub <${ExternalLink} className="w-3.5 h-3.5" aria-hidden="true" />
300            </a>
301          `}
302          <div className="mt-8">
303            <button
304              type="button"
305              onClick=${onClose}
306              className="archive-action archive-action--primary"
307            >
308              Done
309            </button>
310          </div>
311        </div>
312      `;
313    }
314
315    if (phase === 'fallback') {
316      // Reached when the archive backend could not file the report (pre-deploy,
317      // or a honeypot false-positive). The reader's data is preserved in the
318      // prefilled GitHub link; the button opens it on a real click so the popup
319      // is not blocked.
320      return html`
321        <div className="px-6 py-10 text-center">
322          <${ExternalLink} className="w-10 h-10 text-stone-700 mx-auto mb-4" aria-hidden="true" />
323          <h3 className="font-display text-xl text-stone-900 mb-2">Finish on GitHub</h3>
324          <p className="text-sm text-stone-600 max-w-sm mx-auto mb-6">
325            We could not send this from here, so we filled in a GitHub issue with
326            what you entered. Open it to post your ${isProblem ? 'report' : 'suggestion'}.
327          </p>
328          <div className="flex items-center justify-center gap-3">
329            <button
330              type="button"
331              onClick=${() => { openFallback(); onClose(); }}
332              className="archive-action archive-action--primary"
333            >
334              Open the issue form <${ExternalLink} className="w-4 h-4" aria-hidden="true" />
335            </button>
336            <button
337              type="button"
338              onClick=${onClose}
339              className="archive-action archive-action--quiet"
340            >
341              Cancel
342            </button>
343          </div>
344        </div>
345      `;
346    }
347
348    if (phase === 'error') {
349      // A failed send is ambiguous: the request may have timed out or lost its
350      // response yet still filed the issue server-side (or a concurrent retry is
351      // filing it right now). "Try again" is the only safe recovery here because it
352      // reuses the idempotency key, so the server returns the existing issue rather
353      // than filing a duplicate. We deliberately do NOT offer a direct GitHub deep
354      // link on this screen: that path bypasses the key and would open a second
355      // public issue for the same report. Try again returns to the form, which
356      // still carries the pre-submit "Prefer GitHub?" fallback for a genuine
357      // outage — where nothing was filed, so GitHub is safe.
358      return html`
359        <div className="px-6 py-10 text-center">
360          <${AlertCircle} className="w-10 h-10 text-red-600 mx-auto mb-4" aria-hidden="true" />
361          <h3 className="font-display text-xl text-stone-900 mb-2">That did not go through</h3>
362          <p className="text-sm text-stone-600 max-w-sm mx-auto mb-6">${formError}</p>
363          <div className="flex items-center justify-center gap-3">
364            <button
365              type="button"
366              onClick=${() => setPhase('form')}
367              className="archive-action archive-action--primary"
368            >
369              Try again
370            </button>
371          </div>
372        </div>
373      `;
374    }
375
376    // phase === 'form' | 'submitting'
377    return html`
378      <form onSubmit=${handleSubmit} className="px-6 py-6 space-y-4">
379        <div className="flex gap-2">
380          ${intentTab('problem', Bug, 'Report a problem')}
381          ${intentTab('record', Lightbulb, 'Suggest a record')}
382        </div>
383
384        ${isProblem ? html`
385          ${labelledTextarea('whatHappened', 'What happened?', 'I opened a record and the page went blank.', 3, firstFieldRef)}
386          ${labelledTextarea('expected', 'What did you expect? (optional)', 'The record details should have opened.', 2)}
387          ${labelledTextarea('steps', 'Steps to reproduce (optional)', '1. Go to ...\n2. Click ...\n3. See ...', 3)}
388        ` : html`
389          ${labelledInput('url', 'Link to the work', 'https://pressthink.org/...', 'url', firstFieldRef)}
390          ${labelledInput('title', 'Suggested title (optional)', 'e.g. He Said, She Said journalism')}
391          ${labelledTextarea('why', 'Why it belongs in the archive (optional)', 'A short note on what this is.', 3)}
392        `}
393
394        ${labelledInput('email', 'Your email (optional, shown publicly)', 'So we can follow up if we need to.', 'email')}
395
396        <!-- Honeypot: off-screen, not announced, not tabbable. Real people never
397             fill this; naive bots do, and the server drops those silently. -->
398        <div aria-hidden="true" className="absolute left-[-9999px] top-auto w-px h-px overflow-hidden">
399          <label>
400            Website
401            <input
402              type="text"
403              tabindex="-1"
404              autocomplete="off"
405              value=${honeypot}
406              onInput=${(e) => setHoneypot(e.target.value)}
407            />
408          </label>
409        </div>
410
411        <p className="text-xs text-stone-500 leading-relaxed">
412          ${isProblem
413            ? 'We automatically attach the page you are on, the archive version, and 
413your browser so we can reproduce the problem. Your report is posted as a public GitHub issue, so please leave out anything private. No account needed.'
414            : 'We automatically attach the page you are on and the archive version. Your suggestion is posted as a public GitHub issue, so please leave out anything private. No account needed.'}
415        </p>
416
417        ${formError && html`
418          <p role="alert" className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-sm px-3 py-2">
419            ${formError}
420          </p>
421        `}
422
423        <div className="flex items-center justify-between pt-1">
424          <button
425            type="button"
426            onClick=${() => { openFallback(); onClose(); }}
427            disabled=${submitting}
428            className="archive-action archive-action--quiet"
429          >
430            Prefer GitHub? Open the issue form
431          </button>
432          <button
433            type="submit"
434            disabled=${submitting}
435            className="archive-action archive-action--primary"
436          >
437            <${Send} className="w-4 h-4" aria-hidden="true" />
438            ${submitting ? 'Sending...' : 'Send report'}
439          </button>
440        </div>
441      </form>
442    `;
443  };
444
445  return html`
446    <div
447      className="archive-report-dialog archive-dialog-backdrop z-[100]"
448      onClick=${handleBackdrop}
449      role="dialog"
450      aria-modal="true"
451      aria-labelledby="bug-report-title"
452    >
453      <div ref=${modalRef} className="archive-dialog archive-report-dialog__panel flex flex-col overflow-hidden">
454        <div className="archive-report-dialog__header flex-shrink-0 px-6 py-4 flex items-center justify-between gap-4">
455          <div className="flex items-center gap-2">
456            <${Bug} className="w-5 h-5 text-stone-700 flex-shrink-0" aria-hidden="true" />
457            <h2 id="bug-report-title" className="font-display text-xl text-stone-800">
458              Report a problem or suggest a record
459            </h2>
460          </div>
461          <button
462            ref=${closeButtonRef}
463            onClick=${requestClose}
464            disabled=${submitting}
465            className="archive-action archive-action--quiet archive-report-dialog-close flex-shrink-0"
466            aria-label="Close report form"
467          >
468            <${X} className="w-5 h-5" aria-hidden="true" />
469          </button>
470        </div>
471        <!-- Inline minHeight:0 (not a Tailwind min-h-0 class): the pre-built,
472             zero-build stylesheet has no min-h-0 rule, and a flex child defaults
473             to min-height:auto, which refuses to shrink below its content and
474             defeats overflow-y-auto. This is the body's own scroll region. -->
475        <div className="flex-1 overflow-y-auto" style=${{ minHeight: 0 }}>
476          ${body()}
477        </div>
478      </div>
479    </div>
480  `;
481};
482
483export default BugReportModal;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.