diff --git a/src/components/moderation/FieldComparison.tsx b/src/components/moderation/FieldComparison.tsx new file mode 100644 index 00000000..6a227464 --- /dev/null +++ b/src/components/moderation/FieldComparison.tsx @@ -0,0 +1,161 @@ +import { formatFieldName, formatFieldValue } from '@/lib/submissionChangeDetection'; +import type { FieldChange, ImageChange } from '@/lib/submissionChangeDetection'; +import { Badge } from '@/components/ui/badge'; +import { ArrowRight } from 'lucide-react'; + +interface FieldDiffProps { + change: FieldChange; + compact?: boolean; +} + +export function FieldDiff({ change, compact = false }: FieldDiffProps) { + const { field, oldValue, newValue, changeType } = change; + + const getChangeColor = () => { + switch (changeType) { + case 'added': return 'text-green-600 dark:text-green-400'; + case 'removed': return 'text-red-600 dark:text-red-400'; + case 'modified': return 'text-amber-600 dark:text-amber-400'; + default: return ''; + } + }; + + if (compact) { + return ( + + {formatFieldName(field)} + + ); + } + + return ( +
+
{formatFieldName(field)}
+ + {changeType === 'added' && ( +
+ + {formatFieldValue(newValue)} +
+ )} + + {changeType === 'removed' && ( +
+ {formatFieldValue(oldValue)} +
+ )} + + {changeType === 'modified' && ( +
+ + {formatFieldValue(oldValue)} + + + + {formatFieldValue(newValue)} + +
+ )} +
+ ); +} + +interface ImageDiffProps { + change: ImageChange; + compact?: boolean; +} + +export function ImageDiff({ change, compact = false }: ImageDiffProps) { + const { type, oldUrl, newUrl } = change; + + if (compact) { + return ( + + {type === 'banner' ? 'Banner' : 'Card'} Image + + ); + } + + return ( +
+
+ {type === 'banner' ? 'Banner' : 'Card'} Image +
+ +
+ {oldUrl && ( +
+
Before
+ Previous +
+ )} + + {oldUrl && newUrl && ( + + )} + + {newUrl && ( +
+
After
+ New +
+ )} +
+
+ ); +} + +interface LocationDiffProps { + oldLocation: any; + newLocation: any; + compact?: boolean; +} + +export function LocationDiff({ oldLocation, newLocation, compact = false }: LocationDiffProps) { + const formatLocation = (loc: any) => { + if (!loc) return 'None'; + if (typeof loc === 'string') return loc; + if (typeof loc === 'object') { + const parts = [loc.city, loc.state_province, loc.country].filter(Boolean); + return parts.join(', ') || 'Unknown'; + } + return String(loc); + }; + + if (compact) { + return ( + + Location + + ); + } + + return ( +
+
Location
+ +
+ {oldLocation && ( + + {formatLocation(oldLocation)} + + )} + {oldLocation && newLocation && ( + + )} + {newLocation && ( + + {formatLocation(newLocation)} + + )} +
+
+ ); +} diff --git a/src/components/moderation/ItemReviewCard.tsx b/src/components/moderation/ItemReviewCard.tsx index 956f4348..16c69f49 100644 --- a/src/components/moderation/ItemReviewCard.tsx +++ b/src/components/moderation/ItemReviewCard.tsx @@ -5,6 +5,7 @@ import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react'; import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService'; import { useIsMobile } from '@/hooks/use-mobile'; import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay'; +import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; interface ItemReviewCardProps { item: SubmissionItemWithDeps; @@ -39,74 +40,8 @@ export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardP }; const renderItemPreview = () => { - const data = item.item_data; - - switch (item.item_type) { - case 'park': - return ( -
-

{data.name}

-

{data.description}

