diff --git a/src/components/moderation/FieldComparison.tsx b/src/components/moderation/FieldComparison.tsx index 6a227464..24c154e8 100644 --- a/src/components/moderation/FieldComparison.tsx +++ b/src/components/moderation/FieldComparison.tsx @@ -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 ( -
+
{type === 'banner' ? 'Banner' : 'Card'} Image + {isAddition && (New)} + {isRemoval && (Removed)} + {isReplacement && (Changed)}
-
+
{oldUrl && (
Before
Previous
)} {oldUrl && newUrl && ( - + )} {newUrl && (
-
After
+
{isAddition ? 'New Image' : 'After'}
New
)} diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 45b61559..0cce5f5d 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -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((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/PhotoComparison.tsx b/src/components/moderation/PhotoComparison.tsx new file mode 100644 index 00000000..746c8acc --- /dev/null +++ b/src/components/moderation/PhotoComparison.tsx @@ -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 ( + + + +{photos.length} Photo{photos.length > 1 ? 's' : ''} + + ); + } + + return ( +
+
+ + Adding {photos.length} Photo{photos.length > 1 ? 's' : ''} +
+ +
+ {photos.slice(0, 6).map((photo, idx) => ( +
+ {photo.title + {(photo.title || photo.caption) && ( +
+ {photo.title || photo.caption} +
+ )} +
+ ))} + {photos.length > 6 && ( +
+ + +{photos.length - 6} more + +
+ )} +
+
+ ); +} + +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 ( + + + Photo Edit + + ); + } + + return ( +
+
+ + Photo Metadata Edit +
+ +
+ Photo being edited + +
+ {photo.oldTitle !== photo.newTitle && ( +
+
Title:
+
{photo.oldTitle || 'None'}
+
{photo.newTitle || 'None'}
+
+ )} + + {photo.oldCaption !== photo.newCaption && ( +
+
Caption:
+
{photo.oldCaption || 'None'}
+
{photo.newCaption || 'None'}
+
+ )} +
+
+
+ ); +} + +interface PhotoDeletionPreviewProps { + photo: { + url: string; + title?: string; + caption?: string; + }; + compact?: boolean; +} + +export function PhotoDeletionPreview({ photo, compact = false }: PhotoDeletionPreviewProps) { + if (compact) { + return ( + + + Delete Photo + + ); + } + + return ( +
+
+ + Deleting Photo +
+ +
+ {photo.title + + {(photo.title || photo.caption) && ( +
+ {photo.title &&
{photo.title}
} + {photo.caption &&
{photo.caption}
} +
+ )} +
+
+ ); +} diff --git a/src/components/moderation/SubmissionChangesDisplay.tsx b/src/components/moderation/SubmissionChangesDisplay.tsx index 6a518208..3cdab6bd 100644 --- a/src/components/moderation/SubmissionChangesDisplay.tsx +++ b/src/components/moderation/SubmissionChangesDisplay.tsx @@ -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 (
-
+
{getEntityIcon()} {changes.entityName} {getActionBadge()} + {changes.action === 'edit' && ( + + {magnitude.label} Change + + )}
{changes.action === 'edit' && changes.totalChanges > 0 && ( @@ -75,9 +96,9 @@ export function SubmissionChangesDisplay({
)} - {changes.action === 'create' && ( -
- New {item.item_type} + {changes.action === 'create' && item.item_data?.description && ( +
+ {item.item_data.description}
)} diff --git a/src/components/moderation/SubmissionItemsList.tsx b/src/components/moderation/SubmissionItemsList.tsx new file mode 100644 index 00000000..9f63b20f --- /dev/null +++ b/src/components/moderation/SubmissionItemsList.tsx @@ -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([]); + const [hasPhotos, setHasPhotos] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ + {view === 'detailed' && } +
+ ); + } + + if (error) { + return ( + + + {error} + + ); + } + + if (items.length === 0 && !hasPhotos) { + return ( +
+ No items found for this submission +
+ ); + } + + return ( +
+ {/* Show regular submission items */} + {items.map((item) => ( +
+ +
+ ))} + + {/* Show photo submission if exists */} + {hasPhotos && ( +
+ +
+ )} +
+ ); +} diff --git a/src/lib/submissionChangeDetection.ts b/src/lib/submissionChangeDetection.ts index 5b5c9dda..c4e6be93 100644 --- a/src/lib/submissionChangeDetection.ts +++ b/src/lib/submissionChangeDetection.ts @@ -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); }