mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 17:51:12 -05:00
417 lines
17 KiB
TypeScript
417 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { FieldDiff, ImageDiff, LocationDiff } from './FieldComparison';
|
|
import { PhotoAdditionPreview, PhotoEditPreview, PhotoDeletionPreview } from './PhotoComparison';
|
|
import { detectChanges, type ChangesSummary } from '@/lib/submissionChangeDetection';
|
|
import type { SubmissionItemData } from '@/types/submissions';
|
|
import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
|
import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle, Calendar } from 'lucide-react';
|
|
import { TimelineEventPreview } from './TimelineEventPreview';
|
|
import type { TimelineSubmissionData } from '@/types/timeline';
|
|
|
|
interface SubmissionChangesDisplayProps {
|
|
item: SubmissionItemData | SubmissionItemWithDeps | {
|
|
item_data?: unknown;
|
|
original_data?: unknown;
|
|
item_type: string;
|
|
action_type?: 'create' | 'edit' | 'delete'
|
|
};
|
|
view?: 'summary' | 'detailed';
|
|
showImages?: boolean;
|
|
submissionId?: string;
|
|
}
|
|
|
|
// Helper to determine change magnitude
|
|
function getChangeMagnitude(totalChanges: number, hasImages: boolean, action: string) {
|
|
if (action === 'delete') return { label: 'Deletion', variant: 'destructive' as const, icon: AlertTriangle };
|
|
if (action === 'create') return { label: 'New', variant: 'default' as const, icon: Plus };
|
|
if (hasImages) return { label: 'Major', variant: 'default' as const, icon: Edit };
|
|
if (totalChanges >= 5) return { label: 'Major', variant: 'default' as const, icon: Edit };
|
|
if (totalChanges >= 3) return { label: 'Moderate', variant: 'secondary' as const, icon: Edit };
|
|
return { label: 'Minor', variant: 'outline' as const, icon: Edit };
|
|
}
|
|
|
|
export function SubmissionChangesDisplay({
|
|
item,
|
|
view = 'summary',
|
|
showImages = true,
|
|
submissionId
|
|
}: SubmissionChangesDisplayProps) {
|
|
const [changes, setChanges] = useState<ChangesSummary | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const loadChanges = async () => {
|
|
setLoading(true);
|
|
const detectedChanges = await detectChanges(item, submissionId);
|
|
setChanges(detectedChanges);
|
|
setLoading(false);
|
|
};
|
|
loadChanges();
|
|
}, [item, submissionId]);
|
|
|
|
if (loading || !changes) {
|
|
return <Skeleton className="h-16 w-full" />;
|
|
}
|
|
|
|
// Get appropriate icon for entity type
|
|
const getEntityIcon = () => {
|
|
const iconClass = "h-4 w-4";
|
|
switch (item.item_type) {
|
|
case 'park': return <Building2 className={iconClass} />;
|
|
case 'ride': return <Train className={iconClass} />;
|
|
case 'ride_model': return <Train className={iconClass} />;
|
|
case 'manufacturer':
|
|
case 'operator':
|
|
case 'property_owner':
|
|
case 'designer': return <Building className={iconClass} />;
|
|
case 'photo':
|
|
case 'photo_edit':
|
|
case 'photo_delete': return <ImageIcon className={iconClass} />;
|
|
case 'milestone':
|
|
case 'timeline_event': return <Calendar className={iconClass} />;
|
|
default: return <MapPin className={iconClass} />;
|
|
}
|
|
};
|
|
|
|
// Get action badge
|
|
const getActionBadge = () => {
|
|
switch (changes.action) {
|
|
case 'create':
|
|
return <Badge className="bg-green-600"><Plus className="h-3 w-3 mr-1" />New</Badge>;
|
|
case 'edit':
|
|
return <Badge className="bg-amber-600"><Edit className="h-3 w-3 mr-1" />Edit</Badge>;
|
|
case 'delete':
|
|
return <Badge variant="destructive"><Trash2 className="h-3 w-3 mr-1" />Delete</Badge>;
|
|
}
|
|
};
|
|
|
|
const magnitude = getChangeMagnitude(
|
|
changes.totalChanges,
|
|
changes.imageChanges.length > 0,
|
|
changes.action
|
|
);
|
|
|
|
if (view === 'summary') {
|
|
// Special compact display for photo deletions
|
|
if (item.item_type === 'photo_delete') {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{getEntityIcon()}
|
|
<span className="font-medium">{changes.entityName}</span>
|
|
{getActionBadge()}
|
|
</div>
|
|
|
|
{changes.photoChanges.length > 0 && changes.photoChanges[0].type === 'deleted' && (
|
|
<PhotoDeletionPreview
|
|
photo={{
|
|
url: changes.photoChanges[0].photo?.url || (item.item_data as Record<string, unknown>)?.cloudflare_image_url as string || '',
|
|
title: changes.photoChanges[0].photo?.title || (item.item_data as Record<string, unknown>)?.title as string,
|
|
caption: changes.photoChanges[0].photo?.caption || (item.item_data as Record<string, unknown>)?.caption as string,
|
|
entity_type: (item.item_data as Record<string, unknown>)?.entity_type as string,
|
|
entity_name: changes.entityName,
|
|
deletion_reason: changes.photoChanges[0].photo?.deletion_reason || (item.item_data as Record<string, unknown>)?.deletion_reason as string || (item.item_data as Record<string, unknown>)?.reason as string
|
|
}}
|
|
compact={true}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Special compact display for milestone/timeline events
|
|
if (item.item_type === 'milestone' || item.item_type === 'timeline_event') {
|
|
const milestoneData = item.item_data as TimelineSubmissionData;
|
|
const eventType = milestoneData.event_type?.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) || 'Event';
|
|
const eventDate = milestoneData.event_date ? new Date(milestoneData.event_date).toLocaleDateString() : 'No date';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{getEntityIcon()}
|
|
<span className="font-medium">{milestoneData.title}</span>
|
|
{getActionBadge()}
|
|
<Badge variant="secondary" className="text-xs">
|
|
{eventType}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
|
<Calendar className="h-3 w-3" />
|
|
{eventDate}
|
|
{milestoneData.from_value && milestoneData.to_value && (
|
|
<span className="ml-1">
|
|
• {milestoneData.from_value} → {milestoneData.to_value}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{getEntityIcon()}
|
|
<span className="font-medium">{changes.entityName}</span>
|
|
{getActionBadge()}
|
|
{changes.action === 'edit' && (
|
|
<Badge variant={magnitude.variant} className="text-xs">
|
|
{magnitude.label} Change
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{(changes.action === 'edit' || changes.action === 'create') && changes.totalChanges > 0 && (
|
|
<div className="flex flex-wrap gap-1 lg:grid lg:grid-cols-3 xl:grid-cols-4">
|
|
{changes.fieldChanges.slice(0, 5).map((change, idx) => (
|
|
<FieldDiff key={idx} change={change} compact />
|
|
))}
|
|
{changes.imageChanges.map((change, idx) => (
|
|
<ImageDiff key={`img-${idx}`} change={change} compact />
|
|
))}
|
|
{changes.photoChanges.map((change, idx) => {
|
|
if (change.type === 'added' && change.photos) {
|
|
return <PhotoAdditionPreview key={`photo-${idx}`} photos={change.photos} compact />;
|
|
}
|
|
if (change.type === 'edited' && change.photo) {
|
|
return <PhotoEditPreview key={`photo-${idx}`} photo={change.photo} compact />;
|
|
}
|
|
if (change.type === 'deleted' && change.photo) {
|
|
return <PhotoDeletionPreview key={`photo-${idx}`} photo={change.photo} compact />;
|
|
}
|
|
return null;
|
|
})}
|
|
{changes.hasLocationChange && (
|
|
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
|
|
Location
|
|
</Badge>
|
|
)}
|
|
{changes.totalChanges > 5 && (
|
|
<Badge variant="outline">
|
|
+{changes.totalChanges - 5} more
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{changes.action === 'delete' && (
|
|
<div className="text-sm text-destructive">
|
|
Marked for deletion
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Detailed view - special handling for photo deletions
|
|
if (item.item_type === 'photo_delete') {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center gap-2">
|
|
{getEntityIcon()}
|
|
<h3 className="text-lg font-semibold">{changes.entityName}</h3>
|
|
{getActionBadge()}
|
|
</div>
|
|
|
|
{changes.photoChanges.length > 0 && changes.photoChanges[0].type === 'deleted' && (
|
|
<PhotoDeletionPreview
|
|
photo={{
|
|
url: changes.photoChanges[0].photo?.url || (item.item_data as Record<string, unknown>)?.cloudflare_image_url as string || '',
|
|
title: changes.photoChanges[0].photo?.title || (item.item_data as Record<string, unknown>)?.title as string,
|
|
caption: changes.photoChanges[0].photo?.caption || (item.item_data as Record<string, unknown>)?.caption as string,
|
|
entity_type: (item.item_data as Record<string, unknown>)?.entity_type as string,
|
|
entity_name: changes.entityName,
|
|
deletion_reason: changes.photoChanges[0].photo?.deletion_reason || (item.item_data as Record<string, unknown>)?.deletion_reason as string || (item.item_data as Record<string, unknown>)?.reason as string
|
|
}}
|
|
compact={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Detailed view - special handling for milestone/timeline events
|
|
if (item.item_type === 'milestone' || item.item_type === 'timeline_event') {
|
|
const milestoneData = item.item_data as TimelineSubmissionData;
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center gap-2">
|
|
{getEntityIcon()}
|
|
<h3 className="text-lg font-semibold">{milestoneData.title}</h3>
|
|
{getActionBadge()}
|
|
<Badge variant="secondary">Timeline Event</Badge>
|
|
</div>
|
|
|
|
<TimelineEventPreview data={milestoneData} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Detailed view for other items
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center gap-2">
|
|
{getEntityIcon()}
|
|
<h3 className="text-lg font-semibold">{changes.entityName}</h3>
|
|
{getActionBadge()}
|
|
</div>
|
|
|
|
{changes.action === 'create' && (
|
|
<>
|
|
{/* Show if moderator edited the creation */}
|
|
{item.original_data && Object.keys(item.original_data).length > 0 ? (
|
|
<>
|
|
<div className="rounded-md bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 mb-2">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">
|
|
<Edit className="h-4 w-4" />
|
|
Moderator Edits Applied
|
|
</div>
|
|
<div className="text-xs text-blue-600 dark:text-blue-400">
|
|
This creation was modified by a moderator before review. Changed fields are highlighted below.
|
|
</div>
|
|
</div>
|
|
|
|
{changes.fieldChanges.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Creation Data (with moderator edits highlighted)</h4>
|
|
<div className="grid gap-2 lg:grid-cols-2">
|
|
{changes.fieldChanges.map((change, idx) => {
|
|
// Highlight fields that were added OR modified by moderator
|
|
const wasEditedByModerator = item.original_data &&
|
|
Object.keys(item.original_data).length > 0 &&
|
|
(
|
|
// Field was modified from original value
|
|
(change.changeType === 'modified') ||
|
|
// Field was added by moderator (not in original submission)
|
|
(change.changeType === 'added' && item.original_data[change.field] === undefined)
|
|
);
|
|
|
|
return (
|
|
<div key={idx} className={wasEditedByModerator ? 'border-l-4 border-blue-500 pl-3 bg-blue-50/50 dark:bg-blue-950/30 rounded' : ''}>
|
|
<FieldDiff change={change} />
|
|
{wasEditedByModerator ? (
|
|
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 font-medium">
|
|
✓ Modified by moderator
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
// Show all creation fields (no moderator edits)
|
|
<>
|
|
{changes.fieldChanges.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Creation Data</h4>
|
|
<div className="grid gap-2 lg:grid-cols-2">
|
|
{changes.fieldChanges.map((change, idx) => (
|
|
<div key={idx}>
|
|
<FieldDiff change={change} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{changes.imageChanges.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Images</h4>
|
|
<div className="grid gap-2">
|
|
{changes.imageChanges.map((change, idx) => (
|
|
<ImageDiff key={idx} change={change} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{changes.hasLocationChange && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Location</h4>
|
|
<LocationDiff
|
|
oldLocation={null}
|
|
newLocation={((item.item_data as Record<string, unknown>)?.location || (item.item_data as Record<string, unknown>)?.location_id) as string | undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{changes.action === 'delete' && (
|
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
|
This {item.item_type} will be deleted
|
|
</div>
|
|
)}
|
|
|
|
{changes.action === 'edit' && changes.totalChanges > 0 && (
|
|
<>
|
|
{changes.fieldChanges.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Field Changes ({changes.fieldChanges.length})</h4>
|
|
<div className="grid gap-2 lg:grid-cols-2">
|
|
{changes.fieldChanges.map((change, idx) => (
|
|
<FieldDiff key={idx} change={change} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showImages && changes.imageChanges.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Image Changes</h4>
|
|
<div className="grid gap-2">
|
|
{changes.imageChanges.map((change, idx) => (
|
|
<ImageDiff key={idx} change={change} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showImages && changes.photoChanges.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Photo Changes</h4>
|
|
<div className="grid gap-2">
|
|
{changes.photoChanges.map((change, idx) => {
|
|
if (change.type === 'added' && change.photos) {
|
|
return <PhotoAdditionPreview key={idx} photos={change.photos} compact={false} />;
|
|
}
|
|
if (change.type === 'edited' && change.photo) {
|
|
return <PhotoEditPreview key={idx} photo={change.photo} compact={false} />;
|
|
}
|
|
if (change.type === 'deleted' && change.photo) {
|
|
return <PhotoDeletionPreview key={idx} photo={change.photo} compact={false} />;
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{changes.hasLocationChange && (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="text-sm font-medium">Location Change</h4>
|
|
<LocationDiff
|
|
oldLocation={((item.original_data as Record<string, unknown>)?.location || (item.original_data as Record<string, unknown>)?.location_id) as string | undefined}
|
|
newLocation={((item.item_data as Record<string, unknown>)?.location || (item.item_data as Record<string, unknown>)?.location_id) as string | undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{changes.action === 'edit' && changes.totalChanges === 0 && (
|
|
<div className="text-sm text-muted-foreground">
|
|
No changes detected
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|