mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 04:47:04 -05:00
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.
339 lines
11 KiB
TypeScript
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>
|
|
);
|
|
});
|