mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
Refactor photo modification logic
This commit is contained in:
@@ -35,6 +35,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
|
|||||||
const [cardImageUrl, setCardImageUrl] = useState<string | null>(null);
|
const [cardImageUrl, setCardImageUrl] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||||
|
const [isPhotoOperation, setIsPhotoOperation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubmissionItems();
|
fetchSubmissionItems();
|
||||||
@@ -57,6 +58,15 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
|
|||||||
setItemData(firstItem.item_data);
|
setItemData(firstItem.item_data);
|
||||||
setOriginalData(firstItem.original_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
|
// Parse changed fields
|
||||||
const changed: string[] = [];
|
const changed: string[] = [];
|
||||||
const data = firstItem.item_data as any;
|
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
|
// Build photos array for modal
|
||||||
const photos = [];
|
const photos = [];
|
||||||
if (bannerImageUrl) {
|
if (bannerImageUrl) {
|
||||||
|
|||||||
@@ -77,55 +77,128 @@ export function PhotoManagementDialog({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const deletePhoto = async (photoId: string) => {
|
const requestPhotoDelete = async (photoId: string, photo: Photo) => {
|
||||||
if (!confirm('Are you sure you want to delete this photo?')) return;
|
const reason = prompt('Please provide a reason for deleting this photo:');
|
||||||
|
if (!reason) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.from('photos').delete().eq('id', photoId);
|
// Get current user
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('Not authenticated');
|
||||||
|
|
||||||
if (error) throw error;
|
// Create content submission
|
||||||
|
const { data: submission, error: submissionError } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.insert([{
|
||||||
|
user_id: user.id,
|
||||||
|
submission_type: 'photo_delete',
|
||||||
|
content: {
|
||||||
|
photo_id: photoId,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: entityId,
|
||||||
|
reason: reason
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (submissionError) throw submissionError;
|
||||||
|
|
||||||
|
// Create submission item
|
||||||
|
const { error: itemError } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.insert({
|
||||||
|
submission_id: submission.id,
|
||||||
|
item_type: 'photo_delete',
|
||||||
|
item_data: {
|
||||||
|
photo_id: photoId,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: entityId,
|
||||||
|
cloudflare_image_url: photo.cloudflare_image_url,
|
||||||
|
caption: photo.caption,
|
||||||
|
reason: reason
|
||||||
|
},
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (itemError) throw itemError;
|
||||||
|
|
||||||
await fetchPhotos();
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: 'Delete request submitted',
|
||||||
description: 'Photo deleted',
|
description: 'Your photo deletion request has been submitted for moderation',
|
||||||
});
|
});
|
||||||
onUpdate?.();
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting photo:', error);
|
console.error('Error requesting photo deletion:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'Failed to delete photo',
|
description: 'Failed to submit deletion request',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePhoto = async () => {
|
const requestPhotoEdit = async () => {
|
||||||
if (!editingPhoto) return;
|
if (!editingPhoto) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
// Get current user
|
||||||
.from('photos')
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
.update({
|
if (!user) throw new Error('Not authenticated');
|
||||||
caption: editingPhoto.caption,
|
|
||||||
})
|
|
||||||
.eq('id', editingPhoto.id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
// Get original photo data
|
||||||
|
const originalPhoto = photos.find(p => p.id === editingPhoto.id);
|
||||||
|
if (!originalPhoto) throw new Error('Original photo not found');
|
||||||
|
|
||||||
|
// Create content submission
|
||||||
|
const { data: submission, error: submissionError } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.insert([{
|
||||||
|
user_id: user.id,
|
||||||
|
submission_type: 'photo_edit',
|
||||||
|
content: {
|
||||||
|
photo_id: editingPhoto.id,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: entityId
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (submissionError) throw submissionError;
|
||||||
|
|
||||||
|
// Create submission item
|
||||||
|
const { error: itemError } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.insert({
|
||||||
|
submission_id: submission.id,
|
||||||
|
item_type: 'photo_edit',
|
||||||
|
item_data: {
|
||||||
|
photo_id: editingPhoto.id,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: entityId,
|
||||||
|
new_caption: editingPhoto.caption,
|
||||||
|
cloudflare_image_url: editingPhoto.cloudflare_image_url,
|
||||||
|
},
|
||||||
|
original_data: {
|
||||||
|
caption: originalPhoto.caption,
|
||||||
|
},
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (itemError) throw itemError;
|
||||||
|
|
||||||
await fetchPhotos();
|
|
||||||
setEditingPhoto(null);
|
setEditingPhoto(null);
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: 'Edit request submitted',
|
||||||
description: 'Photo updated',
|
description: 'Your photo edit has been submitted for moderation',
|
||||||
});
|
});
|
||||||
onUpdate?.();
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating photo:', error);
|
console.error('Error requesting photo edit:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'Failed to update photo',
|
description: 'Failed to submit edit request',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -167,7 +240,7 @@ export function PhotoManagementDialog({
|
|||||||
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
|
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={updatePhoto}>Save Changes</Button>
|
<Button onClick={requestPhotoEdit}>Submit for Review</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -223,16 +296,16 @@ export function PhotoManagementDialog({
|
|||||||
className="flex-1 sm:flex-initial"
|
className="flex-1 sm:flex-initial"
|
||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
Edit
|
Request Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => deletePhoto(photo.id)}
|
onClick={() => requestPhotoDelete(photo.id, photo)}
|
||||||
className="flex-1 sm:flex-initial"
|
className="flex-1 sm:flex-initial"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
Delete
|
Request Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ export type EntityType =
|
|||||||
| 'manufacturer'
|
| 'manufacturer'
|
||||||
| 'operator'
|
| 'operator'
|
||||||
| 'designer'
|
| 'designer'
|
||||||
| 'property_owner';
|
| 'property_owner'
|
||||||
|
| 'photo_edit'
|
||||||
|
| 'photo_delete';
|
||||||
|
|
||||||
export interface PhotoSubmission {
|
export interface PhotoSubmission {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ serve(async (req) => {
|
|||||||
await approvePhotos(supabase, resolvedData, item.id);
|
await approvePhotos(supabase, resolvedData, item.id);
|
||||||
entityId = item.id; // Use item ID as entity ID for photos
|
entityId = item.id; // Use item ID as entity ID for photos
|
||||||
break;
|
break;
|
||||||
|
case 'photo_edit':
|
||||||
|
await editPhoto(supabase, resolvedData);
|
||||||
|
entityId = resolvedData.photo_id;
|
||||||
|
break;
|
||||||
|
case 'photo_delete':
|
||||||
|
await deletePhoto(supabase, resolvedData);
|
||||||
|
entityId = resolvedData.photo_id;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown item type: ${item.item_type}`);
|
throw new Error(`Unknown item type: ${item.item_type}`);
|
||||||
}
|
}
|
||||||
@@ -595,3 +603,25 @@ function extractImageId(url: string): string {
|
|||||||
const matches = url.match(/\/([^\/]+)\/public$/);
|
const matches = url.match(/\/([^\/]+)\/public$/);
|
||||||
return matches ? matches[1] : url;
|
return matches ? matches[1] : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function editPhoto(supabase: any, data: any): Promise<void> {
|
||||||
|
console.log(`Editing photo ${data.photo_id}`);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('photos')
|
||||||
|
.update({
|
||||||
|
caption: data.new_caption,
|
||||||
|
})
|
||||||
|
.eq('id', data.photo_id);
|
||||||
|
|
||||||
|
if (error) throw new Error(`Failed to edit photo: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePhoto(supabase: any, data: any): Promise<void> {
|
||||||
|
console.log(`Deleting photo ${data.photo_id}`);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('photos')
|
||||||
|
.delete()
|
||||||
|
.eq('id', data.photo_id);
|
||||||
|
|
||||||
|
if (error) throw new Error(`Failed to delete photo: ${error.message}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Restrict direct photo modifications - require moderation queue
|
||||||
|
-- Drop existing policies that allow direct modification
|
||||||
|
DROP POLICY IF EXISTS "Moderators can update photos" ON public.photos;
|
||||||
|
DROP POLICY IF EXISTS "Moderators can delete photos" ON public.photos;
|
||||||
|
|
||||||
|
-- Keep read policies
|
||||||
|
-- Public read access to photos already exists
|
||||||
|
|
||||||
|
-- Only service role (edge functions) can modify photos after approval
|
||||||
|
CREATE POLICY "Service role can insert photos"
|
||||||
|
ON public.photos FOR INSERT
|
||||||
|
TO service_role
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role can update photos"
|
||||||
|
ON public.photos FOR UPDATE
|
||||||
|
TO service_role
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role can delete photos"
|
||||||
|
ON public.photos FOR DELETE
|
||||||
|
TO service_role
|
||||||
|
USING (true);
|
||||||
Reference in New Issue
Block a user