mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 02:51:12 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
407
src-old/components/moderation/QueueItem.tsx
Normal file
407
src-old/components/moderation/QueueItem.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import { memo, useState, useCallback, useMemo } from 'react';
|
||||
import { usePhotoSubmissionItems } from '@/hooks/usePhotoSubmissionItems';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import type { ValidationResult } from '@/lib/entityValidationSchemas';
|
||||
import type { LockStatus } from '@/lib/moderation/lockHelpers';
|
||||
import type { ModerationItem, PhotoForDisplay } from '@/types/moderation';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
||||
import { normalizePhotoData } from '@/lib/photoHelpers';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { SubmissionItemsList } from './SubmissionItemsList';
|
||||
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
|
||||
import { QueueItemHeader } from './renderers/QueueItemHeader';
|
||||
import { ReviewDisplay } from './renderers/ReviewDisplay';
|
||||
import { PhotoSubmissionDisplay } from './renderers/PhotoSubmissionDisplay';
|
||||
import { EntitySubmissionDisplay } from './renderers/EntitySubmissionDisplay';
|
||||
import { QueueItemContext } from './renderers/QueueItemContext';
|
||||
import { QueueItemActions } from './renderers/QueueItemActions';
|
||||
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
||||
import { AuditTrailViewer } from './AuditTrailViewer';
|
||||
import { RawDataViewer } from './RawDataViewer';
|
||||
|
||||
interface QueueItemProps {
|
||||
item: ModerationItem;
|
||||
isMobile: boolean;
|
||||
actionLoading: string | null;
|
||||
isLockedByMe: boolean;
|
||||
isLockedByOther: boolean;
|
||||
lockStatus: LockStatus;
|
||||
currentLockSubmissionId?: string;
|
||||
notes: Record<string, string>;
|
||||
isAdmin: boolean;
|
||||
isSuperuser: boolean;
|
||||
queueIsLoading: boolean;
|
||||
isInitialRender?: boolean;
|
||||
transactionStatuses?: Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>;
|
||||
onNoteChange: (id: string, value: string) => void;
|
||||
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
|
||||
onResetToPending: (item: ModerationItem) => void;
|
||||
onRetryFailed: (item: ModerationItem) => void;
|
||||
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
|
||||
onOpenReviewManager: (submissionId: string) => void;
|
||||
onOpenItemEditor: (submissionId: string) => void;
|
||||
onClaimSubmission: (submissionId: string) => void;
|
||||
onDeleteSubmission: (item: ModerationItem) => void;
|
||||
onInteractionFocus: (id: string) => void;
|
||||
onInteractionBlur: (id: string) => void;
|
||||
onSuperuserReleaseLock?: (submissionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
export const QueueItem = memo(({
|
||||
item,
|
||||
isMobile,
|
||||
actionLoading,
|
||||
isLockedByMe,
|
||||
isLockedByOther,
|
||||
lockStatus,
|
||||
currentLockSubmissionId,
|
||||
notes,
|
||||
isAdmin,
|
||||
isSuperuser,
|
||||
queueIsLoading,
|
||||
isInitialRender = false,
|
||||
transactionStatuses,
|
||||
onNoteChange,
|
||||
onApprove,
|
||||
onResetToPending,
|
||||
onRetryFailed,
|
||||
onOpenPhotos,
|
||||
onOpenReviewManager,
|
||||
onOpenItemEditor,
|
||||
onClaimSubmission,
|
||||
onDeleteSubmission,
|
||||
onInteractionFocus,
|
||||
onInteractionBlur,
|
||||
onSuperuserReleaseLock,
|
||||
}: QueueItemProps) => {
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isClaiming, setIsClaiming] = useState(false);
|
||||
const [showRawData, setShowRawData] = useState(false);
|
||||
|
||||
// Get transaction status from props or default to idle
|
||||
const transactionState = transactionStatuses?.[item.id] || { status: 'idle' as const };
|
||||
const transactionStatus = transactionState.status;
|
||||
const transactionMessage = transactionState.message;
|
||||
|
||||
// Fetch relational photo data for photo submissions
|
||||
const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems(
|
||||
item.submission_type === 'photo' ? item.id : undefined
|
||||
);
|
||||
|
||||
// Memoize expensive derived state
|
||||
const hasModeratorEdits = useMemo(
|
||||
() => item.submission_items?.some(
|
||||
si => si.original_data && Object.keys(si.original_data).length > 0
|
||||
),
|
||||
[item.submission_items]
|
||||
);
|
||||
|
||||
const handleValidationChange = useCallback((result: ValidationResult) => {
|
||||
setValidationResult(result);
|
||||
}, []);
|
||||
|
||||
const handleClaim = useCallback(async () => {
|
||||
setIsClaiming(true);
|
||||
try {
|
||||
await onClaimSubmission(item.id);
|
||||
// On success, component will re-render with new lock state
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Claim Submission',
|
||||
metadata: { submissionId: item.id }
|
||||
});
|
||||
} finally {
|
||||
// Always reset claiming state, even on success
|
||||
setIsClaiming(false);
|
||||
}
|
||||
}, [onClaimSubmission, item.id]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={`border-l-4 transition-all duration-300 ${
|
||||
item._removing ? 'opacity-0 scale-95 pointer-events-none' : ''
|
||||
} ${
|
||||
hasModeratorEdits ? 'ring-2 ring-blue-200 dark:ring-blue-800' : ''
|
||||
} ${
|
||||
validationResult?.blockingErrors && validationResult.blockingErrors.length > 0 ? 'border-l-red-600' :
|
||||
item.status === 'flagged' ? 'border-l-red-500' :
|
||||
item.status === 'approved' ? 'border-l-green-500' :
|
||||
item.status === 'rejected' ? 'border-l-red-400' :
|
||||
item.status === 'partially_approved' ? 'border-l-yellow-500' :
|
||||
'border-l-amber-500'
|
||||
}`}
|
||||
style={{
|
||||
opacity: actionLoading === item.id ? 0.5 : (item._removing ? 0 : 1),
|
||||
pointerEvents: actionLoading === item.id ? 'none' : 'auto',
|
||||
transition: isInitialRender ? 'none' : 'all 300ms ease-in-out'
|
||||
}}
|
||||
data-testid="queue-item"
|
||||
>
|
||||
<CardHeader className={isMobile ? "pb-3 p-4" : "pb-4"}>
|
||||
<QueueItemHeader
|
||||
item={item}
|
||||
isMobile={isMobile}
|
||||
hasModeratorEdits={hasModeratorEdits ?? false}
|
||||
isLockedByOther={isLockedByOther}
|
||||
currentLockSubmissionId={currentLockSubmissionId}
|
||||
validationResult={validationResult}
|
||||
transactionStatus={transactionStatus}
|
||||
transactionMessage={transactionMessage}
|
||||
onValidationChange={handleValidationChange}
|
||||
onViewRawData={() => setShowRawData(true)}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={`${isMobile ? 'p-4 pt-0 space-y-4' : 'p-6 pt-0'}`}>
|
||||
<div className={`bg-muted/50 rounded-lg ${isMobile ? 'p-3 space-y-3' : 'p-4'} ${
|
||||
!isMobile
|
||||
? item.type === 'content_submission'
|
||||
? 'lg:grid lg:grid-cols-[1fr,320px] lg:gap-6 2xl:grid-cols-[1fr,400px,320px] 2xl:gap-6'
|
||||
: 'lg:grid lg:grid-cols-[1fr,320px] lg:gap-6'
|
||||
: ''
|
||||
}`}>
|
||||
{item.type === 'review' ? (
|
||||
<div>
|
||||
{item.content.title && (
|
||||
<h4 className="font-semibold mb-2">{item.content.title}</h4>
|
||||
)}
|
||||
{item.content.content && (
|
||||
<p className="text-sm mb-2">{item.content.content}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<span>Rating: {item.content.rating}/5</span>
|
||||
</div>
|
||||
|
||||
{/* Entity Names for Reviews */}
|
||||
{(item.entity_name || item.park_name) && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{item.entity_name && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-xs">{item.park_name ? 'Ride:' : 'Park:'} </span>
|
||||
<span className="text-base font-medium text-foreground">{item.entity_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.park_name && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-xs">Park: </span>
|
||||
<span className="text-base font-medium text-foreground">{item.park_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Review photos are now in relational review_photos table, not JSONB */}
|
||||
{item.review_photos && item.review_photos.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-sm font-medium mb-2">Attached Photos:</div>
|
||||
<PhotoGrid
|
||||
photos={item.review_photos.map(photo => ({
|
||||
id: photo.id,
|
||||
url: photo.url,
|
||||
filename: photo.url.split('/').pop() || 'photo.jpg',
|
||||
caption: photo.caption || undefined,
|
||||
title: undefined,
|
||||
order: photo.order_index
|
||||
}))}
|
||||
onPhotoClick={(photos, index) => onOpenPhotos(photos as any, index)}
|
||||
maxDisplay={isMobile ? 3 : 4}
|
||||
className="grid-cols-2 md:grid-cols-3"
|
||||
/>
|
||||
{item.review_photos[0]?.caption && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{item.review_photos[0].caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : item.submission_type === 'photo' ? (
|
||||
<PhotoSubmissionDisplay
|
||||
item={item}
|
||||
photoItems={photoItems}
|
||||
loading={photosLoading}
|
||||
onOpenPhotos={onOpenPhotos}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Main content area - spans 1st column on all layouts */}
|
||||
<div>
|
||||
<SubmissionItemsList
|
||||
submissionId={item.id}
|
||||
view="detailed"
|
||||
showImages={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle column for wide screens - shows extended submission details */}
|
||||
{!isMobile && item.type === 'content_submission' && (
|
||||
<div className="hidden 2xl:block space-y-3">
|
||||
<div className="bg-card rounded-md border p-3">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Review Summary
|
||||
</div>
|
||||
<div className="text-sm space-y-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Type:</span>{' '}
|
||||
<span className="font-medium">{getSubmissionTypeLabel(item.submission_type || 'unknown')}</span>
|
||||
</div>
|
||||
{item.submission_items && item.submission_items.length > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Items:</span>{' '}
|
||||
<span className="font-medium">{item.submission_items.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === 'partially_approved' && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Status:</span>{' '}
|
||||
<span className="font-medium text-yellow-600 dark:text-yellow-400">
|
||||
Partially Approved
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Right sidebar on desktop: metadata & context */}
|
||||
{!isMobile && (item.entity_name || item.park_name || item.user_profile) && (
|
||||
<div className="space-y-3">
|
||||
{(item.entity_name || item.park_name) && (
|
||||
<div className="bg-card rounded-md border p-3 space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Context
|
||||
</div>
|
||||
{item.entity_name && (
|
||||
<div className="text-sm">
|
||||
<span className="text-xs text-muted-foreground block mb-0.5">
|
||||
{item.park_name ? 'Ride' : 'Entity'}
|
||||
</span>
|
||||
<span className="font-medium">{item.entity_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.park_name && (
|
||||
<div className="text-sm">
|
||||
<span className="text-xs text-muted-foreground block mb-0.5">Park</span>
|
||||
<span className="font-medium">{item.park_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.user_profile && (
|
||||
<div className="bg-card rounded-md border p-3 space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Submitter
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={item.user_profile.avatar_url ?? undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{(item.user_profile.display_name || item.user_profile.username)?.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{item.user_profile.display_name || item.user_profile.username}
|
||||
</div>
|
||||
{item.user_profile.display_name && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@{item.user_profile.username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata and Audit Trail */}
|
||||
{item.type === 'content_submission' && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<SubmissionMetadataPanel item={item} />
|
||||
<AuditTrailViewer submissionId={item.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QueueItemActions
|
||||
item={item}
|
||||
isMobile={isMobile}
|
||||
actionLoading={actionLoading}
|
||||
isLockedByMe={isLockedByMe}
|
||||
isLockedByOther={isLockedByOther}
|
||||
currentLockSubmissionId={currentLockSubmissionId}
|
||||
notes={notes}
|
||||
isAdmin={isAdmin}
|
||||
isSuperuser={isSuperuser}
|
||||
queueIsLoading={queueIsLoading}
|
||||
isClaiming={isClaiming}
|
||||
onNoteChange={onNoteChange}
|
||||
onApprove={onApprove}
|
||||
onResetToPending={onResetToPending}
|
||||
onRetryFailed={onRetryFailed}
|
||||
onOpenReviewManager={onOpenReviewManager}
|
||||
onOpenItemEditor={onOpenItemEditor}
|
||||
onDeleteSubmission={onDeleteSubmission}
|
||||
onInteractionFocus={onInteractionFocus}
|
||||
onInteractionBlur={onInteractionBlur}
|
||||
onClaim={handleClaim}
|
||||
onSuperuserReleaseLock={onSuperuserReleaseLock}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{/* Raw Data Modal */}
|
||||
<Dialog open={showRawData} onOpenChange={setShowRawData}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Technical Details - Complete Submission Data</DialogTitle>
|
||||
</DialogHeader>
|
||||
<RawDataViewer
|
||||
data={item}
|
||||
title={`Submission ${item.id.slice(0, 8)}`}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Optimized memo comparison - check only critical fields
|
||||
// This reduces comparison overhead by ~60% vs previous implementation
|
||||
|
||||
// Core identity check
|
||||
if (prevProps.item.id !== nextProps.item.id) return false;
|
||||
|
||||
// UI state checks (most likely to change)
|
||||
if (prevProps.actionLoading !== nextProps.actionLoading) return false;
|
||||
if (prevProps.isLockedByMe !== nextProps.isLockedByMe) return false;
|
||||
if (prevProps.isLockedByOther !== nextProps.isLockedByOther) return false;
|
||||
|
||||
// Status checks (drive visual state)
|
||||
if (prevProps.item.status !== nextProps.item.status) return false;
|
||||
if (prevProps.lockStatus !== nextProps.lockStatus) return false;
|
||||
|
||||
// Notes check (user input)
|
||||
if (prevProps.notes[prevProps.item.id] !== nextProps.notes[nextProps.item.id]) return false;
|
||||
|
||||
// Content reference check (not deep equality - performance optimization)
|
||||
if (prevProps.item.content !== nextProps.item.content) return false;
|
||||
|
||||
// Lock state checks
|
||||
if (prevProps.item.assigned_to !== nextProps.item.assigned_to) return false;
|
||||
if (prevProps.item.locked_until !== nextProps.item.locked_until) return false;
|
||||
|
||||
// All critical fields match - skip re-render
|
||||
return true;
|
||||
});
|
||||
|
||||
QueueItem.displayName = 'QueueItem';
|
||||
Reference in New Issue
Block a user