2026-01-26 13:12:17 +00:00
|
|
|
|
2026-01-11 15:58:54 +00:00
|
|
|
import * as React from 'react';
|
2026-01-13 18:47:57 +00:00
|
|
|
import {
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
Info,
|
|
|
|
|
X,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronUp,
|
|
|
|
|
Bug,
|
2026-01-15 18:52:48 +00:00
|
|
|
Copy,
|
2026-01-13 18:47:57 +00:00
|
|
|
} from 'lucide-react';
|
2026-01-11 15:58:54 +00:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { Button } from './button';
|
|
|
|
|
import { Card, CardContent } from './card';
|
|
|
|
|
import { Dialog } from './dialog';
|
2026-01-15 16:03:35 +00:00
|
|
|
import type { ApiError } from '@/schemas/apiSchemas';
|
2026-01-11 15:58:54 +00:00
|
|
|
import { formatUserFriendlyError } from '@/utils/errorMessages';
|
2026-01-11 16:29:11 +00:00
|
|
|
import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler';
|
2026-01-13 18:47:57 +00:00
|
|
|
import {
|
|
|
|
|
formatIssueReport,
|
|
|
|
|
copyIssueReportToClipboard,
|
|
|
|
|
openGitHubIssue,
|
|
|
|
|
} from '@/utils/reportIssue';
|
2026-01-15 16:03:35 +00:00
|
|
|
import toast from '@/utils/toast';
|
2026-01-11 15:58:54 +00:00
|
|
|
|
|
|
|
|
export interface ErrorDisplayProps {
|
2026-01-26 13:12:17 +00:00
|
|
|
error: Error | ApiError | string | { message: string; code?: string | number; status?: number; details?: Record<string, any> };
|
2026-01-11 15:58:54 +00:00
|
|
|
onRetry?: () => void | Promise<void>;
|
|
|
|
|
onDismiss?: () => void;
|
|
|
|
|
showDetails?: boolean;
|
|
|
|
|
context?: {
|
2026-01-26 13:12:17 +00:00
|
|
|
action?: string;
|
|
|
|
|
resource?: string;
|
|
|
|
|
resourceId?: string;
|
|
|
|
|
[key: string]: any;
|
2026-01-11 15:58:54 +00:00
|
|
|
};
|
|
|
|
|
variant?: 'inline' | 'banner' | 'modal' | 'card';
|
|
|
|
|
severity?: 'error' | 'warning' | 'info';
|
|
|
|
|
size?: 'sm' | 'md' | 'lg';
|
|
|
|
|
className?: string;
|
|
|
|
|
dismissible?: boolean;
|
|
|
|
|
title?: string;
|
|
|
|
|
icon?: React.ReactNode;
|
2026-01-26 13:12:17 +00:00
|
|
|
actions?: Array<{ label: string; onClick: () => void; variant?: 'default' | 'outline' | 'ghost' }>;
|
2026-01-11 15:58:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
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 };
|
2026-01-11 15:58:54 +00:00
|
|
|
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) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
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) {
|
2026-01-11 15:58:54 +00:00
|
|
|
try {
|
2026-01-26 13:12:17 +00:00
|
|
|
return formatUserFriendlyError(normalizedError as ApiError, context?.resource as any, false);
|
|
|
|
|
} catch {
|
|
|
|
|
return normalizedError.message;
|
2026-01-11 15:58:54 +00:00
|
|
|
}
|
2026-01-26 13:12:17 +00:00
|
|
|
}
|
|
|
|
|
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]);
|
2026-01-11 15:58:54 +00:00
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
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]);
|
2026-01-11 15:58:54 +00:00
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
const handleRetry = React.useCallback(async () => {
|
|
|
|
|
if (!onRetry || isRetrying) return;
|
|
|
|
|
setIsRetrying(true);
|
|
|
|
|
try { await onRetry(); } finally { setIsRetrying(false); }
|
|
|
|
|
}, [onRetry, isRetrying]);
|
2026-01-11 15:58:54 +00:00
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
const handleDismiss = React.useCallback(() => {
|
|
|
|
|
if (onDismiss) onDismiss();
|
|
|
|
|
if (variant === 'modal') setIsModalOpen(false);
|
|
|
|
|
}, [onDismiss, variant]);
|
2026-01-11 15:58:54 +00:00
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-01-11 15:58:54 +00:00
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
const errorContent = (
|
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 16:15:58 +00:00
|
|
|
<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}>
|
2026-01-26 13:12:17 +00:00
|
|
|
<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>
|
2026-01-11 15:58:54 +00:00
|
|
|
))}
|
2026-01-26 13:12:17 +00:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-01-11 15:58:54 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-26 13:12:17 +00:00
|
|
|
</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>
|
2026-01-11 15:58:54 +00:00
|
|
|
{renderDetails()}
|
|
|
|
|
</div>
|
2026-01-26 13:12:17 +00:00
|
|
|
</Dialog>
|
2026-01-11 15:58:54 +00:00
|
|
|
);
|
2026-01-26 13:12:17 +00:00
|
|
|
case 'inline': default: return errorContent;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-11 15:58:54 +00:00
|
|
|
|
|
|
|
|
ErrorDisplay.displayName = 'ErrorDisplay';
|