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;

View File

@@ -87,8 +87,11 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}
}, [user]);
// Start countdown timer for lock expiry
// Start countdown timer for lock expiry with improved memory leak prevention
const startLockTimer = useCallback((expiresAt: Date) => {
// Track if component is still mounted
let isMounted = true;
// Clear any existing timer first to prevent leaks
if (lockTimerRef.current) {
clearInterval(lockTimerRef.current);
@@ -96,6 +99,15 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}
lockTimerRef.current = setInterval(() => {
// Prevent timer execution if component unmounted
if (!isMounted) {
if (lockTimerRef.current) {
clearInterval(lockTimerRef.current);
lockTimerRef.current = null;
}
return;
}
const now = new Date();
const timeLeft = expiresAt.getTime() - now.getTime();
@@ -119,7 +131,16 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}
}
}, 1000);
}, [toast, onLockStateChange]); // Add dependencies to avoid stale closures
// Return cleanup function
return () => {
isMounted = false;
if (lockTimerRef.current) {
clearInterval(lockTimerRef.current);
lockTimerRef.current = null;
}
};
}, [toast, onLockStateChange]);
// Clean up timer on unmount
useEffect(() => {

View File

@@ -0,0 +1,64 @@
/**
* Type Guard Functions for Moderation Queue
*
* Provides runtime type checking for submission item data.
* Enables type-safe handling of different entity types.
*/
import type {
SubmissionItemData,
ParkItemData,
RideItemData,
CompanyItemData,
RideModelItemData,
PhotoItemData,
} from '@/types/moderation';
/**
* Check if item data is for a park
*/
export function isParkItemData(data: SubmissionItemData): data is ParkItemData {
return 'park_type' in data && 'name' in data;
}
/**
* Check if item data is for a ride
*/
export function isRideItemData(data: SubmissionItemData): data is RideItemData {
return ('ride_id' in data || 'park_id' in data) && 'ride_type' in data;
}
/**
* Check if item data is for a company
*/
export function isCompanyItemData(data: SubmissionItemData): data is CompanyItemData {
return 'company_type' in data && !('park_type' in data) && !('ride_type' in data);
}
/**
* Check if item data is for a ride model
*/
export function isRideModelItemData(data: SubmissionItemData): data is RideModelItemData {
return 'model_type' in data && 'manufacturer_id' in data;
}
/**
* Check if item data is for a photo
*/
export function isPhotoItemData(data: SubmissionItemData): data is PhotoItemData {
return 'photo_url' in data;
}
/**
* Get the entity type from item data (for validation and display)
*/
export function getEntityTypeFromItemData(data: SubmissionItemData): string {
if (isParkItemData(data)) return 'park';
if (isRideItemData(data)) return 'ride';
if (isCompanyItemData(data)) {
return data.company_type; // 'manufacturer', 'designer', etc.
}
if (isRideModelItemData(data)) return 'ride_model';
if (isPhotoItemData(data)) return 'photo';
return 'unknown';
}

View File

@@ -5,6 +5,119 @@
* Extracted from ModerationQueue.tsx to improve maintainability and reusability.
*/
/**
* Photo display interface for moderation queue
*/
export interface PhotoForDisplay {
id: string;
url: string;
cloudflare_image_url: string;
filename: string;
caption?: string | null;
title?: string | null;
date_taken?: string | null;
order_index: number;
}
/**
* Location data interface
*/
export interface LocationData {
id: string;
city?: string | null;
state_province?: string | null;
country: string;
formatted_address?: string | null;
}
/**
* Park submission item data
*/
export interface ParkItemData {
park_id?: string;
name: string;
slug: string;
description?: string;
park_type: string;
status: string;
location_id?: string;
operator_id?: string;
property_owner_id?: string;
opening_date?: string;
closing_date?: string;
source_url?: string;
submission_notes?: string;
}
/**
* Ride submission item data
*/
export interface RideItemData {
ride_id?: string;
name: string;
slug: string;
park_id: string;
manufacturer_id?: string;
designer_id?: string;
model_id?: string;
ride_type: string;
status: string;
opening_date?: string;
closing_date?: string;
source_url?: string;
submission_notes?: string;
}
/**
* Company submission item data
*/
export interface CompanyItemData {
company_id?: string;
name: string;
slug: string;
company_type: string;
country?: string;
founded_year?: number;
source_url?: string;
submission_notes?: string;
}
/**
* Ride model submission item data
*/
export interface RideModelItemData {
model_id?: string;
name: string;
slug: string;
manufacturer_id: string;
model_type: string;
source_url?: string;
submission_notes?: string;
}
/**
* Photo submission item data
*/
export interface PhotoItemData {
photo_url: string;
cloudflare_image_id?: string;
caption?: string;
title?: string;
order_index?: number;
source_url?: string;
submission_notes?: string;
}
/**
* Union type for all submission item data
*/
export type SubmissionItemData =
| ParkItemData
| RideItemData
| CompanyItemData
| RideModelItemData
| PhotoItemData;
/**
* Represents a single item in the moderation queue.
* Can be either a review or a content submission.
@@ -79,8 +192,8 @@ export interface ModerationItem {
submission_items?: Array<{
id: string;
item_type: string;
item_data: any;
original_data?: any;
item_data: SubmissionItemData;
original_data?: SubmissionItemData;
status: string;
}>;
}