mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 20:31:12 -05:00
Merge branch 'main' of https://github.com/pacnpal/thrilltrack-explorer
This commit is contained in:
218
src/components/moderation/ArrayFieldDiff.tsx
Normal file
218
src/components/moderation/ArrayFieldDiff.tsx
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
191
src/components/moderation/FieldComparison.tsx
Normal file
191
src/components/moderation/FieldComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
327
src/components/moderation/SpecialFieldDisplay.tsx
Normal file
327
src/components/moderation/SpecialFieldDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
src/components/moderation/SubmissionChangesDisplay.tsx
Normal file
230
src/components/moderation/SubmissionChangesDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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 (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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user