Files
thrilltrack-explorer/src/components/moderation/SubmissionItemsList.tsx
gpt-engineer-app[bot] 4d7b00e4e7 feat: Implement rich timeline event display
Implement the plan to enhance the display of timeline event submissions in the moderation queue. This includes fixing the database function to fetch timeline event data, creating a new `RichTimelineEventDisplay` component, and updating the `SubmissionItemsList` and `TimelineEventPreview` components to leverage this new display. The goal is to provide moderators with complete and contextually rich information for timeline events.
2025-11-06 15:24:46 +00:00

339 lines
11 KiB
TypeScript

import { useState, useEffect, memo } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
import { RichParkDisplay } from './displays/RichParkDisplay';
import { RichRideDisplay } from './displays/RichRideDisplay';
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { AlertCircle, Loader2 } from 'lucide-react';
import { format } from 'date-fns';
import type { SubmissionItemData } from '@/types/submissions';
import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data';
import type { TimelineSubmissionData } from '@/types/timeline';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
interface SubmissionItemsListProps {
submissionId: string;
view?: 'summary' | 'detailed';
showImages?: boolean;
}
export const SubmissionItemsList = memo(function SubmissionItemsList({
submissionId,
view = 'summary',
showImages = true
}: SubmissionItemsListProps) {
const [items, setItems] = useState<SubmissionItemData[]>([]);
const [hasPhotos, setHasPhotos] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSubmissionItems();
}, [submissionId]);
const fetchSubmissionItems = async () => {
try {
// Only show skeleton on initial load, show refreshing indicator on refresh
if (loading) {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
// Use database function to fetch submission items with entity data in one query
// This eliminates N+1 query problem and properly handles RLS/AAL2 checks
const { data: itemsData, error: itemsError } = await supabase
.rpc('get_submission_items_with_entities', {
p_submission_id: submissionId
});
if (itemsError) throw itemsError;
// Transform to expected format with better null handling
const transformedItems = (itemsData || []).map((item: any) => {
// Ensure entity_data is at least an empty object, never null
const safeEntityData = item.entity_data && typeof item.entity_data === 'object'
? item.entity_data
: {};
return {
...item,
item_data: safeEntityData,
entity_data: item.entity_data // Keep original for debugging
};
});
// Check for photo submissions (using array query to avoid 406)
const { data: photoData, error: photoError } = await supabase
.from('photo_submissions')
.select('id')
.eq('submission_id', submissionId);
if (photoError) {
handleNonCriticalError(photoError, {
action: 'Check photo submissions',
metadata: { submissionId }
});
}
setItems(transformedItems as SubmissionItemData[]);
setHasPhotos(!!(photoData && photoData.length > 0));
} catch (err) {
handleNonCriticalError(err, {
action: 'Fetch submission items',
metadata: { submissionId }
});
setError('Failed to load submission details');
} finally {
setLoading(false);
setRefreshing(false);
}
};
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-16 w-full" />
{view === 'detailed' && <Skeleton className="h-32 w-full" />}
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (items.length === 0 && !hasPhotos) {
return (
<div className="text-sm text-muted-foreground">
No items found for this submission
</div>
);
}
// Render item with appropriate display component
const renderItem = (item: SubmissionItemData) => {
// SubmissionItemData from submissions.ts has item_data property
const entityData = item.item_data;
const actionType = item.action_type || 'create';
// Show item metadata (order_index, depends_on, timestamps, test data flag)
const itemMetadata = (
<div className="flex flex-wrap items-center gap-2 mb-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono">
#{item.order_index ?? 0}
</Badge>
{item.depends_on && (
<Badge variant="outline" className="text-xs">
Depends on: {item.depends_on.slice(0, 8)}...
</Badge>
)}
{(item as any).is_test_data && (
<Badge variant="outline" className="bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
Test Data
</Badge>
)}
{item.created_at && (
<span className="font-mono">
Created: {format(new Date(item.created_at), 'MMM d, HH:mm:ss')}
</span>
)}
{item.updated_at && item.updated_at !== item.created_at && (
<span className="font-mono">
Updated: {format(new Date(item.updated_at), 'MMM d, HH:mm:ss')}
</span>
)}
</div>
);
// Use summary view for compact display
if (view === 'summary') {
return (
<>
{itemMetadata}
<SubmissionChangesDisplay
item={item}
view={view}
showImages={showImages}
submissionId={submissionId}
/>
</>
);
}
// Use rich displays for detailed view - show BOTH rich display AND field-by-field changes
if (item.item_type === 'park' && entityData) {
return (
<>
{itemMetadata}
<RichParkDisplay
data={entityData as unknown as ParkSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if (item.item_type === 'ride' && entityData) {
return (
<>
{itemMetadata}
<RichRideDisplay
data={entityData as unknown as RideSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if ((['manufacturer', 'operator', 'designer', 'property_owner'] as const).some(type => type === item.item_type) && entityData) {
return (
<>
{itemMetadata}
<RichCompanyDisplay
data={entityData as unknown as CompanySubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if (item.item_type === 'ride_model' && entityData) {
return (
<>
{itemMetadata}
<RichRideModelDisplay
data={entityData as unknown as RideModelSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if ((item.item_type === 'milestone' || item.item_type === 'timeline_event') && entityData) {
return (
<>
{itemMetadata}
<RichTimelineEventDisplay
data={entityData as unknown as TimelineSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
// Fallback to SubmissionChangesDisplay
return (
<>
{itemMetadata}
<SubmissionChangesDisplay
item={item}
view={view}
showImages={showImages}
submissionId={submissionId}
/>
</>
);
};
return (
<ModerationErrorBoundary submissionId={submissionId}>
<div className={view === 'summary' ? 'flex flex-col gap-3' : 'flex flex-col gap-6'}>
{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' : ''}>
{renderItem(item)}
</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>
);
});