feat: Implement comprehensive change display

This commit is contained in:
gpt-engineer-app[bot]
2025-10-03 15:46:55 +00:00
parent fe33169ed7
commit 86fb99c696
6 changed files with 372 additions and 18 deletions

View File

@@ -75,35 +75,45 @@ export function ImageDiff({ change, compact = false }: ImageDiffProps) {
); );
} }
// Determine scenario
const isAddition = !oldUrl && newUrl;
const isRemoval = oldUrl && !newUrl;
const isReplacement = oldUrl && newUrl;
return ( return (
<div className="flex flex-col gap-2 p-2 rounded-md bg-muted/50"> <div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
<div className="text-sm font-medium"> <div className="text-sm font-medium">
{type === 'banner' ? 'Banner' : 'Card'} Image {type === 'banner' ? 'Banner' : 'Card'} Image
{isAddition && <span className="text-green-600 dark:text-green-400 ml-2">(New)</span>}
{isRemoval && <span className="text-red-600 dark:text-red-400 ml-2">(Removed)</span>}
{isReplacement && <span className="text-amber-600 dark:text-amber-400 ml-2">(Changed)</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
{oldUrl && ( {oldUrl && (
<div className="flex-1"> <div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">Before</div> <div className="text-xs text-muted-foreground mb-1">Before</div>
<img <img
src={oldUrl} src={oldUrl}
alt="Previous" alt="Previous"
className="w-full h-20 object-cover rounded border-2 border-red-500/50" className="w-full h-32 object-cover rounded border-2 border-red-500/50"
loading="lazy"
/> />
</div> </div>
)} )}
{oldUrl && newUrl && ( {oldUrl && newUrl && (
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
)} )}
{newUrl && ( {newUrl && (
<div className="flex-1"> <div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">After</div> <div className="text-xs text-muted-foreground mb-1">{isAddition ? 'New Image' : 'After'}</div>
<img <img
src={newUrl} src={newUrl}
alt="New" alt="New"
className="w-full h-20 object-cover rounded border-2 border-green-500/50" className="w-full h-32 object-cover rounded border-2 border-green-500/50"
loading="lazy"
/> />
</div> </div>
)} )}

View File

@@ -17,6 +17,7 @@ import { SubmissionReviewManager } from './SubmissionReviewManager';
import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions'; import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
import { SubmissionItemsList } from './SubmissionItemsList';
import { RealtimeConnectionStatus } from './RealtimeConnectionStatus'; import { RealtimeConnectionStatus } from './RealtimeConnectionStatus';
import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { MeasurementDisplay } from '@/components/ui/measurement-display';
@@ -1436,9 +1437,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
item.submission_type === 'property_owner' || item.submission_type === 'property_owner' ||
item.submission_type === 'park' || item.submission_type === 'park' ||
item.submission_type === 'ride') ? ( item.submission_type === 'ride') ? (
<div className="text-sm text-muted-foreground"> <SubmissionItemsList
Standard entity submission - open review manager to see details submissionId={item.id}
</div> view="summary"
showImages={true}
/>
) : ( ) : (
<div> <div>
<div className="text-sm text-muted-foreground mb-2"> <div className="text-sm text-muted-foreground mb-2">

View File

@@ -0,0 +1,159 @@
import { Badge } from '@/components/ui/badge';
import { ImageIcon, Trash2, Edit } from 'lucide-react';
interface PhotoAdditionPreviewProps {
photos: Array<{
url: string;
title?: string;
caption?: string;
}>;
compact?: boolean;
}
export function PhotoAdditionPreview({ photos, compact = false }: PhotoAdditionPreviewProps) {
if (compact) {
return (
<Badge variant="outline" className="text-green-600 dark:text-green-400">
<ImageIcon className="h-3 w-3 mr-1" />
+{photos.length} Photo{photos.length > 1 ? 's' : ''}
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
<div className="text-sm font-medium text-green-600 dark:text-green-400">
<ImageIcon className="h-4 w-4 inline mr-1" />
Adding {photos.length} Photo{photos.length > 1 ? 's' : ''}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{photos.slice(0, 6).map((photo, idx) => (
<div key={idx} className="flex flex-col gap-1">
<img
src={photo.url}
alt={photo.title || photo.caption || `Photo ${idx + 1}`}
className="w-full h-24 object-cover rounded border-2 border-green-500/50"
loading="lazy"
/>
{(photo.title || photo.caption) && (
<div className="text-xs text-muted-foreground truncate">
{photo.title || photo.caption}
</div>
)}
</div>
))}
{photos.length > 6 && (
<div className="flex items-center justify-center h-24 bg-muted rounded border-2 border-dashed">
<span className="text-sm text-muted-foreground">
+{photos.length - 6} more
</span>
</div>
)}
</div>
</div>
);
}
interface PhotoEditPreviewProps {
photo: {
url: string;
oldCaption?: string;
newCaption?: string;
oldTitle?: string;
newTitle?: string;
};
compact?: boolean;
}
export function PhotoEditPreview({ photo, compact = false }: PhotoEditPreviewProps) {
if (compact) {
return (
<Badge variant="outline" className="text-amber-600 dark:text-amber-400">
<Edit className="h-3 w-3 mr-1" />
Photo Edit
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
<Edit className="h-4 w-4 inline mr-1" />
Photo Metadata Edit
</div>
<div className="flex gap-3">
<img
src={photo.url}
alt="Photo being edited"
className="w-32 h-32 object-cover rounded"
loading="lazy"
/>
<div className="flex-1 flex flex-col gap-2 text-sm">
{photo.oldTitle !== photo.newTitle && (
<div>
<div className="font-medium mb-1">Title:</div>
<div className="text-red-600 dark:text-red-400 line-through">{photo.oldTitle || 'None'}</div>
<div className="text-green-600 dark:text-green-400">{photo.newTitle || 'None'}</div>
</div>
)}
{photo.oldCaption !== photo.newCaption && (
<div>
<div className="font-medium mb-1">Caption:</div>
<div className="text-red-600 dark:text-red-400 line-through">{photo.oldCaption || 'None'}</div>
<div className="text-green-600 dark:text-green-400">{photo.newCaption || 'None'}</div>
</div>
)}
</div>
</div>
</div>
);
}
interface PhotoDeletionPreviewProps {
photo: {
url: string;
title?: string;
caption?: string;
};
compact?: boolean;
}
export function PhotoDeletionPreview({ photo, compact = false }: PhotoDeletionPreviewProps) {
if (compact) {
return (
<Badge variant="outline" className="text-red-600 dark:text-red-400">
<Trash2 className="h-3 w-3 mr-1" />
Delete Photo
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-destructive/10">
<div className="text-sm font-medium text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 inline mr-1" />
Deleting Photo
</div>
<div className="flex gap-3">
<img
src={photo.url}
alt={photo.title || photo.caption || 'Photo to be deleted'}
className="w-32 h-32 object-cover rounded opacity-75"
loading="lazy"
/>
{(photo.title || photo.caption) && (
<div className="flex-1 text-sm">
{photo.title && <div className="font-medium">{photo.title}</div>}
{photo.caption && <div className="text-muted-foreground">{photo.caption}</div>}
</div>
)}
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { FieldDiff, ImageDiff, LocationDiff } from './FieldComparison';
import { detectChanges } from '@/lib/submissionChangeDetection'; import { detectChanges } from '@/lib/submissionChangeDetection';
import type { SubmissionItemData } from '@/types/submissions'; import type { SubmissionItemData } from '@/types/submissions';
import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService'; import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus } from 'lucide-react'; import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle } from 'lucide-react';
interface SubmissionChangesDisplayProps { interface SubmissionChangesDisplayProps {
item: SubmissionItemData | SubmissionItemWithDeps; item: SubmissionItemData | SubmissionItemWithDeps;
@@ -11,6 +11,16 @@ interface SubmissionChangesDisplayProps {
showImages?: boolean; showImages?: boolean;
} }
// 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({ export function SubmissionChangesDisplay({
item, item,
view = 'summary', view = 'summary',
@@ -45,13 +55,24 @@ export function SubmissionChangesDisplay({
} }
}; };
const magnitude = getChangeMagnitude(
changes.totalChanges,
changes.imageChanges.length > 0,
changes.action
);
if (view === 'summary') { if (view === 'summary') {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{getEntityIcon()} {getEntityIcon()}
<span className="font-medium">{changes.entityName}</span> <span className="font-medium">{changes.entityName}</span>
{getActionBadge()} {getActionBadge()}
{changes.action === 'edit' && (
<Badge variant={magnitude.variant} className="text-xs">
{magnitude.label} Change
</Badge>
)}
</div> </div>
{changes.action === 'edit' && changes.totalChanges > 0 && ( {changes.action === 'edit' && changes.totalChanges > 0 && (
@@ -75,9 +96,9 @@ export function SubmissionChangesDisplay({
</div> </div>
)} )}
{changes.action === 'create' && ( {changes.action === 'create' && item.item_data?.description && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground line-clamp-2">
New {item.item_type} {item.item_data.description}
</div> </div>
)} )}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import type { SubmissionItemData } from '@/types/submissions';
interface SubmissionItemsListProps {
submissionId: string;
view?: 'summary' | 'detailed';
showImages?: boolean;
}
export function SubmissionItemsList({
submissionId,
view = 'summary',
showImages = true
}: SubmissionItemsListProps) {
const [items, setItems] = useState<SubmissionItemData[]>([]);
const [hasPhotos, setHasPhotos] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSubmissionItems();
}, [submissionId]);
const fetchSubmissionItems = async () => {
try {
setLoading(true);
setError(null);
// Fetch submission items
const { data: itemsData, error: itemsError } = await supabase
.from('submission_items')
.select('*')
.eq('submission_id', submissionId)
.order('order_index');
if (itemsError) throw itemsError;
// Check for photo submissions
const { data: photoData, error: photoError } = await supabase
.from('photo_submissions')
.select('id')
.eq('submission_id', submissionId)
.single();
if (photoError && photoError.code !== 'PGRST116') {
console.warn('Error checking photo submissions:', photoError);
}
setItems((itemsData || []) as SubmissionItemData[]);
setHasPhotos(!!photoData);
} catch (err) {
console.error('Error fetching submission items:', err);
setError('Failed to load submission details');
} finally {
setLoading(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>
);
}
return (
<div className="flex flex-col gap-3">
{/* Show regular submission items */}
{items.map((item) => (
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
<SubmissionChangesDisplay
item={item}
view={view}
showImages={showImages}
/>
</div>
))}
{/* Show photo submission if exists */}
{hasPhotos && (
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
<PhotoSubmissionDisplay submissionId={submissionId} />
</div>
)}
</div>
);
}

View File

@@ -15,12 +15,22 @@ export interface ImageChange {
newId?: string; newId?: string;
} }
export interface PhotoChange {
type: 'added' | 'edited' | 'deleted';
photoUrl: string;
title?: string;
caption?: string;
oldCaption?: string;
newCaption?: string;
}
export interface ChangesSummary { export interface ChangesSummary {
action: 'create' | 'edit' | 'delete'; action: 'create' | 'edit' | 'delete';
entityType: string; entityType: string;
entityName?: string; entityName?: string;
fieldChanges: FieldChange[]; fieldChanges: FieldChange[];
imageChanges: ImageChange[]; imageChanges: ImageChange[];
photoChanges: PhotoChange[];
hasLocationChange: boolean; hasLocationChange: boolean;
totalChanges: number; totalChanges: number;
} }
@@ -120,8 +130,9 @@ export function detectChanges(item: { item_data?: any; original_data?: any; item
entityName, entityName,
fieldChanges, fieldChanges,
imageChanges, imageChanges,
photoChanges: [], // Will be populated by component with submissionId
hasLocationChange, hasLocationChange,
totalChanges: fieldChanges.length + imageChanges.length, totalChanges: fieldChanges.length + imageChanges.length + (hasLocationChange ? 1 : 0)
}; };
} }
@@ -217,10 +228,48 @@ export function formatFieldName(field: string): string {
export function formatFieldValue(value: any): string { export function formatFieldValue(value: any): string {
if (value === null || value === undefined) return 'None'; if (value === null || value === undefined) return 'None';
if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'object') {
if (Array.isArray(value)) return `${value.length} items`; // Handle dates
return JSON.stringify(value, null, 2); if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) {
try {
const date = new Date(value);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return String(value);
} }
}
// Handle arrays - show actual items
if (Array.isArray(value)) {
if (value.length === 0) return 'None';
if (value.length <= 3) return value.map(v => String(v)).join(', ');
return `${value.slice(0, 3).map(v => String(v)).join(', ')}... +${value.length - 3} more`;
}
// Handle objects - create readable summary
if (typeof value === 'object') {
// Location object
if (value.city || value.state_province || value.country) {
const parts = [value.city, value.state_province, value.country].filter(Boolean);
return parts.join(', ');
}
// Generic object - show key-value pairs
const entries = Object.entries(value).slice(0, 3);
if (entries.length === 0) return 'Empty';
return entries.map(([k, v]) => `${k}: ${v}`).join(', ');
}
// Handle URLs
if (typeof value === 'string' && value.startsWith('http')) {
try {
const url = new URL(value);
return url.hostname + (url.pathname !== '/' ? url.pathname.slice(0, 30) : '');
} catch {
return value;
}
}
if (typeof value === 'number') return value.toLocaleString(); if (typeof value === 'number') return value.toLocaleString();
return String(value); return String(value);
} }