-
- {data.park_type && {data.park_type}} - {data.status && {data.status}} -
-
- ); - - case 'ride': - return ( -
-

{data.name}

-

{data.description}

-
- {data.category && {data.category}} - {data.status && {data.status}} -
-
- ); - - case 'manufacturer': - case 'operator': - case 'property_owner': - case 'designer': - return ( -
-

{data.name}

-

{data.description}

- {data.founded_year && ( - Founded {data.founded_year} - )} -
- ); - - case 'ride_model': - return ( -
-

{data.name}

-

{data.description}

-
- {data.category && {data.category}} - {data.ride_type && {data.ride_type}} -
-
- ); - - case 'photo': - return ( -
- {/* Fetch and display from photo_submission_items */} - -
- ); - - default: - return ( -
- No preview available -
- ); - } + // Use standardized change detection display + return ; }; return ( diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 0f0c1c4a..45b61559 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -16,7 +16,7 @@ import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions'; import { useIsMobile } from '@/hooks/use-mobile'; -import { EntityEditPreview } from './EntityEditPreview'; +import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; import { RealtimeConnectionStatus } from './RealtimeConnectionStatus'; import { MeasurementDisplay } from '@/components/ui/measurement-display'; @@ -1436,11 +1436,9 @@ export const ModerationQueue = forwardRef((props, ref) => { item.submission_type === 'property_owner' || item.submission_type === 'park' || item.submission_type === 'ride') ? ( - +
+ Standard entity submission - open review manager to see details +
) : (
diff --git a/src/components/moderation/SubmissionChangesDisplay.tsx b/src/components/moderation/SubmissionChangesDisplay.tsx new file mode 100644 index 00000000..6a518208 --- /dev/null +++ b/src/components/moderation/SubmissionChangesDisplay.tsx @@ -0,0 +1,157 @@ +import { Badge } from '@/components/ui/badge'; +import { FieldDiff, ImageDiff, LocationDiff } from './FieldComparison'; +import { detectChanges } 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 } from 'lucide-react'; + +interface SubmissionChangesDisplayProps { + item: SubmissionItemData | SubmissionItemWithDeps; + view?: 'summary' | 'detailed'; + showImages?: boolean; +} + +export function SubmissionChangesDisplay({ + item, + view = 'summary', + showImages = true +}: SubmissionChangesDisplayProps) { + const changes = detectChanges(item); + + // Get appropriate icon for entity type + const getEntityIcon = () => { + const iconClass = "h-4 w-4"; + switch (item.item_type) { + case 'park': return ; + case 'ride': return ; + case 'manufacturer': + case 'operator': + case 'property_owner': + case 'designer': return ; + case 'photo': return ; + default: return ; + } + }; + + // Get action badge + const getActionBadge = () => { + switch (changes.action) { + case 'create': + return New; + case 'edit': + return Edit; + case 'delete': + return Delete; + } + }; + + if (view === 'summary') { + return ( +
+
+ {getEntityIcon()} + {changes.entityName} + {getActionBadge()} +
+ + {changes.action === 'edit' && changes.totalChanges > 0 && ( +
+ {changes.fieldChanges.slice(0, 5).map((change, idx) => ( + + ))} + {changes.imageChanges.map((change, idx) => ( + + ))} + {changes.hasLocationChange && ( + + Location + + )} + {changes.totalChanges > 5 && ( + + +{changes.totalChanges - 5} more + + )} +
+ )} + + {changes.action === 'create' && ( +
+ New {item.item_type} +
+ )} + + {changes.action === 'delete' && ( +
+ Marked for deletion +
+ )} +
+ ); + } + + // Detailed view + return ( +
+
+ {getEntityIcon()} +

{changes.entityName}

