feat: Standardize submission display

This commit is contained in:
gpt-engineer-app[bot]
2025-10-03 15:32:23 +00:00
parent e15705e94d
commit fe33169ed7
5 changed files with 551 additions and 74 deletions

View File

@@ -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 (
<Badge variant="outline" className={getChangeColor()}>
{formatFieldName(field)}
</Badge>
);
}
return (
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">{formatFieldName(field)}</div>
{changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ {formatFieldValue(newValue)}
</div>
)}
{changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(oldValue)}
</div>
)}
{changeType === 'modified' && (
<div className="flex items-center gap-2 text-sm">
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(oldValue)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(newValue)}
</span>
</div>
)}
</div>
);
}
interface ImageDiffProps {
change: ImageChange;
compact?: boolean;
}
export function ImageDiff({ change, compact = false }: ImageDiffProps) {
const { type, oldUrl, newUrl } = change;
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
{type === 'banner' ? 'Banner' : 'Card'} Image
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">
{type === 'banner' ? 'Banner' : 'Card'} Image
</div>
<div className="flex items-center gap-2">
{oldUrl && (
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">Before</div>
<img
src={oldUrl}
alt="Previous"
className="w-full h-20 object-cover rounded border-2 border-red-500/50"
/>
</div>
)}
{oldUrl && newUrl && (
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
{newUrl && (
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">After</div>
<img
src={newUrl}
alt="New"
className="w-full h-20 object-cover rounded border-2 border-green-500/50"
/>
</div>
)}
</div>
</div>
);
}
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 (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Location
</Badge>
);
}
return (
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">Location</div>
<div className="flex items-center gap-2 text-sm">
{oldLocation && (
<span className="text-red-600 dark:text-red-400 line-through">
{formatLocation(oldLocation)}
</span>
)}
{oldLocation && newLocation && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
{newLocation && (
<span className="text-green-600 dark:text-green-400">
{formatLocation(newLocation)}
</span>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.park_type && <Badge variant="outline">{data.park_type}</Badge>}
{data.status && <Badge variant="outline">{data.status}</Badge>}
</div>
</div>
);
case 'ride':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.category && <Badge variant="outline">{data.category}</Badge>}
{data.status && <Badge variant="outline">{data.status}</Badge>}
</div>
</div>
);
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
{data.founded_year && (
<Badge variant="outline">Founded {data.founded_year}</Badge>
)}
</div>
);
case 'ride_model':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.category && <Badge variant="outline">{data.category}</Badge>}
{data.ride_type && <Badge variant="outline">{data.ride_type}</Badge>}
</div>
</div>
);
case 'photo':
return (
<div className="space-y-2">
{/* Fetch and display from photo_submission_items */}
<PhotoSubmissionDisplay submissionId={data.submission_id} />
</div>
);
default:
return (
<div className="text-sm text-muted-foreground">
No preview available
</div>
);
}
// Use standardized change detection display
return <SubmissionChangesDisplay item={item} view="summary" showImages={true} />;
};
return (

View File

@@ -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<ModerationQueueRef>((props, ref) => {
item.submission_type === 'property_owner' ||
item.submission_type === 'park' ||
item.submission_type === 'ride') ? (
<EntityEditPreview
submissionId={item.id}
entityType={item.submission_type}
entityName={item.content.name || item.entity_name}
/>
<div className="text-sm text-muted-foreground">
Standard entity submission - open review manager to see details
</div>
) : (
<div>
<div className="text-sm text-muted-foreground mb-2">

View File

@@ -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 <Building2 className={iconClass} />;
case 'ride': return <Train className={iconClass} />;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer': return <Building className={iconClass} />;
case 'photo': return <ImageIcon 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>;
}
};
if (view === 'summary') {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{getEntityIcon()}
<span className="font-medium">{changes.entityName}</span>
{getActionBadge()}
</div>
{changes.action === 'edit' && changes.totalChanges > 0 && (
<div className="flex flex-wrap gap-1">
{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.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 === 'create' && (
<div className="text-sm text-muted-foreground">
New {item.item_type}
</div>
)}
{changes.action === 'delete' && (
<div className="text-sm text-destructive">
Marked for deletion
</div>
)}
</div>
);
}
// Detailed view
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' && (
<div className="text-sm text-muted-foreground">
Creating new {item.item_type}
</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">
{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>
)}
{changes.hasLocationChange && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Location Change</h4>
<LocationDiff
oldLocation={item.original_data?.location || item.original_data?.location_id}
newLocation={item.item_data?.location || item.item_data?.location_id}
/>
</div>
)}
</>
)}
{changes.action === 'edit' && changes.totalChanges === 0 && (
<div className="text-sm text-muted-foreground">
No changes detected
</div>
)}
</div>
);
}

View File

@@ -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);
}