File size: 4,898 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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
/**
 * Reusable error banner with structured affordances.
 *
 * Every page used to hand-roll its own red card with just `{error.message}`
 * inside — no retry, no way to copy the full stack, no consistent layout.
 * This component is the standard: title, message, optional error code,
 * optional retry, and a "Copy full error" button that copies a JSON blob
 * (code + message + details) to the clipboard so users can paste it into
 * a bug report without screenshots.
 */
import { useState } from 'react'
import { AlertCircle, Copy, Check, RefreshCw } from 'lucide-react'

export interface ErrorCardProps {
  /** Short heading, e.g. "Couldn't load library". */
  title?: string
  /** Human-readable message. Defaults to the `Error.message` if `error` is given. */
  message?: string
  /** Backend error code (e.g. a stringified HTTP status, `ECONNREFUSED`). */
  code?: string
  /** Optional long-form details the user can copy to clipboard. */
  details?: string
  /** Raw Error / thrown value — used to derive defaults when message is blank. */
  error?: unknown
  /** Click handler for a "Retry" button. Hidden when undefined. */
  onRetry?: () => void
  /** Extra class names appended to the outer container. */
  className?: string
}

function deriveMessage(error: unknown): string {
  if (error instanceof Error) return error.message
  if (typeof error === 'string') return error
  if (error && typeof error === 'object' && 'message' in error) {
    const m = (error as { message?: unknown }).message
    if (typeof m === 'string') return m
  }
  return 'An unexpected error occurred.'
}

function deriveDetails(error: unknown): string | undefined {
  if (error instanceof Error && error.stack) return error.stack
  return undefined
}

export default function ErrorCard({
  title = 'Something went wrong',
  message,
  code,
  details,
  error,
  onRetry,
  className,
}: ErrorCardProps) {
  const [copied, setCopied] = useState(false)
  const effectiveMessage = message ?? (error !== undefined ? deriveMessage(error) : '')
  const effectiveDetails = details ?? (error !== undefined ? deriveDetails(error) : undefined)

  const onCopy = async () => {
    const payload = JSON.stringify(
      {
        title,
        code,
        message: effectiveMessage,
        details: effectiveDetails,
        timestamp: new Date().toISOString(),
      },
      null,
      2,
    )
    try {
      await navigator.clipboard.writeText(payload)
      setCopied(true)
      setTimeout(() => setCopied(false), 1500)
    } catch {
      // Clipboard APIs require a secure context — fall back to a prompt so
      // the user can still grab the text. We avoid logging to console
      // because the caller probably already surfaced the message inline.
      const win = typeof window !== 'undefined' ? window : null
      if (win) win.prompt('Copy error details:', payload)
    }
  }

  return (
    <div
      role="alert"
      className={
        'card flex items-start gap-3 border-rose-200 bg-rose-50 text-sm text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200 ' +
        (className ?? '')
      }
    >
      <AlertCircle size={16} className="mt-0.5 shrink-0" />
      <div className="min-w-0 flex-1">
        <div className="flex flex-wrap items-center gap-2">
          <span className="font-medium text-rose-800 dark:text-rose-100">{title}</span>
          {code && (
            <code className="rounded border border-rose-300/60 bg-white/50 px-1.5 py-0.5 font-mono text-[11px] text-rose-700 dark:border-rose-400/30 dark:bg-rose-500/10 dark:text-rose-200">
              {code}
            </code>
          )}
        </div>
        {effectiveMessage && (
          <div className="mt-1 break-words text-rose-700/90 dark:text-rose-200/90">
            {effectiveMessage}
          </div>
        )}
        <div className="mt-3 flex flex-wrap items-center gap-2">
          {onRetry && (
            <button
              type="button"
              onClick={onRetry}
              className="inline-flex items-center gap-1.5 rounded-md border border-rose-300 bg-white px-2.5 py-1 text-xs font-medium text-rose-700 transition-colors hover:bg-rose-50 dark:border-rose-400/40 dark:bg-transparent dark:text-rose-100 dark:hover:bg-rose-500/10"
            >
              <RefreshCw size={12} /> Retry
            </button>
          )}
          <button
            type="button"
            onClick={onCopy}
            className="inline-flex items-center gap-1.5 rounded-md border border-rose-300/60 bg-transparent px-2.5 py-1 text-xs font-medium text-rose-700 transition-colors hover:bg-white/60 dark:border-rose-400/30 dark:text-rose-100 dark:hover:bg-rose-500/10"
          >
            {copied ? <Check size={12} /> : <Copy size={12} />}
            {copied ? 'Copied' : 'Copy full error'}
          </button>
        </div>
      </div>
    </div>
  )
}