Spaces:
Running
Running
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>
)
}
|