This commit is contained in:
pacnpal
2025-10-04 14:34:37 +00:00
97 changed files with 6202 additions and 1347 deletions

View File

@@ -0,0 +1,218 @@
import { Badge } from '@/components/ui/badge';
import { Plus, Minus, Edit, Check } from 'lucide-react';
import { formatFieldValue } from '@/lib/submissionChangeDetection';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
interface ArrayFieldDiffProps {
fieldName: string;
oldArray: any[];
newArray: any[];
compact?: boolean;
}
interface ArrayDiffItem {
type: 'added' | 'removed' | 'modified' | 'unchanged';
oldValue?: any;
newValue?: any;
index: number;
}
export function ArrayFieldDiff({ fieldName, oldArray, newArray, compact = false }: ArrayFieldDiffProps) {
const [showUnchanged, setShowUnchanged] = useState(false);
// Compute array differences
const differences = computeArrayDiff(oldArray || [], newArray || []);
const changedItems = differences.filter(d => d.type !== 'unchanged');
const unchangedCount = differences.filter(d => d.type === 'unchanged').length;
const totalChanges = changedItems.length;
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Edit className="h-3 w-3 mr-1" />
{fieldName} ({totalChanges} changes)
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{fieldName} ({differences.length} items, {totalChanges} changed)
</div>
{unchangedCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowUnchanged(!showUnchanged)}
className="h-6 text-xs"
>
{showUnchanged ? 'Hide' : 'Show'} {unchangedCount} unchanged
</Button>
)}
</div>
<div className="flex flex-col gap-1">
{differences.map((diff, idx) => {
if (diff.type === 'unchanged' && !showUnchanged) {
return null;
}
return (
<ArrayDiffItemDisplay key={idx} diff={diff} />
);
})}
</div>
</div>
);
}
function ArrayDiffItemDisplay({ diff }: { diff: ArrayDiffItem }) {
const isObject = typeof diff.newValue === 'object' || typeof diff.oldValue === 'object';
switch (diff.type) {
case 'added':
return (
<div className="flex items-start gap-2 p-2 rounded bg-green-500/10 border border-green-500/20">
<Plus className="h-4 w-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(diff.newValue)}
</span>
)}
</div>
</div>
);
case 'removed':
return (
<div className="flex items-start gap-2 p-2 rounded bg-red-500/10 border border-red-500/20">
<Minus className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
{isObject ? (
<ObjectDisplay value={diff.oldValue} className="line-through opacity-75" />
) : (
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(diff.oldValue)}
</span>
)}
</div>
</div>
);
case 'modified':
return (
<div className="flex flex-col gap-1 p-2 rounded bg-amber-500/10 border border-amber-500/20">
<div className="flex items-start gap-2">
<Edit className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="text-red-600 dark:text-red-400 line-through mb-1">
{isObject ? (
<ObjectDisplay value={diff.oldValue} />
) : (
formatFieldValue(diff.oldValue)
)}
</div>
<div className="text-green-600 dark:text-green-400">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
formatFieldValue(diff.newValue)
)}
</div>
</div>
</div>
</div>
);
case 'unchanged':
return (
<div className="flex items-start gap-2 p-2 rounded bg-muted/20">
<Check className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm text-muted-foreground">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
formatFieldValue(diff.newValue)
)}
</div>
</div>
);
}
}
function ObjectDisplay({ value, className = '' }: { value: any; className?: string }) {
if (!value || typeof value !== 'object') {
return <span className={className}>{formatFieldValue(value)}</span>;
}
return (
<div className={`space-y-0.5 ${className}`}>
{Object.entries(value).map(([key, val]) => (
<div key={key} className="flex gap-2">
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span>
<span>{formatFieldValue(val)}</span>
</div>
))}
</div>
);
}
/**
* Compute differences between two arrays
*/
function computeArrayDiff(oldArray: any[], newArray: any[]): ArrayDiffItem[] {
const results: ArrayDiffItem[] = [];
const maxLength = Math.max(oldArray.length, newArray.length);
// Simple position-based comparison
for (let i = 0; i < maxLength; i++) {
const oldValue = i < oldArray.length ? oldArray[i] : undefined;
const newValue = i < newArray.length ? newArray[i] : undefined;
if (oldValue === undefined && newValue !== undefined) {
// Added
results.push({ type: 'added', newValue, index: i });
} else if (oldValue !== undefined && newValue === undefined) {
// Removed
results.push({ type: 'removed', oldValue, index: i });
} else if (!isEqual(oldValue, newValue)) {
// Modified
results.push({ type: 'modified', oldValue, newValue, index: i });
} else {
// Unchanged
results.push({ type: 'unchanged', oldValue, newValue, index: i });
}
}
return results;
}
/**
* Deep equality check
*/
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;
}

