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.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-06 15:24:46 +00:00
parent bd4f75bfb2
commit 4d7b00e4e7
3 changed files with 373 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ import { RichParkDisplay } from './displays/RichParkDisplay';
import { RichRideDisplay } from './displays/RichRideDisplay'; import { RichRideDisplay } from './displays/RichRideDisplay';
import { RichCompanyDisplay } from './displays/RichCompanyDisplay'; import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
import { RichRideModelDisplay } from './displays/RichRideModelDisplay'; import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -13,6 +14,7 @@ import { AlertCircle, Loader2 } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { SubmissionItemData } from '@/types/submissions'; import type { SubmissionItemData } from '@/types/submissions';
import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data'; import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data';
import type { TimelineSubmissionData } from '@/types/timeline';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler'; import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary'; import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
@@ -270,6 +272,29 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
); );
} }
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 // Fallback to SubmissionChangesDisplay
return ( return (
<> <>

View File

@@ -1,38 +1,93 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Calendar, Tag } from 'lucide-react'; import { Calendar, Tag, Building2, MapPin } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
import type { TimelineSubmissionData } from '@/types/timeline'; import type { TimelineSubmissionData } from '@/types/timeline';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface TimelineEventPreviewProps { interface TimelineEventPreviewProps {
data: TimelineSubmissionData; data: TimelineSubmissionData;
} }
export function TimelineEventPreview({ data }: TimelineEventPreviewProps) { export function TimelineEventPreview({ data }: TimelineEventPreviewProps) {
const [entityName, setEntityName] = useState<string | null>(null);
useEffect(() => {
if (!data?.entity_id || !data?.entity_type) return;
const fetchEntityName = async () => {
const table = data.entity_type === 'park' ? 'parks' : 'rides';
const { data: entity } = await supabase
.from(table)
.select('name')
.eq('id', data.entity_id)
.single();
setEntityName(entity?.name || null);
};
fetchEntityName();
}, [data?.entity_id, data?.entity_type]);
const formatEventType = (type: string) => { const formatEventType = (type: string) => {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}; };
const getEventTypeColor = (type: string) => {
const colors: Record<string, string> = {
opening: 'bg-green-600',
closure: 'bg-red-600',
reopening: 'bg-blue-600',
renovation: 'bg-purple-600',
expansion: 'bg-indigo-600',
acquisition: 'bg-amber-600',
name_change: 'bg-cyan-600',
operator_change: 'bg-orange-600',
owner_change: 'bg-orange-600',
location_change: 'bg-pink-600',
status_change: 'bg-yellow-600',
milestone: 'bg-emerald-600',
};
return colors[type] || 'bg-gray-600';
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Tag className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Timeline Event: {data.title} {data.title}
</CardTitle> </CardTitle>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<Badge className={`${getEventTypeColor(data.event_type)} text-white text-xs`}>
{formatEventType(data.event_type)}
</Badge>
<Badge variant="outline" className="text-xs">
{data.entity_type}
</Badge>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{entityName && (
<div className="flex items-center gap-2 text-sm">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Entity:</span>
<span className="text-foreground">{entityName}</span>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium">Event Type:</span> <span className="font-medium">Event Date:</span>
<p className="text-muted-foreground"> <p className="text-muted-foreground flex items-center gap-1 mt-1">
{formatEventType(data.event_type)}
</p>
</div>
<div>
<span className="font-medium">Date:</span>
<p className="text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{new Date(data.event_date).toLocaleDateString()} <FlexibleDateDisplay
({data.event_date_precision}) date={data.event_date}
precision={data.event_date_precision}
/>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Precision: {data.event_date_precision}
</p> </p>
</div> </div>
</div> </div>
@@ -45,6 +100,20 @@ export function TimelineEventPreview({ data }: TimelineEventPreviewProps) {
</span> </span>
</div> </div>
)} )}
{(data.from_entity_id || data.to_entity_id) && (
<div className="text-xs text-muted-foreground">
<Tag className="h-3 w-3 inline mr-1" />
Related entities: {data.from_entity_id ? 'From entity' : ''} {data.to_entity_id ? 'To entity' : ''}
</div>
)}
{(data.from_location_id || data.to_location_id) && (
<div className="text-xs text-muted-foreground">
<MapPin className="h-3 w-3 inline mr-1" />
Location change involved
</div>
)}
{data.description && ( {data.description && (
<div> <div>

View File

@@ -0,0 +1,266 @@
import { Calendar, Tag, ArrowRight, MapPin, Building2, Clock } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
import type { TimelineSubmissionData } from '@/types/timeline';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface RichTimelineEventDisplayProps {
data: TimelineSubmissionData;
actionType: 'create' | 'edit' | 'delete';
}
export function RichTimelineEventDisplay({ data, actionType }: RichTimelineEventDisplayProps) {
const [entityName, setEntityName] = useState<string | null>(null);
const [parkContext, setParkContext] = useState<string | null>(null);
const [fromEntity, setFromEntity] = useState<string | null>(null);
const [toEntity, setToEntity] = useState<string | null>(null);
const [fromLocation, setFromLocation] = useState<any>(null);
const [toLocation, setToLocation] = useState<any>(null);
useEffect(() => {
if (!data) return;
const fetchRelatedData = async () => {
// Fetch the main entity this timeline event is for
if (data.entity_id && data.entity_type) {
if (data.entity_type === 'park') {
const { data: park } = await supabase
.from('parks')
.select('name')
.eq('id', data.entity_id)
.single();
setEntityName(park?.name || null);
} else if (data.entity_type === 'ride') {
const { data: ride } = await supabase
.from('rides')
.select('name, park:parks(name)')
.eq('id', data.entity_id)
.single();
setEntityName(ride?.name || null);
setParkContext((ride?.park as any)?.name || null);
}
}
// Fetch from/to entities for relational changes
if (data.from_entity_id) {
const { data: entity } = await supabase
.from('companies')
.select('name')
.eq('id', data.from_entity_id)
.single();
setFromEntity(entity?.name || null);
}
if (data.to_entity_id) {
const { data: entity } = await supabase
.from('companies')
.select('name')
.eq('id', data.to_entity_id)
.single();
setToEntity(entity?.name || null);
}
// Fetch from/to locations for location changes
if (data.from_location_id) {
const { data: loc } = await supabase
.from('locations')
.select('*')
.eq('id', data.from_location_id)
.single();
setFromLocation(loc);
}
if (data.to_location_id) {
const { data: loc } = await supabase
.from('locations')
.select('*')
.eq('id', data.to_location_id)
.single();
setToLocation(loc);
}
};
fetchRelatedData();
}, [data.entity_id, data.entity_type, data.from_entity_id, data.to_entity_id, data.from_location_id, data.to_location_id]);
const formatEventType = (type: string) => {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
};
const getEventTypeColor = (type: string) => {
switch (type) {
case 'opening': return 'bg-green-600';
case 'closure': return 'bg-red-600';
case 'reopening': return 'bg-blue-600';
case 'renovation': return 'bg-purple-600';
case 'expansion': return 'bg-indigo-600';
case 'acquisition': return 'bg-amber-600';
case 'name_change': return 'bg-cyan-600';
case 'operator_change':
case 'owner_change': return 'bg-orange-600';
case 'location_change': return 'bg-pink-600';
case 'status_change': return 'bg-yellow-600';
case 'milestone': return 'bg-emerald-600';
default: return 'bg-gray-600';
}
};
const getPrecisionIcon = (precision: string) => {
switch (precision) {
case 'day': return '📅';
case 'month': return '📆';
case 'year': return '🗓️';
default: return '📅';
}
};
const formatLocation = (loc: any) => {
if (!loc) return null;
const parts = [loc.city, loc.state_province, loc.country].filter(Boolean);
return parts.join(', ');
};
return (
<div className="space-y-4">
{/* Header Section */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Calendar className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground">{data.title}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge className={`${getEventTypeColor(data.event_type)} text-white text-xs`}>
{formatEventType(data.event_type)}
</Badge>
{actionType === 'create' && (
<Badge className="bg-green-600 text-white text-xs">New Event</Badge>
)}
{actionType === 'edit' && (
<Badge className="bg-amber-600 text-white text-xs">Edit Event</Badge>
)}
{actionType === 'delete' && (
<Badge variant="destructive" className="text-xs">Delete Event</Badge>
)}
</div>
</div>
</div>
<Separator />
{/* Entity Context Section */}
<div className="grid gap-3">
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Event For:</span>
<span className="text-foreground">
{entityName || 'Loading...'}
<Badge variant="outline" className="ml-2 text-xs">
{data.entity_type}
</Badge>
</span>
</div>
{parkContext && (
<div className="flex items-center gap-2 text-sm">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Park:</span>
<span className="text-foreground">{parkContext}</span>
</div>
)}
</div>
<Separator />
{/* Event Date Section */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Event Date:</span>
</div>
<div className="flex items-center gap-3 pl-6">
<span className="text-2xl">{getPrecisionIcon(data.event_date_precision)}</span>
<div>
<div className="text-lg font-semibold">
<FlexibleDateDisplay
date={data.event_date}
precision={data.event_date_precision}
/>
</div>
<div className="text-xs text-muted-foreground">
Precision: {data.event_date_precision}
</div>
</div>
</div>
</div>
{/* Change Details Section */}
{(data.from_value || data.to_value || fromEntity || toEntity) && (
<>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Change Details:</div>
<div className="flex items-center gap-3 pl-6">
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">From</div>
<div className="font-medium">
{fromEntity || data.from_value || '—'}
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">To</div>
<div className="font-medium">
{toEntity || data.to_value || '—'}
</div>
</div>
</div>
</div>
</>
)}
{/* Location Change Section */}
{(fromLocation || toLocation) && (
<>
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<MapPin className="h-4 w-4" />
Location Change:
</div>
<div className="flex items-center gap-3 pl-6">
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">From</div>
<div className="font-medium">
{formatLocation(fromLocation) || '—'}
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">To</div>
<div className="font-medium">
{formatLocation(toLocation) || '—'}
</div>
</div>
</div>
</div>
</>
)}
{/* Description Section */}
{data.description && (
<>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Description:</div>
<p className="text-sm text-muted-foreground pl-6 leading-relaxed">
{data.description}
</p>
</div>
</>
)}
</div>
);
}