Files
thrilltrack-explorer/src/components/moderation/EntityEditPreview.tsx
gpt-engineer-app[bot] c7f3e9e1b2 Continue console cleanup
2025-10-21 18:07:25 +00:00

347 lines
11 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { supabase } from '@/integrations/supabase/client';
import { Image as ImageIcon } from 'lucide-react';
import { PhotoModal } from './PhotoModal';
import { handleError } from '@/lib/errorHandler';
interface EntityEditPreviewProps {
submissionId: string;
entityType: string;
entityName?: string;
}
/**
* Deep equality check for detecting changes in nested objects/arrays
*/
const deepEqual = (a: any, b: any): boolean => {
// Handle null/undefined cases
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
// Handle primitives and functions
if (typeof a !== 'object') return a === b;
// Handle arrays
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, index) => deepEqual(item, b[index]));
}
// One is array, other is not
if (Array.isArray(a) !== Array.isArray(b)) return false;
// Handle objects
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => deepEqual(a[key], b[key]));
};
interface ImageAssignments {
uploaded: Array<{
url: string;
cloudflare_id: string;
}>;
banner_assignment: number | null;
card_assignment: number | null;
}
interface SubmissionItemData {
id: string;
item_data: Record<string, unknown>;
original_data?: Record<string, unknown>;
}
export const EntityEditPreview = ({ submissionId, entityType, entityName }: EntityEditPreviewProps) => {
const [loading, setLoading] = useState(true);
const [itemData, setItemData] = useState<Record<string, unknown> | null>(null);
const [originalData, setOriginalData] = useState<Record<string, unknown> | null>(null);
const [changedFields, setChangedFields] = useState<string[]>([]);
const [bannerImageUrl, setBannerImageUrl] = useState<string | null>(null);
const [cardImageUrl, setCardImageUrl] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [isPhotoOperation, setIsPhotoOperation] = useState(false);
useEffect(() => {
fetchSubmissionItems();
}, [submissionId]);
const fetchSubmissionItems = async () => {
try {
setLoading(true);
const { data: items, error } = await supabase
.from('submission_items')
.select('*')
.eq('submission_id', submissionId)
.order('order_index', { ascending: true });
if (error) throw error;
if (items && items.length > 0) {
const firstItem = items[0];
setItemData(firstItem.item_data as Record<string, unknown>);
setOriginalData(firstItem.original_data as Record<string, unknown> | null);
// 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 Record<string, unknown>;
// Check for image changes
if (data.images && typeof data.images === 'object') {
const images = data.images as {
uploaded?: Array<{ url: string; cloudflare_id: string }>;
banner_assignment?: number | null;
card_assignment?: number | null;
};
// Safety check: verify uploaded array exists and is valid
if (!images.uploaded || !Array.isArray(images.uploaded)) {
// Invalid images data structure, skip image processing
return;
}
// Extract banner image
if (images.banner_assignment !== null && images.banner_assignment !== undefined) {
// Safety check: verify index is within bounds
if (images.banner_assignment >= 0 && images.banner_assignment < images.uploaded.length) {
const bannerImg = images.uploaded[images.banner_assignment];
// Validate nested image data
if (bannerImg && bannerImg.url) {
setBannerImageUrl(bannerImg.url);
changed.push('banner_image');
}
}
}
// Extract card image
if (images.card_assignment !== null && images.card_assignment !== undefined) {
// Safety check: verify index is within bounds
if (images.card_assignment >= 0 && images.card_assignment < images.uploaded.length) {
const cardImg = images.uploaded[images.card_assignment];
// Validate nested image data
if (cardImg && cardImg.url) {
setCardImageUrl(cardImg.url);
changed.push('card_image');
}
}
}
}
// Check for other field changes by comparing with original_data
if (firstItem.original_data) {
const originalData = firstItem.original_data as Record<string, unknown>;
const excludeFields = ['images', 'updated_at', 'created_at'];
Object.keys(data).forEach(key => {
if (!excludeFields.includes(key)) {
// Use deep equality check for objects and arrays
const isEqual = deepEqual(data[key], originalData[key]);
if (!isEqual) {
changed.push(key);
}
}
});
}
setChangedFields(changed);
}
} catch (error: unknown) {
handleError(error, {
action: 'Load Submission Preview',
metadata: { submissionId, entityType }
});
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="text-sm text-muted-foreground">
Loading preview...
</div>
);
}
if (!itemData) {
return (
<div className="text-sm text-muted-foreground">
No preview available
</div>
);
}
// 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 && typeof itemData.cloudflare_image_url === 'string' && (
<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 as string) || <em>No caption</em>}
</span>
</div>
<div>
<span className="font-medium">New caption: </span>
<span className="text-muted-foreground">
{(itemData?.new_caption as string) || <em>No caption</em>}
</span>
</div>
</div>
)}
{!isEdit && itemData?.reason && typeof itemData.reason === 'string' && (
<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) {
photos.push({
id: 'banner',
url: `${bannerImageUrl}`,
caption: 'New Banner Image'
});
}
if (cardImageUrl) {
photos.push({
id: 'card',
url: `${cardImageUrl}`,
caption: 'New Card Image'
});
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="capitalize">
{entityType}
</Badge>
<Badge variant="secondary">
Edit
</Badge>
</div>
{entityName && (
<div className="font-medium text-base">
{entityName}
</div>
)}
{changedFields.length > 0 && (
<div className="text-sm">
<span className="font-medium">Changed fields: </span>
<span className="text-muted-foreground">
{changedFields.map(field => field.replace(/_/g, ' ')).join(', ')}
</span>
</div>
)}
{(bannerImageUrl || cardImageUrl) && (
<div className="space-y-2">
<div className="font-medium text-sm flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
Image Changes:
</div>
<div className="grid grid-cols-2 gap-2">
{bannerImageUrl && (
<Card className="overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary transition-all" onClick={() => {
setSelectedImageIndex(0);
setIsModalOpen(true);
}}>
<CardContent className="p-2">
<img
src={`${bannerImageUrl}`}
alt="New banner"
className="w-full h-24 object-cover rounded"
/>
<div className="text-xs text-center mt-1 text-muted-foreground">
Banner
</div>
</CardContent>
</Card>
)}
{cardImageUrl && (
<Card className="overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary transition-all" onClick={() => {
setSelectedImageIndex(bannerImageUrl ? 1 : 0);
setIsModalOpen(true);
}}>
<CardContent className="p-2">
<img
src={`${cardImageUrl}`}
alt="New card"
className="w-full h-24 object-cover rounded"
/>
<div className="text-xs text-center mt-1 text-muted-foreground">
Card
</div>
</CardContent>
</Card>
)}
</div>
</div>
)}
<div className="text-xs text-muted-foreground italic">
Click "Review Items" for full details
</div>
<PhotoModal
photos={photos}
initialIndex={selectedImageIndex}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</div>
);
};