View File

@@ -35,6 +35,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
const [cardImageUrl, setCardImageUrl] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [isPhotoOperation, setIsPhotoOperation] = useState(false);
useEffect(() => {
fetchSubmissionItems();
@@ -57,6 +58,15 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
setItemData(firstItem.item_data);
setOriginalData(firstItem.original_data);
// Check for photo edit/delete operations
if (firstItem.item_type === 'photo_edit' || firstItem.item_type === 'photo_delete') {
setIsPhotoOperation(true);
if (firstItem.item_type === 'photo_edit') {
setChangedFields(['caption']);
}
return;
}
// Parse changed fields
const changed: string[] = [];
const data = firstItem.item_data as any;
@@ -121,6 +131,63 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
);
}
// Handle photo edit/delete operations
if (isPhotoOperation) {
const isEdit = changedFields.includes('caption');
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline">
Photo
</Badge>
<Badge variant={isEdit ? "secondary" : "destructive"}>
{isEdit ? 'Edit' : 'Delete'}
</Badge>
</div>
{itemData?.cloudflare_image_url && (
<Card className="overflow-hidden">
<CardContent className="p-2">
<img
src={itemData.cloudflare_image_url}
alt="Photo to be modified"
className="w-full h-32 object-cover rounded"
/>
</CardContent>
</Card>
)}
{isEdit && (
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">Old caption: </span>
<span className="text-muted-foreground">
{originalData?.caption || <em>No caption</em>}
</span>
</div>
<div>
<span className="font-medium">New caption: </span>
<span className="text-muted-foreground">
{itemData?.new_caption || <em>No caption</em>}
</span>
</div>
</div>
)}
{!isEdit && itemData?.reason && (
<div className="text-sm">
<span className="font-medium">Reason: </span>
<span className="text-muted-foreground">{itemData.reason}</span>
</div>
)}
<div className="text-xs text-muted-foreground italic">
Click "Review Items" for full details
</div>
</div>
);
}
// Build photos array for modal
const photos = [];
if (bannerImageUrl) {

View File

@@ -0,0 +1,191 @@
import { Badge } from '@/components/ui/badge';
import { formatFieldName, formatFieldValue } from '@/lib/submissionChangeDetection';
import type { FieldChange, ImageChange } from '@/lib/submissionChangeDetection';
import { ArrowRight } from 'lucide-react';
import { ArrayFieldDiff } from './ArrayFieldDiff';
import { SpecialFieldDisplay } from './SpecialFieldDisplay';
interface FieldDiffProps {
change: FieldChange;
compact?: boolean;
}
export function FieldDiff({ change, compact = false }: FieldDiffProps) {
const { field, oldValue, newValue, changeType } = change;
// Check if this is an array field that needs special handling
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
return (
<ArrayFieldDiff
fieldName={formatFieldName(field)}
oldArray={oldValue}
newArray={newValue}
compact={compact}
/>
);
}
// Check if this is a special field type that needs custom rendering
const specialDisplay = SpecialFieldDisplay({ change, compact });
if (specialDisplay) {
return specialDisplay;
}
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>
);
}
// Determine scenario
const isAddition = !oldUrl && newUrl;
const isRemoval = oldUrl && !newUrl;
const isReplacement = oldUrl && newUrl;
return (
<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-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-32 object-cover rounded border-2 border-red-500/50"
loading="lazy"
/>
</div>
)}
{oldUrl && newUrl && (
<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">{isAddition ? 'New Image' : 'After'}</div>
<img
src={newUrl}
alt="New"
className="w-full h-32 object-cover rounded border-2 border-green-500/50"
loading="lazy"
/>
</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,14 +5,16 @@ 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;
onEdit: () => void;
onStatusChange: (status: 'approved' | 'rejected') => void;
submissionId: string;
}
export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardProps) {
export function ItemReviewCard({ item, onEdit, onStatusChange, submissionId }: ItemReviewCardProps) {
const isMobile = useIsMobile();
const getItemIcon = () => {
@@ -39,74 +41,15 @@ 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 detailed view for review manager with photo detection
return (
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={true}
submissionId={submissionId}
/>
);
};
return (

View File

@@ -14,9 +14,11 @@ import { useAuth } from '@/hooks/useAuth';
import { format } from 'date-fns';
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 { SubmissionItemsList } from './SubmissionItemsList';
import { MeasurementDisplay } from '@/components/ui/measurement-display';
import { useAdminSettings } from '@/hooks/useAdminSettings';
interface ModerationItem {
id: string;
@@ -54,6 +56,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const isMobile = useIsMobile();
const [items, setItems] = useState<ModerationItem[]>([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
const [activeEntityFilter, setActiveEntityFilter] = useState<EntityFilter>('all');
@@ -67,21 +70,28 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth();
// Get admin settings for polling configuration
const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
// Expose refresh method via ref
useImperativeHandle(ref, () => ({
refresh: () => {
fetchItems(activeEntityFilter, activeStatusFilter);
fetchItems(activeEntityFilter, activeStatusFilter, false); // Manual refresh shows loading
}
}), [activeEntityFilter, activeStatusFilter]);
const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending') => {
const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => {
if (!user) {
console.log('Skipping fetch - user not authenticated');
return;
}
try {
setLoading(true);
// Only show loading on initial load or filter change
if (!silent) {
setLoading(true);
}
let reviewStatuses: string[] = [];
let submissionStatuses: string[] = [];
@@ -325,9 +335,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Sort by creation date (newest first for better UX)
formattedItems.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
console.log('Formatted items:', formattedItems);
console.log('Photo submissions:', formattedItems.filter(item => item.submission_type === 'photo'));
setItems(formattedItems);
} catch (error: any) {
@@ -343,46 +350,36 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
variant: "destructive",
});
} finally {
setLoading(false);
// Only clear loading if it was set
if (!silent) {
setLoading(false);
}
if (isInitialLoad) {
setIsInitialLoad(false);
}
}
};
// Set up realtime subscriptions
useRealtimeSubmissions({
onInsert: (payload) => {
console.log('New submission received');
toast({
title: 'New Submission',
description: 'A new content submission has been added',
});
fetchItems(activeEntityFilter, activeStatusFilter);
},
onUpdate: (payload) => {
console.log('Submission updated');
// Update items state directly for better UX
setItems(prevItems =>
prevItems.map(item =>
item.id === payload.new.id && item.type === 'content_submission'
? { ...item, status: payload.new.status, content: { ...item.content, ...payload.new } }
: item
)
);
},
onDelete: (payload) => {
console.log('Submission deleted');
setItems(prevItems =>
prevItems.filter(item => !(item.id === payload.old.id && item.type === 'content_submission'))
);
},
enabled: !!user,
});
// Initial fetch on mount and filter changes
useEffect(() => {
if (user) {
fetchItems(activeEntityFilter, activeStatusFilter);
fetchItems(activeEntityFilter, activeStatusFilter, false); // Show loading
}
}, [activeEntityFilter, activeStatusFilter, user]);
// Polling for auto-refresh
useEffect(() => {
if (!user || refreshMode !== 'auto' || isInitialLoad) return;
const interval = setInterval(() => {
fetchItems(activeEntityFilter, activeStatusFilter, true); // Silent refresh
}, pollInterval);
return () => {
clearInterval(interval);
};
}, [user, refreshMode, pollInterval, activeEntityFilter, activeStatusFilter, isInitialLoad]);
const handleResetToPending = async (item: ModerationItem) => {
setActionLoading(item.id);
try {
@@ -467,7 +464,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
) => {
// Prevent multiple clicks on the same item
if (actionLoading === item.id) {
console.log('Action already in progress for item:', item.id);
return;
}
@@ -527,8 +523,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
manufacturer_id: modelManufacturerId,
category: item.content.new_ride_model.category,
ride_type: item.content.new_ride_model.ride_type,
description: item.content.new_ride_model.description,
technical_specs: item.content.new_ride_model.technical_specs
description: item.content.new_ride_model.description
})
.select()
.single();
@@ -584,8 +579,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Handle photo submissions - create photos records when approved
if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') {
console.log('🖼️ [PHOTO APPROVAL] Starting photo submission approval');
try {
// Fetch photo submission from new relational tables
const { data: photoSubmission, error: fetchError } = await supabase
@@ -598,15 +591,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.eq('submission_id', item.id)
.single();
console.log('🖼️ [PHOTO APPROVAL] Fetched photo submission:', photoSubmission);
if (fetchError || !photoSubmission) {
console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to fetch photo submission:', fetchError);
console.error('Failed to fetch photo submission:', fetchError);
throw new Error('Failed to fetch photo submission data');
}
if (!photoSubmission.items || photoSubmission.items.length === 0) {
console.error('🖼️ [PHOTO APPROVAL] ERROR: No photo items found');
console.error('No photo items found in submission');
throw new Error('No photos found in submission');
}
@@ -616,10 +607,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.select('id')
.eq('submission_id', item.id);
console.log('🖼️ [PHOTO APPROVAL] Existing photos check:', existingPhotos);
if (existingPhotos && existingPhotos.length > 0) {
console.log('🖼️ [PHOTO APPROVAL] Photos already exist for this submission, skipping creation');
// Just update submission status
const { error: updateError } = await supabase
@@ -649,19 +637,15 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
approved_at: new Date().toISOString(),
}));
console.log('🖼️ [PHOTO APPROVAL] Creating photo records:', photoRecords);
const { data: createdPhotos, error: insertError } = await supabase
.from('photos')
.insert(photoRecords)
.select();
if (insertError) {
console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to insert photos:', insertError);
console.error('Failed to insert photos:', insertError);
throw insertError;
}
console.log('🖼️ [PHOTO APPROVAL] ✅ Successfully created photos:', createdPhotos);
}
// Update submission status
@@ -676,12 +660,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.eq('id', item.id);
if (updateError) {
console.error('🖼️ [PHOTO APPROVAL] Error updating submission:', updateError);
console.error('Error updating submission:', updateError);
throw updateError;
}
console.log('🖼️ [PHOTO APPROVAL] ✅ Complete! Photos approved and published');
toast({
title: "Photos Approved",
description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`,
@@ -692,8 +674,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return;
} catch (error: any) {
console.error('🖼️ [PHOTO APPROVAL] ❌ FATAL ERROR:', error);
console.error('🖼️ [PHOTO APPROVAL] Error details:', error.message, error.code, error.details);
console.error('Photo approval error:', error);
throw error;
}
}
@@ -707,8 +688,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.in('status', ['pending', 'rejected']);
if (!itemsError && submissionItems && submissionItems.length > 0) {
console.log(`Found ${submissionItems.length} pending submission items for ${item.id}`);
if (action === 'approved') {
// Call the edge function to process all items
const { data: approvalData, error: approvalError } = await supabase.functions.invoke(
@@ -726,8 +705,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
throw new Error(`Failed to process submission items: ${approvalError.message}`);
}
console.log('Submission items processed successfully:', approvalData);
toast({
title: "Submission Approved",
description: `Successfully processed ${submissionItems.length} item(s)`,
@@ -738,7 +715,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return;
} else if (action === 'rejected') {
// Cascade rejection to all pending items
console.log('Cascading rejection to submission items');
const { error: rejectError } = await supabase
.from('submission_items')
.update({
@@ -752,8 +728,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
if (rejectError) {
console.error('Failed to cascade rejection:', rejectError);
// Don't fail the whole operation, just log it
} else {
console.log('Successfully cascaded rejection to submission items');
}
}
}
@@ -781,8 +755,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
updateData.reviewer_notes = moderatorNotes;
}
console.log('Updating item:', item.id, 'with data:', updateData, 'table:', table);
const { error, data } = await supabase
.from(table)
.update(updateData)
@@ -794,16 +766,12 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
throw error;
}
console.log('Update response:', { data, rowsAffected: data?.length });
// Check if the update actually affected any rows
if (!data || data.length === 0) {
console.error('No rows were updated. This might be due to RLS policies or the item not existing.');
throw new Error('Failed to update item - no rows affected. You might not have permission to moderate this content.');
}
console.log('Update successful, rows affected:', data.length);
toast({
title: `Content ${action}`,
description: `The ${item.type} has been ${action}`,
@@ -823,10 +791,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return newNotes;
});
// Only refresh if we're viewing a filter that should no longer show this item
// Refresh if needed based on filter
if ((activeStatusFilter === 'pending' && (action === 'approved' || action === 'rejected')) ||
(activeStatusFilter === 'flagged' && (action === 'approved' || action === 'rejected'))) {
console.log('Item no longer matches filter, removing from view');
// Item no longer matches filter
}
} catch (error: any) {
@@ -854,7 +822,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Prevent duplicate calls
if (actionLoading === item.id) {
console.log('Deletion already in progress for:', item.id);
return;
}
@@ -864,8 +831,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
setItems(prev => prev.filter(i => i.id !== item.id));
try {
console.log('Starting deletion process for submission:', item.id);
// Step 1: Extract photo IDs from the submission content
const photoIds: string[] = [];
const validImageIds: string[] = [];
@@ -875,18 +840,12 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const photosArray = item.content?.content?.photos || item.content?.photos;
if (photosArray && Array.isArray(photosArray)) {
console.log('Processing photos from content:', photosArray);
for (const photo of photosArray) {
console.log('Processing photo object:', photo);
console.log('Photo keys:', Object.keys(photo));
console.log('photo.imageId:', photo.imageId, 'type:', typeof photo.imageId);
let imageId = '';
// First try to use the stored imageId directly
if (photo.imageId) {
imageId = photo.imageId;
console.log('Using stored image ID:', imageId);
} else if (photo.url) {
// Check if this looks like a Cloudflare image ID (not a blob URL)
if (photo.url.startsWith('blob:')) {
@@ -908,7 +867,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
imageId = cloudflareMatch[1];
}
}
console.log('Extracted image ID from URL:', imageId, 'from URL:', photo.url);
}
if (imageId) {
@@ -921,14 +879,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}
}
console.log(`Found ${validImageIds.length} valid image IDs to delete, ${skippedPhotos.length} photos will be orphaned`);
// Step 2: Delete photos from Cloudflare Images (if any valid IDs)
if (validImageIds.length > 0) {
const deletePromises = validImageIds.map(async (imageId) => {
try {
console.log('Attempting to delete image from Cloudflare:', imageId);
// Use Supabase SDK - automatically includes session token
const { data, error } = await supabase.functions.invoke('upload-image', {
method: 'DELETE',
@@ -939,8 +893,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
throw new Error(`Failed to delete image: ${error.message}`);
}
console.log('Successfully deleted image:', imageId, data);
} catch (deleteError) {
console.error(`Failed to delete photo ${imageId} from Cloudflare:`, deleteError);
// Continue with other deletions - don't fail the entire operation
@@ -952,7 +904,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}
// Step 3: Delete the submission from the database
console.log('Deleting submission from database:', item.id);
const { error } = await supabase
.from('content_submissions')
.delete()
@@ -973,8 +924,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
if (checkData && !checkError) {
console.error('DELETION FAILED: Item still exists in database after delete operation');
throw new Error('Deletion failed - item still exists in database');
} else {
console.log('Verified: Submission successfully deleted from database');
}
const deletedCount = validImageIds.length;
@@ -1201,7 +1150,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
console.error('Failed to load review photo:', photo.url);
(e.target as HTMLImageElement).style.display = 'none';
}}
onLoad={() => console.log('Review photo loaded:', photo.url)}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white text-xs opacity-0 hover:opacity-100 transition-opacity rounded">
<Eye className="w-4 h-4" />
@@ -1254,21 +1202,29 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
src={photo.url}
alt={`Photo ${index + 1}: ${photo.filename}`}
className="w-full max-h-64 object-contain rounded hover:opacity-80 transition-opacity"
onError={(e) => {
console.error('Failed to load photo submission:', photo);
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `
<div class="absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs">
<div>⚠️ Image failed to load</div>
<div class="mt-1 font-mono text-xs break-all px-2">${photo.url}</div>
</div>
`;
}
}}
onLoad={() => console.log('Photo submission loaded:', photo.url)}
onError={(e) => {
console.error('Failed to load photo submission:', photo);
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
// Create elements safely using DOM API to prevent XSS
const errorContainer = document.createElement('div');
errorContainer.className = 'absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs';
const errorIcon = document.createElement('div');
errorIcon.textContent = '⚠️ Image failed to load';
const urlDisplay = document.createElement('div');
urlDisplay.className = 'mt-1 font-mono text-xs break-all px-2';
// Use textContent to prevent XSS - it escapes HTML automatically
urlDisplay.textContent = photo.url;
errorContainer.appendChild(errorIcon);
errorContainer.appendChild(urlDisplay);
parent.appendChild(errorContainer);
}
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 hover:opacity-100 transition-opacity rounded">
<Eye className="w-5 h-5" />
@@ -1468,13 +1424,17 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{item.content.ride?.max_speed_kmh && (
<div>
<span className="font-medium">Max Speed: </span>
<span className="text-muted-foreground">{item.content.ride.max_speed_kmh} km/h</span>
<span className="text-muted-foreground">
<MeasurementDisplay value={item.content.ride.max_speed_kmh} type="speed" className="inline" />
</span>
</div>
)}
{item.content.ride?.max_height_meters && (
<div>
<span className="font-medium">Max Height: </span>
<span className="text-muted-foreground">{item.content.ride.max_height_meters} m</span>
<span className="text-muted-foreground">
<MeasurementDisplay value={item.content.ride.max_height_meters} type="height" className="inline" />
</span>
</div>
)}
</div>
@@ -1487,10 +1447,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
item.submission_type === 'property_owner' ||
item.submission_type === 'park' ||
item.submission_type === 'ride') ? (
<EntityEditPreview
<SubmissionItemsList
submissionId={item.id}
entityType={item.submission_type}
entityName={item.content.name || item.entity_name}
view="summary"
showImages={true}
/>
) : (
<div>
@@ -1736,6 +1696,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<div className="space-y-4">
{/* Filter Bar */}
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
</div>
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>

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

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -8,6 +8,8 @@ import { Label } from '@/components/ui/label';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { format } from 'date-fns';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useAuth } from '@/hooks/useAuth';
interface Report {
id: string;
@@ -38,14 +40,35 @@ const STATUS_COLORS = {
dismissed: 'outline',
} as const;
export function ReportsQueue() {
export interface ReportsQueueRef {
refresh: () => void;
}
export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const { toast } = useToast();
const { user } = useAuth();
const fetchReports = async () => {
// Get admin settings for polling configuration
const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
// Expose refresh method via ref
useImperativeHandle(ref, () => ({
refresh: () => fetchReports(false) // Manual refresh shows loading
}), []);
const fetchReports = async (silent = false) => {
try {
// Only show loading on initial load
if (!silent) {
setLoading(true);
}
const { data, error } = await supabase
.from('reports')
.select(`
@@ -106,13 +129,35 @@ export function ReportsQueue() {
variant: "destructive",
});
} finally {
setLoading(false);
// Only clear loading if it was set
if (!silent) {
setLoading(false);
}
if (isInitialLoad) {
setIsInitialLoad(false);
}
}
};
// Initial fetch on mount
useEffect(() => {
fetchReports();
}, []);
if (user) {
fetchReports(false); // Show loading
}
}, [user]);
// Polling for auto-refresh
useEffect(() => {
if (!user || refreshMode !== 'auto' || isInitialLoad) return;
const interval = setInterval(() => {
fetchReports(true); // Silent refresh
}, pollInterval);
return () => {
clearInterval(interval);
};
}, [user, refreshMode, pollInterval, isInitialLoad]);
const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => {
setActionLoading(reportId);
@@ -258,4 +303,4 @@ export function ReportsQueue() {
))}
</div>
);
}
});

