mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 13:51:12 -05:00
feat: Implement comprehensive change display
This commit is contained in:
@@ -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 (
|
||||
<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">
|
||||
{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 className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{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"
|
||||
className="w-full h-32 object-cover rounded border-2 border-red-500/50"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<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
|
||||
src={newUrl}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SubmissionReviewManager } from './SubmissionReviewManager';
|
||||
import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
|
||||
import { SubmissionItemsList } from './SubmissionItemsList';
|
||||
import { RealtimeConnectionStatus } from './RealtimeConnectionStatus';
|
||||
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 === 'park' ||
|
||||
item.submission_type === 'ride') ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Standard entity submission - open review manager to see details
|
||||
</div>
|
||||
<SubmissionItemsList
|
||||
submissionId={item.id}
|
||||
view="summary"
|
||||
showImages={true}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
|
||||
159
src/components/moderation/PhotoComparison.tsx
Normal file
159
src/components/moderation/PhotoComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface SubmissionChangesDisplayProps {
|
||||
item: SubmissionItemData | SubmissionItemWithDeps;
|
||||
@@ -11,6 +11,16 @@ interface SubmissionChangesDisplayProps {
|
||||
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({
|
||||
item,
|
||||
view = 'summary',
|
||||
@@ -45,13 +55,24 @@ export function SubmissionChangesDisplay({
|
||||
}
|
||||
};
|
||||
|
||||
const magnitude = getChangeMagnitude(
|
||||
changes.totalChanges,
|
||||
changes.imageChanges.length > 0,
|
||||
changes.action
|
||||
);
|
||||
|
||||
if (view === 'summary') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center 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.totalChanges > 0 && (
|
||||
@@ -75,9 +96,9 @@ export function SubmissionChangesDisplay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changes.action === 'create' && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
New {item.item_type}
|
||||
{changes.action === 'create' && item.item_data?.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-2">
|
||||
{item.item_data.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
112
src/components/moderation/SubmissionItemsList.tsx
Normal file
112
src/components/moderation/SubmissionItemsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,22 @@ export interface ImageChange {
|
||||
newId?: string;
|
||||
}
|
||||
|
||||
export interface PhotoChange {
|
||||
type: 'added' | 'edited' | 'deleted';
|
||||
photoUrl: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
oldCaption?: string;
|
||||
newCaption?: string;
|
||||
}
|
||||
|
||||
export interface ChangesSummary {
|
||||
action: 'create' | 'edit' | 'delete';
|
||||
entityType: string;
|
||||
entityName?: string;
|
||||
fieldChanges: FieldChange[];
|
||||
imageChanges: ImageChange[];
|
||||
photoChanges: PhotoChange[];
|
||||
hasLocationChange: boolean;
|
||||
totalChanges: number;
|
||||
}
|
||||
@@ -120,8 +130,9 @@ export function detectChanges(item: { item_data?: any; original_data?: any; item
|
||||
entityName,
|
||||
fieldChanges,
|
||||
imageChanges,
|
||||
photoChanges: [], // Will be populated by component with submissionId
|
||||
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 {
|
||||
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);
|
||||
|
||||
// Handle dates
|
||||
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();
|
||||
return String(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user