File size: 3,723 Bytes
5f3e9f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import React from 'react'
import { AlertOctagon, RefreshCw } from 'lucide-react'

interface Props {
  children: React.ReactNode
}

interface State {
  error: Error | null
  info: React.ErrorInfo | null
}

/**
 * Top-level error boundary. Catches any render-time exception in the
 * React tree and renders a recoverable error card instead of a blank
 * white screen. Component-level errors below this point should still
 * use local try/catch + toast for inline failures (network etc.).
 */
export default class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { error: null, info: null }
  }

  static getDerivedStateFromError(error: Error): Partial<State> {
    return { error }
  }

  componentDidCatch(error: Error, info: React.ErrorInfo): void {
    // Keep the structured info around for the "Copy details" button. We
    // intentionally do NOT post this anywhere — the app is local-first.
    this.setState({ error, info })
    console.error('[ErrorBoundary]', error, info?.componentStack)
  }

  reset = (): void => {
    this.setState({ error: null, info: null })
  }

  reload = (): void => {
    window.location.reload()
  }

  copyDetails = async (): Promise<void> => {
    const { error, info } = this.state
    if (!error) return
    const text = [
      `Error: ${error.name}: ${error.message}`,
      '',
      'Stack:',
      error.stack ?? '(no stack)',
      '',
      'Component stack:',
      info?.componentStack ?? '(no component stack)',
      '',
      `URL: ${window.location.href}`,
      `User agent: ${navigator.userAgent}`,
    ].join('\n')
    try {
      await navigator.clipboard.writeText(text)
    } catch {
      /* ignore — clipboard may be unavailable in non-secure contexts */
    }
  }

  render(): React.ReactNode {
    if (!this.state.error) return this.props.children

    const { error } = this.state
    return (
      <div
        role="alert"
        className="mx-auto my-12 max-w-xl rounded-xl border border-rose-200 bg-rose-50 p-6 text-rose-800 shadow-sm dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100"
      >
        <div className="flex items-start gap-3">
          <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-rose-100 text-rose-600 dark:bg-rose-500/20 dark:text-rose-200">
            <AlertOctagon size={18} />
          </div>
          <div className="min-w-0 flex-1">
            <h2 className="font-display text-lg font-semibold">
              Something broke while rendering this page
            </h2>
            <p className="mt-1 text-sm text-rose-700/90 dark:text-rose-100/80">
              The app caught the error so the rest of TextBro keeps running.
              You can try going back, reloading, or copying the details to
              report the bug.
            </p>
            <pre className="mt-3 max-h-40 overflow-auto rounded-md bg-white/60 p-3 text-xs text-rose-900 dark:bg-black/30 dark:text-rose-100">
              {error.name}: {error.message}
            </pre>
            <div className="mt-4 flex flex-wrap gap-2">
              <button type="button" className="btn-primary" onClick={this.reload}>
                <RefreshCw size={14} /> Reload app
              </button>
              <button type="button" className="btn-secondary" onClick={this.reset}>
                Try again
              </button>
              <button
                type="button"
                className="btn-ghost"
                onClick={() => void this.copyDetails()}
              >
                Copy details
              </button>
            </div>
          </div>
        </div>
      </div>
    )
  }
}