View File

@@ -0,0 +1,327 @@
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { MeasurementDisplay } from '@/components/ui/measurement-display';
import { SpeedDisplay } from '@/components/ui/speed-display';
import { MapPin, ArrowRight, Calendar, ExternalLink } from 'lucide-react';
import type { FieldChange } from '@/lib/submissionChangeDetection';
import { formatFieldValue } from '@/lib/submissionChangeDetection';
interface SpecialFieldDisplayProps {
change: FieldChange;
compact?: boolean;
}
export function SpecialFieldDisplay({ change, compact = false }: SpecialFieldDisplayProps) {
const fieldName = change.field.toLowerCase();
// Detect field type
if (fieldName.includes('speed') || fieldName === 'max_speed_kmh') {
return <SpeedFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('height') || fieldName.includes('length') ||
fieldName === 'max_height_meters' || fieldName === 'length_meters' ||
fieldName === 'drop_height_meters') {
return <MeasurementFieldDisplay change={change} compact={compact} />;
}
if (fieldName === 'status') {
return <StatusFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('date') && !fieldName.includes('updated') && !fieldName.includes('created')) {
return <DateFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('_id') && fieldName !== 'id' && fieldName !== 'user_id') {
return <RelationshipFieldDisplay change={change} compact={compact} />;
}
if (fieldName === 'latitude' || fieldName === 'longitude') {
return <CoordinateFieldDisplay change={change} compact={compact} />;
}
// Fallback to null, will be handled by regular FieldDiff
return null;
}
function SpeedFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Speed
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<div className="text-red-600 dark:text-red-400 line-through">
<SpeedDisplay kmh={change.oldValue} />
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="text-green-600 dark:text-green-400">
<SpeedDisplay kmh={change.newValue} />
</div>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ <SpeedDisplay kmh={change.newValue} />
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
<SpeedDisplay kmh={change.oldValue} />
</div>
)}
</div>
);
}
function MeasurementFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-purple-600 dark:text-purple-400">
Measurement
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<div className="text-red-600 dark:text-red-400 line-through">
<MeasurementDisplay value={change.oldValue} type="distance" />
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="text-green-600 dark:text-green-400">
<MeasurementDisplay value={change.newValue} type="distance" />
</div>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ <MeasurementDisplay value={change.newValue} type="distance" />
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
<MeasurementDisplay value={change.oldValue} type="distance" />
</div>
)}
</div>
);
}
function StatusFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
const getStatusColor = (status: string) => {
const statusLower = String(status).toLowerCase();
if (statusLower === 'operating' || statusLower === 'active') return 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20';
if (statusLower === 'closed' || statusLower === 'inactive') return 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20';
if (statusLower === 'under_construction' || statusLower === 'pending') return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20';
return 'bg-muted/30 text-muted-foreground';
};
if (compact) {
return (
<Badge variant="outline" className="text-indigo-600 dark:text-indigo-400">
Status
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3">
<Badge className={`${getStatusColor(change.oldValue)} line-through`}>
{formatFieldValue(change.oldValue)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className={getStatusColor(change.newValue)}>
{formatFieldValue(change.newValue)}
</Badge>
</div>
)}
{change.changeType === 'added' && (
<Badge className={getStatusColor(change.newValue)}>
{formatFieldValue(change.newValue)}
</Badge>
)}
{change.changeType === 'removed' && (
<Badge className={`${getStatusColor(change.oldValue)} line-through opacity-75`}>
{formatFieldValue(change.oldValue)}
</Badge>
)}
</div>
);
}
function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-teal-600 dark:text-teal-400">
<Calendar className="h-3 w-3 mr-1" />
Date
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
{formatFieldName(change.field)}
</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(change.oldValue)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(change.newValue)}
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ {formatFieldValue(change.newValue)}
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(change.oldValue)}
</div>
)}
</div>
);
}
function RelationshipFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
// This would ideally fetch entity names, but for now we show IDs with better formatting
const formatFieldName = (name: string) =>
name.replace(/_id$/, '').replace(/_/g, ' ').trim()
.replace(/^./, str => str.toUpperCase());
if (compact) {
return (
<Badge variant="outline" className="text-cyan-600 dark:text-cyan-400">
{formatFieldName(change.field)}
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm font-mono">
<span className="text-red-600 dark:text-red-400 line-through text-xs">
{String(change.oldValue).slice(0, 8)}...
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 text-xs">
{String(change.newValue).slice(0, 8)}...
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono text-xs">
+ {String(change.newValue).slice(0, 8)}...
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono text-xs">
{String(change.oldValue).slice(0, 8)}...
</div>
)}
</div>
);
}
function CoordinateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-orange-600 dark:text-orange-400">
<MapPin className="h-3 w-3 mr-1" />
Coordinates
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
{formatFieldName(change.field)}
</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<span className="text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 font-mono">
{Number(change.newValue).toFixed(6)}°
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono">
+ {Number(change.newValue).toFixed(6)}°
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { FieldDiff, ImageDiff, LocationDiff } from './FieldComparison';
import { PhotoAdditionPreview, PhotoEditPreview, PhotoDeletionPreview } from './PhotoComparison';
import { detectChanges, type ChangesSummary } 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, AlertTriangle } from 'lucide-react';
interface SubmissionChangesDisplayProps {
item: SubmissionItemData | SubmissionItemWithDeps;
view?: 'summary' | 'detailed';
showImages?: boolean;
submissionId?: string;
}
// 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',
showImages = true,
submissionId
}: SubmissionChangesDisplayProps) {
const [changes, setChanges] = useState<ChangesSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadChanges = async () => {
setLoading(true);
const detectedChanges = await detectChanges(item, submissionId);
setChanges(detectedChanges);
setLoading(false);
};
loadChanges();
}, [item, submissionId]);
if (loading || !changes) {
return <Skeleton className="h-16 w-full" />;
}
// 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>;
}
};
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 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 && (
<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.photoChanges.map((change, idx) => {
if (change.type === 'added' && change.photos) {
return <PhotoAdditionPreview key={`photo-${idx}`} photos={change.photos} compact />;
}
if (change.type === 'edited' && change.photo) {
return <PhotoEditPreview key={`photo-${idx}`} photo={change.photo} compact />;
}
if (change.type === 'deleted' && change.photo) {
return <PhotoDeletionPreview key={`photo-${idx}`} photo={change.photo} compact />;
}
return null;
})}
{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' && item.item_data?.description && (
<div className="text-sm text-muted-foreground line-clamp-2">
{item.item_data.description}
</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>
)}
{showImages && changes.photoChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Photo Changes</h4>
<div className="grid gap-2">
{changes.photoChanges.map((change, idx) => {
if (change.type === 'added' && change.photos) {
return <PhotoAdditionPreview key={idx} photos={change.photos} compact={false} />;
}
if (change.type === 'edited' && change.photo) {
return <PhotoEditPreview key={idx} photo={change.photo} compact={false} />;
}
if (change.type === 'deleted' && change.photo) {
return <PhotoDeletionPreview key={idx} photo={change.photo} compact={false} />;
}
return null;
})}
</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,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 (using array query to avoid 406)
const { data: photoData, error: photoError } = await supabase
.from('photo_submissions')
.select('id')
.eq('submission_id', submissionId);
if (photoError) {
console.warn('Error checking photo submissions:', photoError);
}
setItems((itemsData || []) as SubmissionItemData[]);
setHasPhotos(photoData && photoData.length > 0);
} 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}
submissionId={submissionId}
/>
</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

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems';
import {
fetchSubmissionItems,
buildDependencyTree,
@@ -60,20 +59,6 @@ export function SubmissionReviewManager({
const isMobile = useIsMobile();
const Container = isMobile ? Sheet : Dialog;
// Set up realtime subscription for submission items
useRealtimeSubmissionItems({
submissionId,
onUpdate: (payload) => {
console.log('Submission item updated in real-time:', payload);
toast({
title: 'Item Updated',
description: 'A submission item was updated by another moderator',
});
loadSubmissionItems();
},
enabled: open && !!submissionId,
});
useEffect(() => {
if (open && submissionId) {
loadSubmissionItems();
@@ -473,7 +458,11 @@ export function SubmissionReviewManager({
<ItemReviewCard
item={item}
onEdit={() => handleEdit(item)}
onStatusChange={(status) => handleItemStatusChange(item.id, status)}
onStatusChange={async () => {
// Status changes handled via approve/reject actions
await loadSubmissionItems();
}}
submissionId={submissionId}
/>
</div>
))}