feat: Implement all 7 phases

This commit is contained in:
gpt-engineer-app[bot]
2025-11-02 21:00:22 +00:00
parent bccaebc6d6
commit f3c898dfc1
12 changed files with 1236 additions and 42 deletions

View 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;
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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;

View File

@@ -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>
);
});

View File

@@ -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;