+ {getActionBadge()} +
+ + {changes.action === 'create' && ( +
+ Creating new {item.item_type} +
+ )} + + {changes.action === 'delete' && ( +
+ This {item.item_type} will be deleted +
+ )} + + {changes.action === 'edit' && changes.totalChanges > 0 && ( + <> + {changes.fieldChanges.length > 0 && ( +
+

Field Changes ({changes.fieldChanges.length})

+
+ {changes.fieldChanges.map((change, idx) => ( + + ))} +
+
+ )} + + {showImages && changes.imageChanges.length > 0 && ( +
+

Image Changes

+
+ {changes.imageChanges.map((change, idx) => ( + + ))} +
+
+ )} + + {changes.hasLocationChange && ( +
+

Location Change

+ +
+ )} + + )} + + {changes.action === 'edit' && changes.totalChanges === 0 && ( +
+ No changes detected +
+ )} +
+ ); +} diff --git a/src/lib/submissionChangeDetection.ts b/src/lib/submissionChangeDetection.ts new file mode 100644 index 00000000..5b5c9dda --- /dev/null +++ b/src/lib/submissionChangeDetection.ts @@ -0,0 +1,226 @@ +import type { SubmissionItemData } from '@/types/submissions'; + +export interface FieldChange { + field: string; + oldValue: any; + newValue: any; + changeType: 'added' | 'removed' | 'modified'; +} + +export interface ImageChange { + type: 'banner' | 'card'; + oldUrl?: string; + newUrl?: string; + oldId?: string; + newId?: string; +} + +export interface ChangesSummary { + action: 'create' | 'edit' | 'delete'; + entityType: string; + entityName?: string; + fieldChanges: FieldChange[]; + imageChanges: ImageChange[]; + hasLocationChange: boolean; + totalChanges: number; +} + +/** + * Detects what changed between original_data and item_data + */ +export function detectChanges(item: { item_data?: any; original_data?: any; item_type: string }): ChangesSummary { + const itemData = item.item_data || {}; + const originalData = item.original_data || {}; + + // Determine action type + const action: 'create' | 'edit' | 'delete' = + !originalData || Object.keys(originalData).length === 0 ? 'create' : + itemData.deleted ? 'delete' : 'edit'; + + const fieldChanges: FieldChange[] = []; + const imageChanges: ImageChange[] = []; + let hasLocationChange = false; + + if (action === 'create') { + // For creates, all fields are "added" + Object.entries(itemData).forEach(([key, value]) => { + if (shouldTrackField(key) && value !== null && value !== undefined && value !== '') { + fieldChanges.push({ + field: key, + oldValue: null, + newValue: value, + changeType: 'added', + }); + } + }); + } else if (action === 'edit') { + // Compare each field + const allKeys = new Set([ + ...Object.keys(itemData), + ...Object.keys(originalData) + ]); + + allKeys.forEach(key => { + if (!shouldTrackField(key)) return; + + const oldValue = originalData[key]; + const newValue = itemData[key]; + + // Handle location changes specially + if (key === 'location' || key === 'location_id') { + if (!isEqual(oldValue, newValue)) { + hasLocationChange = true; + fieldChanges.push({ + field: key, + oldValue, + newValue, + changeType: 'modified', + }); + } + return; + } + + // Check for changes + if (!isEqual(oldValue, newValue)) { + if ((oldValue === null || oldValue === undefined || oldValue === '') && newValue) { + fieldChanges.push({ + field: key, + oldValue, + newValue, + changeType: 'added', + }); + } else if ((newValue === null || newValue === undefined || newValue === '') && oldValue) { + fieldChanges.push({ + field: key, + oldValue, + newValue, + changeType: 'removed', + }); + } else { + fieldChanges.push({ + field: key, + oldValue, + newValue, + changeType: 'modified', + }); + } + } + }); + + // Detect image changes + detectImageChanges(originalData, itemData, imageChanges); + } + + // Get entity name + const entityName = itemData.name || originalData?.name || 'Unknown'; + + return { + action, + entityType: item.item_type, + entityName, + fieldChanges, + imageChanges, + hasLocationChange, + totalChanges: fieldChanges.length + imageChanges.length, + }; +} + +/** + * Determines if a field should be tracked for changes + */ +function shouldTrackField(key: string): boolean { + const excludedFields = [ + 'id', + 'created_at', + 'updated_at', + 'slug', + 'image_assignments', + 'banner_image_url', + 'banner_image_id', + 'card_image_url', + 'card_image_id', + ]; + return !excludedFields.includes(key); +} + +/** + * Deep equality check for values + */ +function isEqual(a: any, b: any): boolean { + if (a === b) return true; + if (a == null || b == null) return a === b; + if (typeof a !== typeof b) return false; + + if (typeof a === 'object') { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => isEqual(item, b[i])); + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + + return keysA.every(key => isEqual(a[key], b[key])); + } + + return false; +} + +/** + * Detects changes in banner/card images + */ +function detectImageChanges( + originalData: any, + itemData: any, + imageChanges: ImageChange[] +): void { + // Check banner image + if (originalData.banner_image_id !== itemData.banner_image_id || + originalData.banner_image_url !== itemData.banner_image_url) { + imageChanges.push({ + type: 'banner', + oldUrl: originalData.banner_image_url, + newUrl: itemData.banner_image_url, + oldId: originalData.banner_image_id, + newId: itemData.banner_image_id, + }); + } + + // Check card image + if (originalData.card_image_id !== itemData.card_image_id || + originalData.card_image_url !== itemData.card_image_url) { + imageChanges.push({ + type: 'card', + oldUrl: originalData.card_image_url, + newUrl: itemData.card_image_url, + oldId: originalData.card_image_id, + newId: itemData.card_image_id, + }); + } +} + +/** + * Format field name for display + */ +export function formatFieldName(field: string): string { + return field + .replace(/_/g, ' ') + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .trim(); +} + +/** + * Format field value for display + */ +export function formatFieldValue(value: any): string { + if (value === null || value === undefined) return 'None'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (typeof value === 'object') { + if (Array.isArray(value)) return `${value.length} items`; + return JSON.stringify(value, null, 2); + } + if (typeof value === 'number') return value.toLocaleString(); + return String(value); +}