mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:31:13 -05:00
feat: Show moderation notes and allow reversing decisions
This commit is contained in:
@@ -28,6 +28,14 @@ interface ModerationItem {
|
|||||||
};
|
};
|
||||||
entity_name?: string;
|
entity_name?: string;
|
||||||
park_name?: string;
|
park_name?: string;
|
||||||
|
reviewed_at?: string;
|
||||||
|
reviewed_by?: string;
|
||||||
|
reviewer_notes?: string;
|
||||||
|
reviewer_profile?: {
|
||||||
|
username: string;
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
||||||
@@ -107,6 +115,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
photos,
|
photos,
|
||||||
park_id,
|
park_id,
|
||||||
ride_id,
|
ride_id,
|
||||||
|
moderated_at,
|
||||||
|
moderated_by,
|
||||||
parks:park_id (
|
parks:park_id (
|
||||||
name
|
name
|
||||||
),
|
),
|
||||||
@@ -135,7 +145,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
submission_type,
|
submission_type,
|
||||||
created_at,
|
created_at,
|
||||||
user_id,
|
user_id,
|
||||||
status
|
status,
|
||||||
|
reviewed_at,
|
||||||
|
reviewer_id,
|
||||||
|
reviewer_notes
|
||||||
`)
|
`)
|
||||||
.in('status', submissionStatuses);
|
.in('status', submissionStatuses);
|
||||||
|
|
||||||
@@ -193,11 +206,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
submissions = submissionsWithEntities;
|
submissions = submissionsWithEntities;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get unique user IDs to fetch profiles
|
// Get unique user IDs to fetch profiles (including reviewers)
|
||||||
const userIds = [
|
const userIds = [
|
||||||
...reviews.map(r => r.user_id),
|
...reviews.map(r => r.user_id),
|
||||||
...submissions.map(s => s.user_id)
|
...submissions.map(s => s.user_id),
|
||||||
];
|
...reviews.filter(r => r.moderated_by).map(r => r.moderated_by),
|
||||||
|
...submissions.filter(s => s.reviewer_id).map(s => s.reviewer_id)
|
||||||
|
].filter((id, index, arr) => id && arr.indexOf(id) === index); // Remove duplicates and nulls
|
||||||
|
|
||||||
// Fetch profiles for all users with avatars
|
// Fetch profiles for all users with avatars
|
||||||
const { data: profiles } = await supabase
|
const { data: profiles } = await supabase
|
||||||
@@ -230,6 +245,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
user_profile: profileMap.get(review.user_id),
|
user_profile: profileMap.get(review.user_id),
|
||||||
entity_name,
|
entity_name,
|
||||||
park_name,
|
park_name,
|
||||||
|
reviewed_at: review.moderated_at,
|
||||||
|
reviewed_by: review.moderated_by,
|
||||||
|
reviewer_notes: (review as any).reviewer_notes,
|
||||||
|
reviewer_profile: review.moderated_by ? profileMap.get(review.moderated_by) : undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
...submissions.map(submission => ({
|
...submissions.map(submission => ({
|
||||||
@@ -243,6 +262,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
user_profile: profileMap.get(submission.user_id),
|
user_profile: profileMap.get(submission.user_id),
|
||||||
entity_name: (submission as any).entity_name,
|
entity_name: (submission as any).entity_name,
|
||||||
park_name: (submission as any).park_name,
|
park_name: (submission as any).park_name,
|
||||||
|
reviewed_at: submission.reviewed_at,
|
||||||
|
reviewed_by: submission.reviewer_id,
|
||||||
|
reviewer_notes: submission.reviewer_notes,
|
||||||
|
reviewer_profile: submission.reviewer_id ? profileMap.get(submission.reviewer_id) : undefined,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -850,6 +873,73 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reviewer Information for approved/rejected items */}
|
||||||
|
{(item.status === 'approved' || item.status === 'rejected') && (item.reviewed_at || item.reviewer_notes) && (
|
||||||
|
<div className="space-y-3 pt-4 border-t">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>Reviewed {item.reviewed_at ? format(new Date(item.reviewed_at), 'MMM d, yyyy HH:mm') : 'recently'}</span>
|
||||||
|
{item.reviewer_profile && (
|
||||||
|
<>
|
||||||
|
<span>by</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={item.reviewer_profile.avatar_url} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(item.reviewer_profile.display_name || item.reviewer_profile.username)?.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.reviewer_profile.display_name || item.reviewer_profile.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.reviewer_notes && (
|
||||||
|
<div className="bg-muted/30 p-3 rounded-lg">
|
||||||
|
<p className="text-sm font-medium mb-1">Reviewer Notes:</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{item.reviewer_notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reverse Decision Buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Reverse Decision</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add notes about reversing this decision..."
|
||||||
|
value={notes[`reverse-${item.id}`] || ''}
|
||||||
|
onChange={(e) => setNotes(prev => ({ ...prev, [`reverse-${item.id}`]: e.target.value }))}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{item.status === 'approved' && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleModerationAction(item, 'rejected', notes[`reverse-${item.id}`])}
|
||||||
|
disabled={actionLoading === item.id}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
Change to Rejected
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{item.status === 'rejected' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleModerationAction(item, 'approved', notes[`reverse-${item.id}`])}
|
||||||
|
disabled={actionLoading === item.id}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Change to Approved
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete button for rejected submissions (admin/superadmin only) */}
|
{/* Delete button for rejected submissions (admin/superadmin only) */}
|
||||||
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin() || isSuperuser()) && (
|
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin() || isSuperuser()) && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user