veza/apps/web/src/components/ui/ErrorDisplay.tsx
senke 66a56409ad feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):

- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
  for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
  (max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
  replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
  AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
  TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:15:58 +01:00

246 lines
12 KiB
TypeScript

import * as React from 'react';
import {
AlertTriangle,
AlertCircle,
Info,
X,
ChevronDown,
ChevronUp,
Bug,
Copy,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from './button';
import { Card, CardContent } from './card';
import { Dialog } from './dialog';
import type { ApiError } from '@/schemas/apiSchemas';
import { formatUserFriendlyError } from '@/utils/errorMessages';
import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler';
import {
formatIssueReport,
copyIssueReportToClipboard,
openGitHubIssue,
} from '@/utils/reportIssue';
import toast from '@/utils/toast';
export interface ErrorDisplayProps {
error: Error | ApiError | string | { message: string; code?: string | number; status?: number; details?: Record<string, any> };
onRetry?: () => void | Promise<void>;
onDismiss?: () => void;
showDetails?: boolean;
context?: {
action?: string;
resource?: string;
resourceId?: string;
[key: string]: any;
};
variant?: 'inline' | 'banner' | 'modal' | 'card';
severity?: 'error' | 'warning' | 'info';
size?: 'sm' | 'md' | 'lg';
className?: string;
dismissible?: boolean;
title?: string;
icon?: React.ReactNode;
actions?: Array<{ label: string; onClick: () => void; variant?: 'default' | 'outline' | 'ghost' }>;
}
function normalizeError(error: any): { message: string; code?: string | number; status?: number; details?: any; stack?: string } {
if (typeof error === 'string') return { message: error };
if (error instanceof Error) return { message: error.message, stack: error.stack };
if (error && typeof error === 'object') {
try {
const apiError = parseApiError(error);
return {
message: apiError.message || 'An error occurred',
code: apiError.code,
status: typeof apiError.code === 'number' ? apiError.code : undefined,
details: apiError.details,
};
} catch {
return {
message: (error as any).message || String(error),
code: (error as any).code,
status: (error as any).status,
details: (error as any).details,
};
}
}
return { message: String(error) };
}
export const ErrorDisplay = React.forwardRef<HTMLDivElement, ErrorDisplayProps>(({
error, onRetry, onDismiss, showDetails: showDetailsProp, context,
variant = 'inline', severity = 'error', size = 'md', className, dismissible,
title: titleProp, icon: iconProp, actions = [], ...props
}, ref) => {
const [isDetailsExpanded, setIsDetailsExpanded] = React.useState(false);
const [isRetrying, setIsRetrying] = React.useState(false);
const [isModalOpen, setIsModalOpen] = React.useState(true);
const normalizedError = React.useMemo(() => normalizeError(error), [error]);
const apiError = React.useMemo(() => parseApiError(error), [error]);
const errorCategory = React.useMemo(() => getErrorCategory(apiError), [apiError]);
const isServerError = React.useMemo(() => errorCategory === 'server_error' || (normalizedError.status !== undefined && normalizedError.status >= 500), [errorCategory, error]);
const isDev = import.meta.env.DEV;
const showDetails = showDetailsProp ?? isDev;
const userMessage = React.useMemo(() => {
if (normalizedError.message) {
try {
return formatUserFriendlyError(normalizedError as ApiError, context?.resource as any, false);
} catch {
return normalizedError.message;
}
}
return 'An unexpected error occurred';
}, [normalizedError, context]);
const title = React.useMemo(() => {
if (titleProp) return titleProp;
if (context?.action) return `Error ${context.action}`;
switch (severity) {
case 'error': return 'Error';
case 'warning': return 'Warning';
case 'info': return 'Information';
default: return 'Error';
}
}, [titleProp, context, severity]);
const iconConfig = React.useMemo(() => {
if (iconProp) return iconProp;
const iconSize = size === 'sm' ? 'w-4 h-4' : size === 'lg' ? 'w-6 h-6' : 'w-5 h-5';
switch (severity) {
case 'error': return <AlertCircle className={cn(iconSize, 'text-destructive')} />;
case 'warning': return <AlertTriangle className={cn(iconSize, 'text-warning')} />;
case 'info': return <Info className={cn(iconSize, 'text-info')} />;
default: return <AlertCircle className={cn(iconSize, 'text-destructive')} />;
}
}, [iconProp, severity, size]);
const colorStyles = React.useMemo(() => {
switch (severity) {
case 'error': return { bg: 'bg-destructive/10', border: 'border-destructive/30', text: 'text-destructive', icon: 'text-destructive' };
case 'warning': return { bg: 'bg-warning/10', border: 'border-warning/30', text: 'text-warning', icon: 'text-warning' };
case 'info': return { bg: 'bg-info/10', border: 'border-info/30', text: 'text-info', icon: 'text-info' };
default: return { bg: 'bg-destructive/10', border: 'border-destructive/30', text: 'text-destructive', icon: 'text-destructive' };
}
}, [severity]);
const sizeStyles = React.useMemo(() => {
switch (size) {
case 'sm': return { padding: 'p-4', text: 'text-xs', title: 'text-sm', gap: 'gap-2' };
case 'lg': return { padding: 'p-6', text: 'text-base', title: 'text-lg', gap: 'gap-4' };
default: return { padding: 'p-4', text: 'text-sm', title: 'text-base', gap: 'gap-4' };
}
}, [size]);
const handleRetry = React.useCallback(async () => {
if (!onRetry || isRetrying) return;
setIsRetrying(true);
try { await onRetry(); } finally { setIsRetrying(false); }
}, [onRetry, isRetrying]);
const handleDismiss = React.useCallback(() => {
if (onDismiss) onDismiss();
if (variant === 'modal') setIsModalOpen(false);
}, [onDismiss, variant]);
const handleReportIssue = React.useCallback(async () => {
try {
const report = formatIssueReport(error, { component: context?.resource, action: context?.action, userId: context?.userId, additionalInfo: context });
try { openGitHubIssue(report); toast.success('Opening GitHub issue...'); }
catch { await copyIssueReportToClipboard(report); toast.success('Issue report copied to clipboard'); }
} catch { toast.error('Failed to generate issue report'); }
}, [error, context]);
const handleCopyRequestId = React.useCallback(async () => {
if (!apiError.request_id) return;
try { await navigator.clipboard.writeText(apiError.request_id); toast.success('Request ID copied to clipboard'); }
catch { toast.error('Failed to copy request ID'); }
}, [apiError.request_id]);
const isDismissible = dismissible ?? (onDismiss !== undefined || variant === 'modal');
const renderDetails = () => {
if (!showDetails || !isDetailsExpanded) return null;
const details = [];
if (apiError.request_id) details.push({ label: 'Request ID', value: apiError.request_id });
if (normalizedError.code) details.push({ label: 'Error Code', value: String(normalizedError.code) });
if (normalizedError.status) details.push({ label: 'HTTP Status', value: String(normalizedError.status) });
if (normalizedError.details) details.push({ label: 'Details', value: JSON.stringify(normalizedError.details, null, 2) });
if (normalizedError.stack) details.push({ label: 'Stack Trace', value: normalizedError.stack });
if (context) details.push({ label: 'Context', value: JSON.stringify(context, null, 2) });
if (details.length === 0) return null;
return (
<div className="mt-4 pt-4 border-t border-white/10">
<div className="space-y-2">
{details.map((detail, idx) => (
<div key={idx} className="text-xs">
<span className="font-semibold opacity-70">{detail.label}:</span>
<pre className="mt-1 p-2 bg-black/20 rounded text-xs overflow-x-auto">
{typeof detail.value === 'string' ? detail.value : JSON.stringify(detail.value, null, 2)}
</pre>
</div>
))}
</div>
</div>
);
};
const errorContent = (
<div ref={ref} role="alert" aria-live="polite" className={cn('rounded-lg border flex shadow-card', colorStyles.bg, colorStyles.border, colorStyles.text, sizeStyles.padding, sizeStyles.gap, className)} {...props}>
<div className="flex-shrink-0 pt-0.5">{iconConfig}</div>
<div className="flex-1 min-w-0">
<div className={cn('font-semibold mb-1', sizeStyles.title)}>{title}</div>
<div className={cn('opacity-90', sizeStyles.text)}>{userMessage}</div>
{renderDetails()}
{(onRetry || actions.length > 0 || isServerError || (showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context))) && (
<div className="mt-4 flex flex-wrap gap-2 items-center">
{onRetry && <Button variant="outline" size={size === 'sm' ? 'sm' : 'default'} onClick={handleRetry} disabled={isRetrying} className="border-current text-current hover:bg-current/10 bg-transparent">{isRetrying ? 'Retrying...' : 'Retry'}</Button>}
{isServerError && apiError.request_id && (
<>
<Button variant="outline" size={size === 'sm' ? 'sm' : 'default'} onClick={handleCopyRequestId} className="border-current text-current hover:bg-current/10 bg-transparent" title="Copy Request ID"><Copy className="w-4 h-4 mr-1.5" />Copy ID</Button>
<Button variant="outline" size={size === 'sm' ? 'sm' : 'default'} onClick={handleReportIssue} className="border-current text-current hover:bg-current/10 bg-transparent"><Bug className="w-4 h-4 mr-1.5" />Report</Button>
</>
)}
{actions.map((action, idx) => (
<Button key={idx} variant={action.variant || 'outline'} size={size === 'sm' ? 'sm' : 'default'} onClick={action.onClick} className="border-current text-current hover:bg-current/10 bg-transparent">{action.label}</Button>
))}
{showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context) && (
<Button variant="ghost" size={size === 'sm' ? 'sm' : 'default'} onClick={() => setIsDetailsExpanded(!isDetailsExpanded)} className="text-current hover:bg-current/10 hover:text-current">{isDetailsExpanded ? <><ChevronUp className="w-4 h-4 mr-1" />Hide Details</> : <><ChevronDown className="w-4 h-4 mr-1" />Show Details</>}</Button>
)}
</div>
)}
</div>
{isDismissible && (
<button onClick={handleDismiss} className="opacity-70 hover:opacity-100 transition-opacity flex-shrink-0 self-start" aria-label="Dismiss error">
<X className={cn(size === 'sm' ? 'w-4 h-4' : 'w-5 h-5')} />
</button>
)}
</div>
);
switch (variant) {
case 'banner': return <div className="w-full">{errorContent}</div>;
case 'card': return <Card className={cn(colorStyles.border, className, "glass")}><CardContent className={cn(sizeStyles.padding, 'pt-6')}>{errorContent}</CardContent></Card>;
case 'modal': return (
<Dialog open={isModalOpen} onClose={handleDismiss} title={title} variant={severity === 'error' ? 'alert' : 'default'} footer={
<div className="flex gap-2 justify-end">
{onRetry && <Button variant="outline" onClick={handleRetry} disabled={isRetrying}>{isRetrying ? 'Retrying...' : 'Retry'}</Button>}
{actions.map((action, idx) => <Button key={idx} variant={action.variant || 'outline'} onClick={action.onClick}>{action.label}</Button>)}
<Button variant="default" onClick={handleDismiss}>{onRetry ? 'Close' : 'Dismiss'}</Button>
</div>
}>
<div className={cn(colorStyles.text)}>
<div className={cn('opacity-90 mb-4', sizeStyles.text)}>{userMessage}</div>
{renderDetails()}
</div>
</Dialog>
);
case 'inline': default: return errorContent;
}
});
ErrorDisplay.displayName = 'ErrorDisplay';