mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 21:51:17 -05:00
feat: Implement all 7 phases
This commit is contained in:
159
src/components/error/ModerationErrorBoundary.tsx
Normal file
159
src/components/error/ModerationErrorBoundary.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
interface ModerationErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
submissionId?: string;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ModerationErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary for Moderation Queue Components
|
||||
*
|
||||
* Prevents individual queue item render errors from crashing the entire queue.
|
||||
* Shows user-friendly error UI with retry functionality.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <ModerationErrorBoundary submissionId={item.id}>
|
||||
* <QueueItem item={item} />
|
||||
* </ModerationErrorBoundary>
|
||||
* ```
|
||||
*/
|
||||
export class ModerationErrorBoundary extends Component<
|
||||
ModerationErrorBoundaryProps,
|
||||
ModerationErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ModerationErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ModerationErrorBoundaryState> {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Log error to monitoring system
|
||||
logger.error('Moderation component error caught by boundary', {
|
||||
action: 'error_boundary_catch',
|
||||
submissionId: this.props.submissionId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
|
||||
// Update state with error info
|
||||
this.setState({
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call optional error handler
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<Card className="border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/10">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-300">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
Queue Item Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to render submission</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-sm">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
{this.props.submissionId && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
Submission ID: {this.props.submissionId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Retry
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
JSON.stringify({
|
||||
error: this.state.error?.message,
|
||||
stack: this.state.error?.stack,
|
||||
submissionId: this.props.submissionId,
|
||||
}, null, 2)
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy Error Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Show Component Stack
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-auto p-2 bg-muted rounded text-xs">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
import { useModerationQueueManager } from '@/hooks/moderation';
|
||||
import { QueueItem } from './QueueItem';
|
||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||
import { QueueSkeleton } from './QueueSkeleton';
|
||||
import { LockStatusDisplay } from './LockStatusDisplay';
|
||||
import { getLockStatus } from '@/lib/moderation/lockHelpers';
|
||||
@@ -199,9 +200,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
{queueManager.items.map((item, index) => (
|
||||
<QueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
<ModerationErrorBoundary key={item.id} submissionId={item.id}>
|
||||
<QueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isMobile={isMobile}
|
||||
actionLoading={queueManager.actionLoading}
|
||||
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until)}
|
||||
@@ -221,9 +223,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
onOpenItemEditor={handleOpenItemEditor}
|
||||
onClaimSubmission={queueManager.queue.claimSubmission}
|
||||
onDeleteSubmission={queueManager.deleteSubmission}
|
||||
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
|
||||
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
|
||||
/>
|
||||
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
|
||||
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
|
||||
/>
|
||||
</ModerationErrorBoundary>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -48,12 +48,16 @@ export const QueueFilters = ({
|
||||
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
||||
{/* Entity Type Filter */}
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
||||
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
||||
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
||||
<Select
|
||||
value={activeEntityFilter}
|
||||
onValueChange={onEntityFilterChange}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
||||
<SelectTrigger
|
||||
id="entity-filter"
|
||||
className={isMobile ? "h-10" : ""}
|
||||
aria-label="Filter by entity type"
|
||||
>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
{getEntityFilterIcon(activeEntityFilter)}
|
||||
@@ -92,12 +96,16 @@ export const QueueFilters = ({
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
|
||||
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
|
||||
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
|
||||
<Select
|
||||
value={activeStatusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
||||
<SelectTrigger
|
||||
id="status-filter"
|
||||
className={isMobile ? "h-10" : ""}
|
||||
aria-label="Filter by submission status"
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
||||
</SelectValue>
|
||||
@@ -132,6 +140,7 @@ export const QueueFilters = ({
|
||||
size={isMobile ? "default" : "sm"}
|
||||
onClick={onClearFilters}
|
||||
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Clear Filters
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePhotoSubmissionItems } from '@/hooks/usePhotoSubmissionItems';
|
||||
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
||||
import { normalizePhotoData } from '@/lib/photoHelpers';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
import type { PhotoForDisplay } from '@/types/moderation';
|
||||
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -41,7 +42,7 @@ interface QueueItemProps {
|
||||
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
|
||||
onResetToPending: (item: ModerationItem) => void;
|
||||
onRetryFailed: (item: ModerationItem) => void;
|
||||
onOpenPhotos: (photos: any[], index: number) => void;
|
||||
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
|
||||
onOpenReviewManager: (submissionId: string) => void;
|
||||
onOpenItemEditor: (submissionId: string) => void;
|
||||
onClaimSubmission: (submissionId: string) => void;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import type { SubmissionItemData } from '@/types/submissions';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||
|
||||
interface SubmissionItemsListProps {
|
||||
submissionId: string;
|
||||
@@ -97,32 +98,34 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{refreshing && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Refreshing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show regular submission items */}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view={view}
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<ModerationErrorBoundary submissionId={submissionId}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{refreshing && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Refreshing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show regular submission items */}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view={view}
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Show photo submission if exists */}
|
||||
{hasPhotos && (
|
||||
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<PhotoSubmissionDisplay submissionId={submissionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Show photo submission if exists */}
|
||||
{hasPhotos && (
|
||||
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<PhotoSubmissionDisplay submissionId={submissionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModerationErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,10 +7,12 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
||||
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
import type { SubmissionItemData } from '@/types/moderation';
|
||||
|
||||
interface ValidationSummaryProps {
|
||||
item: {
|
||||
item_type: string;
|
||||
item_data: any;
|
||||
item_data: SubmissionItemData;
|
||||
id?: string;
|
||||
};
|
||||
onValidationChange?: (result: ValidationResult) => void;
|
||||
|
||||
Reference in New Issue
Block a user