Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

@@ -0,0 +1,51 @@
import { Filter, MessageSquare, FileText, Image } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { EntityFilter, StatusFilter } from '@/types/moderation';
interface ActiveFiltersDisplayProps {
entityFilter: EntityFilter;
statusFilter: StatusFilter;
defaultEntityFilter?: EntityFilter;
defaultStatusFilter?: StatusFilter;
}
const getEntityFilterIcon = (filter: EntityFilter) => {
switch (filter) {
case 'reviews': return <MessageSquare className="w-4 h-4" />;
case 'submissions': return <FileText className="w-4 h-4" />;
case 'photos': return <Image className="w-4 h-4" />;
default: return <Filter className="w-4 h-4" />;
}
};
// Removed - sorting functionality deleted
export const ActiveFiltersDisplay = ({
entityFilter,
statusFilter,
defaultEntityFilter = 'all',
defaultStatusFilter = 'pending'
}: ActiveFiltersDisplayProps) => {
const hasActiveFilters =
entityFilter !== defaultEntityFilter ||
statusFilter !== defaultStatusFilter;
if (!hasActiveFilters) return null;
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Active filters:</span>
{entityFilter !== defaultEntityFilter && (
<Badge variant="secondary" className="flex items-center gap-1">
{getEntityFilterIcon(entityFilter)}
{entityFilter}
</Badge>
)}
{statusFilter !== defaultStatusFilter && (
<Badge variant="secondary" className="capitalize">
{statusFilter}
</Badge>
)}
</div>
);
};

View File

@@ -0,0 +1,115 @@
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CheckCircle, XCircle, Flag, Shield, AlertCircle } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface ActivityCardProps {
activity: {
id: string;
type: 'submission' | 'report' | 'review';
action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged';
entity_type?: string;
entity_name?: string;
timestamp: string;
moderator_id?: string | null;
moderator?: {
username: string;
display_name?: string | null;
avatar_url?: string | null;
};
};
}
export function ActivityCard({ activity }: ActivityCardProps) {
const getActionIcon = () => {
switch (activity.action) {
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />;
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />;
case 'reviewed':
return <Shield className="w-5 h-5 text-blue-600 dark:text-blue-400" />;
case 'dismissed':
return <XCircle className="w-5 h-5 text-gray-600 dark:text-gray-400" />;
case 'flagged':
return <AlertCircle className="w-5 h-5 text-orange-600 dark:text-orange-400" />;
default:
return <Flag className="w-5 h-5 text-muted-foreground" />;
}
};
const getActionBadge = () => {
const variants = {
approved: 'default',
rejected: 'destructive',
reviewed: 'default',
dismissed: 'secondary',
flagged: 'secondary',
} as const;
return (
<Badge variant={variants[activity.action] || 'secondary'} className="capitalize">
{activity.action}
</Badge>
);
};
const getActivityTitle = () => {
const typeLabels = {
submission: 'Submission',
report: 'Report',
review: 'Review',
};
const entityTypeLabels = {
park: 'Park',
ride: 'Ride',
photo: 'Photo',
company: 'Company',
review: 'Review',
};
const entityLabel = activity.entity_type
? entityTypeLabels[activity.entity_type as keyof typeof entityTypeLabels] || activity.entity_type
: typeLabels[activity.type];
return `${entityLabel} ${activity.action}`;
};
const moderatorName = activity.moderator?.display_name || activity.moderator?.username || 'Unknown Moderator';
const moderatorInitial = moderatorName.charAt(0).toUpperCase();
return (
<Card className="p-4 hover:bg-accent/50 transition-colors">
<div className="flex items-start gap-4">
<div className="p-2 bg-muted rounded-lg">
{getActionIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<p className="font-medium truncate">
{getActivityTitle()}
</p>
{getActionBadge()}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Avatar className="w-5 h-5">
<AvatarImage src={activity.moderator?.avatar_url || undefined} />
<AvatarFallback className="text-xs">{moderatorInitial}</AvatarFallback>
</Avatar>
<span className="truncate">
by {moderatorName}
</span>
<span className="text-xs"></span>
<span className="text-xs whitespace-nowrap">
{formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })}
</span>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,218 @@
import { Badge } from '@/components/ui/badge';
import { Plus, Minus, Edit, Check } from 'lucide-react';
import { formatFieldValue } from '@/lib/submissionChangeDetection';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
interface ArrayFieldDiffProps {
fieldName: string;
oldArray: unknown[];
newArray: unknown[];
compact?: boolean;
}
interface ArrayDiffItem {
type: 'added' | 'removed' | 'modified' | 'unchanged';
oldValue?: unknown;
newValue?: unknown;
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: unknown; 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: unknown[], newArray: unknown[]): 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: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a === 'object') {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => isEqual(item, b[i]));
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => isEqual(a[key], b[key]));
}
return false;
}

View File

@@ -0,0 +1,173 @@
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronRight, History, Eye, Lock, Unlock, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Skeleton } from '@/components/ui/skeleton';
import { format } from 'date-fns';
import { supabase } from '@/lib/supabaseClient';
import { handleError } from '@/lib/errorHandler';
interface AuditLogEntry {
id: string;
action: string;
moderator_id: string;
submission_id: string | null;
previous_status: string | null;
new_status: string | null;
notes: string | null;
created_at: string;
is_test_data: boolean | null;
}
interface AuditTrailViewerProps {
submissionId: string;
}
export function AuditTrailViewer({ submissionId }: AuditTrailViewerProps) {
const [isOpen, setIsOpen] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && auditLogs.length === 0) {
fetchAuditLogs();
}
}, [isOpen, submissionId]);
const fetchAuditLogs = async () => {
try {
setLoading(true);
const { data, error } = await supabase
.from('moderation_audit_log')
.select('*')
.eq('submission_id', submissionId)
.order('created_at', { ascending: false });
if (error) throw error;
setAuditLogs(data || []);
} catch (error) {
handleError(error, {
action: 'Fetch Audit Trail',
metadata: { submissionId }
});
} finally {
setLoading(false);
}
};
const getActionIcon = (action: string) => {
switch (action) {
case 'viewed':
return <Eye className="h-4 w-4" />;
case 'claimed':
case 'locked':
return <Lock className="h-4 w-4" />;
case 'released':
case 'unlocked':
return <Unlock className="h-4 w-4" />;
case 'approved':
return <CheckCircle className="h-4 w-4" />;
case 'rejected':
return <XCircle className="h-4 w-4" />;
case 'escalated':
return <AlertCircle className="h-4 w-4" />;
default:
return <History className="h-4 w-4" />;
}
};
const getActionColor = (action: string) => {
switch (action) {
case 'approved':
return 'text-green-600 dark:text-green-400';
case 'rejected':
return 'text-red-600 dark:text-red-400';
case 'escalated':
return 'text-orange-600 dark:text-orange-400';
case 'claimed':
case 'locked':
return 'text-blue-600 dark:text-blue-400';
default:
return 'text-muted-foreground';
}
};
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<History className="h-4 w-4" />
<span>Audit Trail</span>
{auditLogs.length > 0 && (
<Badge variant="outline" className="ml-auto">
{auditLogs.length} action{auditLogs.length !== 1 ? 's' : ''}
</Badge>
)}
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-card rounded-lg border">
{loading ? (
<div className="p-4 space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : auditLogs.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">
No audit trail entries found
</div>
) : (
<div className="divide-y">
{auditLogs.map((entry) => (
<div key={entry.id} className="p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${getActionColor(entry.action)}`}>
{getActionIcon(entry.action)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium capitalize">
{entry.action.replace('_', ' ')}
</span>
<span className="text-xs text-muted-foreground font-mono">
{format(new Date(entry.created_at), 'MMM d, HH:mm:ss')}
</span>
</div>
{(entry.previous_status || entry.new_status) && (
<div className="flex items-center gap-2 text-xs">
{entry.previous_status && (
<Badge variant="outline" className="capitalize">
{entry.previous_status}
</Badge>
)}
{entry.previous_status && entry.new_status && (
<span className="text-muted-foreground"></span>
)}
{entry.new_status && (
<Badge variant="default" className="capitalize">
{entry.new_status}
</Badge>
)}
</div>
)}
{entry.notes && (
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded mt-2">
{entry.notes}
</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,24 @@
interface AutoRefreshIndicatorProps {
enabled: boolean;
intervalSeconds: number;
mode?: 'polling' | 'realtime';
}
export const AutoRefreshIndicator = ({
enabled,
intervalSeconds,
mode = 'polling'
}: AutoRefreshIndicatorProps) => {
if (!enabled) return null;
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span>Auto-refresh active</span>
</div>
<span></span>
<span>Checking every {intervalSeconds}s</span>
</div>
);
};

View File

@@ -0,0 +1,59 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
variant?: 'default' | 'destructive';
}
export const ConfirmationDialog = ({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
variant = 'default',
}: ConfirmationDialogProps) => {
const handleConfirm = () => {
onConfirm();
onOpenChange(false);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className={variant === 'destructive' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
ConfirmationDialog.displayName = 'ConfirmationDialog';

View File

@@ -0,0 +1,136 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import { type DependencyConflict, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { useAuth } from '@/hooks/useAuth';
import { handleError, handleSuccess } from '@/lib/errorHandler';
interface ConflictResolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflicts: DependencyConflict[];
items: SubmissionItemWithDeps[];
onResolve: () => void;
}
export function ConflictResolutionDialog({
open,
onOpenChange,
conflicts,
items,
onResolve,
}: ConflictResolutionDialogProps) {
const [resolutions, setResolutions] = useState<Record<string, string>>({});
const [isApplying, setIsApplying] = useState(false);
const { user } = useAuth();
const handleResolutionChange = (itemId: string, action: string) => {
setResolutions(prev => ({ ...prev, [itemId]: action }));
};
const allConflictsResolved = conflicts.every(
conflict => resolutions[conflict.itemId]
);
const handleApply = async () => {
if (!user?.id) {
handleError(new Error('Authentication required'), {
action: 'Resolve Conflicts',
metadata: { conflictCount: conflicts.length }
});
return;
}
setIsApplying(true);
const { resolveConflicts } = await import('@/lib/conflictResolutionService');
try {
const result = await resolveConflicts(conflicts, resolutions, items, user.id);
if (!result.success) {
handleError(new Error(result.error || 'Failed to resolve conflicts'), {
action: 'Resolve Conflicts',
userId: user.id,
metadata: { conflictCount: conflicts.length }
});
return;
}
handleSuccess('Conflicts Resolved', 'All conflicts have been resolved successfully');
onResolve();
onOpenChange(false);
} catch (error: unknown) {
handleError(error, {
action: 'Resolve Conflicts',
userId: user.id,
metadata: { conflictCount: conflicts.length }
});
} finally {
setIsApplying(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Resolve Dependency Conflicts</DialogTitle>
<DialogDescription>
{conflicts.length} conflict(s) found. Choose how to resolve each one.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{conflicts.map((conflict) => {
const item = items.find(i => i.id === conflict.itemId);
return (
<div key={conflict.itemId} className="space-y-3">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<p className="font-medium">
{item?.item_type.replace('_', ' ').toUpperCase()}: {
item && typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) && 'name' in item.item_data
? String((item.item_data as Record<string, unknown>).name)
: 'Unnamed'
}
</p>
<p className="text-sm mt-1">{conflict.message}</p>
</AlertDescription>
</Alert>
<RadioGroup
value={resolutions[conflict.itemId] || ''}
onValueChange={(value) => handleResolutionChange(conflict.itemId, value)}
>
{conflict.suggestions.map((suggestion, idx) => (
<div key={idx} className="flex items-center space-x-2">
<RadioGroupItem value={suggestion.action} id={`${conflict.itemId}-${idx}`} />
<Label htmlFor={`${conflict.itemId}-${idx}`} className="cursor-pointer">
{suggestion.label}
</Label>
</div>
))}
</RadioGroup>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isApplying}>
Cancel
</Button>
<Button onClick={handleApply} loading={isApplying} loadingText="Applying..." disabled={!allConflictsResolved}>
Apply & Approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,157 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { AlertTriangle, User, Clock } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { format } from 'date-fns';
import type { ConflictCheckResult } from '@/lib/submissionItemsService';
interface ConflictResolutionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflictData: ConflictCheckResult;
onResolve: (resolution: 'keep-mine' | 'keep-theirs' | 'reload') => Promise<void>;
}
export function ConflictResolutionModal({
open,
onOpenChange,
conflictData,
onResolve,
}: ConflictResolutionModalProps) {
const [selectedResolution, setSelectedResolution] = useState<string | null>(null);
const [isResolving, setIsResolving] = useState(false);
const { toast } = useToast();
const handleResolve = async () => {
if (!selectedResolution) return;
setIsResolving(true);
try {
await onResolve(selectedResolution as 'keep-mine' | 'keep-theirs' | 'reload');
toast({
title: 'Conflict Resolved',
description: 'Changes have been applied successfully',
});
onOpenChange(false);
} catch (error) {
toast({
title: 'Resolution Failed',
description: error instanceof Error ? error.message : 'Failed to resolve conflict',
variant: 'destructive',
});
} finally {
setIsResolving(false);
}
};
if (!conflictData.serverVersion) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Edit Conflict Detected
</DialogTitle>
<DialogDescription>
Someone else modified this submission while you were editing.
Choose how to resolve the conflict.
</DialogDescription>
</DialogHeader>
<Alert className="border-destructive/50 bg-destructive/10">
<AlertDescription className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4" />
<span className="font-medium">
Modified by: {conflictData.serverVersion.modified_by_profile?.display_name ||
conflictData.serverVersion.modified_by_profile?.username || 'Unknown'}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{format(new Date(conflictData.serverVersion.last_modified_at), 'PPpp')}
</div>
</div>
</AlertDescription>
</Alert>
<div className="space-y-3 py-4">
<h4 className="text-sm font-medium">Choose Resolution:</h4>
<Button
variant={selectedResolution === 'keep-mine' ? 'default' : 'outline'}
className="w-full justify-start text-left h-auto py-3"
onClick={() => setSelectedResolution('keep-mine')}
>
<div className="flex-1">
<div className="font-medium">Keep My Changes</div>
<div className="text-xs text-muted-foreground mt-1">
Overwrite their changes with your edits (use with caution)
</div>
</div>
{selectedResolution === 'keep-mine' && (
<Badge variant="default" className="ml-2">Selected</Badge>
)}
</Button>
<Button
variant={selectedResolution === 'keep-theirs' ? 'default' : 'outline'}
className="w-full justify-start text-left h-auto py-3"
onClick={() => setSelectedResolution('keep-theirs')}
>
<div className="flex-1">
<div className="font-medium">Keep Their Changes</div>
<div className="text-xs text-muted-foreground mt-1">
Discard your changes and accept the latest version
</div>
</div>
{selectedResolution === 'keep-theirs' && (
<Badge variant="default" className="ml-2">Selected</Badge>
)}
</Button>
<Button
variant={selectedResolution === 'reload' ? 'default' : 'outline'}
className="w-full justify-start text-left h-auto py-3"
onClick={() => setSelectedResolution('reload')}
>
<div className="flex-1">
<div className="font-medium">Reload and Review</div>
<div className="text-xs text-muted-foreground mt-1">
Load the latest version to review changes before deciding
</div>
</div>
{selectedResolution === 'reload' && (
<Badge variant="default" className="ml-2">Selected</Badge>
)}
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isResolving}>
Cancel
</Button>
<Button
onClick={handleResolve}
disabled={!selectedResolution || isResolving}
>
{isResolving ? 'Resolving...' : 'Apply Resolution'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,83 @@
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, Clock, XCircle, ChevronRight } from 'lucide-react';
import { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { cn } from '@/lib/utils';
interface DependencyTreeViewProps {
items: SubmissionItemWithDeps[];
}
export function DependencyTreeView({ items }: DependencyTreeViewProps) {
// Build tree structure - root items (no depends_on)
const rootItems = items.filter(item => !item.depends_on);
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle2 className="w-4 h-4 text-green-600" />;
case 'rejected':
return <XCircle className="w-4 h-4 text-destructive" />;
case 'pending':
default:
return <Clock className="w-4 h-4 text-muted-foreground" />;
}
};
const getItemLabel = (item: SubmissionItemWithDeps): string => {
const data = typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data)
? item.item_data as Record<string, unknown>
: {};
const name = 'name' in data && typeof data.name === 'string' ? data.name : 'Unnamed';
const type = item.item_type.replace('_', ' ');
return `${name} (${type})`;
};
const renderItem = (item: SubmissionItemWithDeps, level: number = 0) => {
const dependents = items.filter(i => i.depends_on === item.id);
const hasChildren = dependents.length > 0;
return (
<div key={item.id} className={cn("space-y-2", level > 0 && "ml-6 border-l-2 border-border pl-4")}>
<div className="flex items-center gap-2">
{level > 0 && <ChevronRight className="w-4 h-4 text-muted-foreground" />}
{getStatusIcon(item.status)}
<span className={cn(
"text-sm",
item.status === 'approved' && "text-green-700 dark:text-green-400",
item.status === 'rejected' && "text-destructive",
item.status === 'pending' && "text-foreground"
)}>
{getItemLabel(item)}
</span>
<Badge variant={item.status === 'approved' ? 'default' : item.status === 'rejected' ? 'destructive' : 'secondary'} className="text-xs">
{item.status}
</Badge>
{item.depends_on && (
<Badge variant="outline" className="text-xs">
depends on parent
</Badge>
)}
</div>
{hasChildren && dependents.map(dep => renderItem(dep, level + 1))}
</div>
);
};
if (items.length <= 1) {
return null; // Don't show tree for single items
}
return (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">Submission Items ({items.length})</span>
<Badge variant="outline" className="text-xs">
Composite Submission
</Badge>
</div>
<div className="space-y-2">
{rootItems.map(item => renderItem(item))}
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowDown, AlertCircle } from 'lucide-react';
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useIsMobile } from '@/hooks/use-mobile';
import { DependencyTreeView } from './DependencyTreeView';
interface DependencyVisualizerProps {
items: SubmissionItemWithDeps[];
selectedIds: Set<string>;
}
export function DependencyVisualizer({ items, selectedIds }: DependencyVisualizerProps) {
const isMobile = useIsMobile();
const dependencyLevels = useMemo(() => {
const levels: SubmissionItemWithDeps[][] = [];
const visited = new Set<string>();
const getRootItems = () => items.filter(item => !item.depends_on);
const addLevel = (currentItems: SubmissionItemWithDeps[]) => {
if (currentItems.length === 0) return;
const nextLevel: SubmissionItemWithDeps[] = [];
currentItems.forEach(item => {
if (!visited.has(item.id)) {
visited.add(item.id);
if (item.dependents) {
nextLevel.push(...item.dependents);
}
}
});
levels.push(currentItems);
addLevel(nextLevel);
};
addLevel(getRootItems());
return levels;
}, [items]);
const hasCircularDependency = items.length > 0 && dependencyLevels.flat().length !== items.length;
return (
<div className="space-y-6">
{/* Compact dependency tree view */}
<DependencyTreeView items={items} />
{hasCircularDependency && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Circular dependency detected! This submission needs admin review.
</AlertDescription>
</Alert>
)}
{dependencyLevels.length === 0 && (
<Alert>
<AlertDescription>
No dependencies found in this submission
</AlertDescription>
</Alert>
)}
{dependencyLevels.map((level, levelIdx) => (
<div key={levelIdx} className="space-y-3">
<div className="flex items-center gap-2">
<h4 className={`font-medium text-muted-foreground ${isMobile ? 'text-xs' : 'text-sm'}`}>
Level {levelIdx + 1}
</h4>
<div className="flex-1 h-px bg-border" />
</div>
<div className={`grid gap-3 ${isMobile ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2'}`}>
{level.map((item) => (
<Card
key={item.id}
className={`${
selectedIds.has(item.id)
? 'ring-2 ring-primary'
: ''
} ${isMobile ? 'touch-manipulation' : ''}`}
>
<CardHeader className={isMobile ? "pb-2 p-4" : "pb-2"}>
<div className="flex items-center justify-between gap-2">
<CardTitle className={isMobile ? "text-xs" : "text-sm"}>
{item.item_type.replace('_', ' ').toUpperCase()}
</CardTitle>
<Badge
variant={
item.status === 'approved' ? 'default' :
item.status === 'rejected' ? 'destructive' :
'secondary'
}
className={isMobile ? "text-xs shrink-0" : ""}
>
{item.status}
</Badge>
</div>
</CardHeader>
<CardContent className={isMobile ? "p-4 pt-0" : ""}>
<p className={`font-medium ${isMobile ? 'text-sm' : 'text-sm'}`}>
{typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) && 'name' in item.item_data
? String((item.item_data as Record<string, unknown>).name)
: 'Unnamed'}
</p>
{item.dependents && item.dependents.length > 0 && (
<p className={`text-muted-foreground mt-1 ${isMobile ? 'text-xs' : 'text-xs'}`}>
Has {item.dependents.length} dependent(s)
</p>
)}
</CardContent>
</Card>
))}
</div>
{levelIdx < dependencyLevels.length - 1 && (
<div className="flex justify-center py-2">
<ArrowDown className={isMobile ? "w-4 h-4 text-muted-foreground" : "w-5 h-5 text-muted-foreground"} />
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchEditHistory } from '@/lib/submissionItemsService';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { EditHistoryEntry } from './EditHistoryEntry';
import { History, Loader2, AlertCircle } from 'lucide-react';
interface EditHistoryRecord {
id: string;
item_id: string;
edited_at: string;
edit_reason: string | null;
changed_fields: string[];
field_changes?: Array<{
id: string;
field_name: string;
old_value: string | null;
new_value: string | null;
}>;
editor?: {
username: string;
avatar_url?: string | null;
} | null;
}
interface EditHistoryAccordionProps {
submissionId: string;
}
const INITIAL_LOAD = 20;
const LOAD_MORE_INCREMENT = 10;
export function EditHistoryAccordion({ submissionId }: EditHistoryAccordionProps) {
const [limit, setLimit] = useState(INITIAL_LOAD);
const { data: editHistory, isLoading, error } = useQuery({
queryKey: ['edit-history', submissionId, limit],
queryFn: async () => {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch edit history with user profiles
const { data, error } = await supabase
.from('item_edit_history')
.select(`
id,
item_id,
edited_at,
edit_reason,
changed_fields,
field_changes:item_field_changes(
id,
field_name,
old_value,
new_value
),
editor:profiles!item_edit_history_edited_by_fkey(
username,
avatar_url
)
`)
.eq('item_id', submissionId)
.order('edited_at', { ascending: false })
.limit(limit);
if (error) throw error;
return (data || []) as unknown as EditHistoryRecord[];
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const loadMore = () => {
setLimit(prev => prev + LOAD_MORE_INCREMENT);
};
const hasMore = editHistory && editHistory.length === limit;
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="edit-history">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<History className="h-4 w-4" />
<span>Edit History</span>
{editHistory && editHistory.length > 0 && (
<span className="text-xs text-muted-foreground">
({editHistory.length} edit{editHistory.length !== 1 ? 's' : ''})
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load edit history: {error instanceof Error ? error.message : 'Unknown error'}
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length === 0 && (
<Alert>
<AlertDescription>
No edit history found for this submission.
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length > 0 && (
<div className="space-y-4">
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-3">
{editHistory.map((entry: EditHistoryRecord) => {
// Transform relational field_changes into beforeData/afterData objects
const beforeData: Record<string, unknown> = {};
const afterData: Record<string, unknown> = {};
entry.field_changes?.forEach(change => {
beforeData[change.field_name] = change.old_value;
afterData[change.field_name] = change.new_value;
});
return (
<EditHistoryEntry
key={entry.id}
editId={entry.id}
editorName={entry.editor?.username || 'Unknown User'}
editorAvatar={entry.editor?.avatar_url || undefined}
timestamp={entry.edited_at}
changedFields={entry.changed_fields || []}
editReason={entry.edit_reason || undefined}
beforeData={beforeData}
afterData={afterData}
/>
);
})}
</div>
</ScrollArea>
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={loadMore}
>
Load More
</Button>
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,131 @@
import { formatDistanceToNow } from 'date-fns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, Edit, User } from 'lucide-react';
import { useState } from 'react';
interface EditHistoryEntryProps {
editId: string;
editorName: string;
editorAvatar?: string;
timestamp: string;
changedFields: string[];
editReason?: string;
beforeData?: Record<string, any>;
afterData?: Record<string, any>;
}
export function EditHistoryEntry({
editId,
editorName,
editorAvatar,
timestamp,
changedFields,
editReason,
beforeData,
afterData,
}: EditHistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(false);
const getFieldValue = (data: Record<string, any> | undefined, field: string): string => {
if (!data || !(field in data)) return '—';
const value = data[field];
if (value === null || value === undefined) return '—';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};
return (
<Card className="p-4">
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div className="flex items-start gap-3">
{/* Editor Avatar */}
<Avatar className="h-8 w-8">
<AvatarImage src={editorAvatar} alt={editorName} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
{/* Edit Info */}
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{editorName}</span>
<Badge variant="secondary" className="text-xs">
<Edit className="h-3 w-3 mr-1" />
{changedFields.length} field{changedFields.length !== 1 ? 's' : ''}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(timestamp), { addSuffix: true })}
</span>
</div>
{/* Changed Fields Summary */}
<div className="flex flex-wrap gap-1">
{changedFields.slice(0, 3).map((field) => (
<Badge key={field} variant="outline" className="text-xs">
{field}
</Badge>
))}
{changedFields.length > 3 && (
<Badge variant="outline" className="text-xs">
+{changedFields.length - 3} more
</Badge>
)}
</div>
{/* Edit Reason */}
{editReason && (
<p className="text-sm text-muted-foreground italic">
"{editReason}"
</p>
)}
{/* Expand/Collapse Button */}
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2">
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
<span className="ml-1">{isExpanded ? 'Hide' : 'Show'} Changes</span>
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* Detailed Changes */}
<CollapsibleContent className="mt-3 space-y-3">
{changedFields.map((field) => {
const beforeValue = getFieldValue(beforeData, field);
const afterValue = getFieldValue(afterData, field);
return (
<div key={field} className="border-l-2 border-muted pl-3 space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{field}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Before</div>
<div className="bg-destructive/10 text-destructive rounded p-2 font-mono text-xs break-all">
{beforeValue}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">After</div>
<div className="bg-success/10 text-success rounded p-2 font-mono text-xs break-all">
{afterValue}
</div>
</div>
</div>
</div>
);
})}
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@@ -0,0 +1,53 @@
import { CheckCircle, LucideIcon } from 'lucide-react';
import type { EntityFilter, StatusFilter } from '@/types/moderation';
interface EmptyQueueStateProps {
entityFilter: EntityFilter;
statusFilter: StatusFilter;
icon?: LucideIcon;
title?: string;
customMessage?: string;
}
const getEmptyStateMessage = (entityFilter: EntityFilter, statusFilter: StatusFilter): string => {
const entityLabel = entityFilter === 'all' ? 'items' :
entityFilter === 'reviews' ? 'reviews' :
entityFilter === 'photos' ? 'photos' : 'submissions';
switch (statusFilter) {
case 'pending':
return `No pending ${entityLabel} require moderation at this time.`;
case 'partially_approved':
return `No partially approved ${entityLabel} found.`;
case 'flagged':
return `No flagged ${entityLabel} found.`;
case 'approved':
return `No approved ${entityLabel} found.`;
case 'rejected':
return `No rejected ${entityLabel} found.`;
case 'all':
return `No ${entityLabel} found.`;
default:
return `No ${entityLabel} found for the selected filter.`;
}
};
export const EmptyQueueState = ({
entityFilter,
statusFilter,
icon: Icon = CheckCircle,
title = 'No items found',
customMessage
}: EmptyQueueStateProps) => {
const message = customMessage || getEmptyStateMessage(entityFilter, statusFilter);
return (
<div className="text-center py-8">
<Icon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-muted-foreground">{message}</p>
</div>
);
};
EmptyQueueState.displayName = 'EmptyQueueState';

View File

@@ -0,0 +1,93 @@
import { CheckCircle, Search, PartyPopper, HelpCircle, LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { EntityFilter, StatusFilter } from '@/types/moderation';
interface EnhancedEmptyStateProps {
entityFilter: EntityFilter;
statusFilter: StatusFilter;
onClearFilters?: () => void;
onLearnMore?: () => void;
}
type EmptyStateVariant = {
icon: LucideIcon;
title: string;
description: string;
action?: {
label: string;
onClick: () => void;
};
};
const getEmptyStateVariant = (
entityFilter: EntityFilter,
statusFilter: StatusFilter,
onClearFilters?: () => void,
onLearnMore?: () => void
): EmptyStateVariant => {
const entityLabel = entityFilter === 'all' ? 'items' :
entityFilter === 'reviews' ? 'reviews' :
entityFilter === 'photos' ? 'photos' : 'submissions';
// Success state: No pending items
if (statusFilter === 'pending' && entityFilter === 'all') {
return {
icon: PartyPopper,
title: 'All caught up!',
description: 'No pending items require moderation at this time. Great work!',
};
}
// Filtered but no results: Suggest clearing filters
if (entityFilter !== 'all' || statusFilter !== 'pending') {
return {
icon: Search,
title: `No ${entityLabel} found`,
description: `No ${entityLabel} match your current filters. Try clearing filters to see all items.`,
action: onClearFilters ? {
label: 'Clear Filters',
onClick: onClearFilters,
} : undefined,
};
}
// First-time user: Onboarding
return {
icon: HelpCircle,
title: 'Welcome to the Moderation Queue',
description: 'Submissions will appear here when users contribute content. Claim, review, and approve or reject items.',
action: onLearnMore ? {
label: 'Learn More',
onClick: onLearnMore,
} : undefined,
};
};
export const EnhancedEmptyState = ({
entityFilter,
statusFilter,
onClearFilters,
onLearnMore
}: EnhancedEmptyStateProps) => {
const variant = getEmptyStateVariant(entityFilter, statusFilter, onClearFilters, onLearnMore);
const Icon = variant.icon;
return (
<div className="flex flex-col items-center justify-center py-12 px-4">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Icon className="w-8 h-8 text-primary" />
</div>
<h3 className="text-xl font-semibold mb-2 text-foreground">{variant.title}</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
{variant.description}
</p>
{variant.action && (
<Button onClick={variant.action.onClick} variant="outline">
{variant.action.label}
</Button>
)}
</div>
);
};
EnhancedEmptyState.displayName = 'EnhancedEmptyState';

View File

@@ -0,0 +1,164 @@
import { useState, useEffect, useMemo } from 'react';
import { Clock, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface LockState {
submissionId: string;
expiresAt: Date;
}
interface QueueStats {
pendingCount: number;
assignedToMe: number;
avgWaitHours: number;
}
interface EnhancedLockStatusDisplayProps {
currentLock: LockState | null;
queueStats: QueueStats | null;
loading: boolean;
onExtendLock: () => void;
onReleaseLock: () => void;
getCurrentTime: () => Date;
}
const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
const WARNING_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
const CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
export const EnhancedLockStatusDisplay = ({
currentLock,
queueStats,
loading,
onExtendLock,
onReleaseLock,
getCurrentTime,
}: EnhancedLockStatusDisplayProps) => {
const [timeLeft, setTimeLeft] = useState<number>(0);
useEffect(() => {
if (!currentLock) return;
const updateTimer = () => {
const now = getCurrentTime();
const remaining = currentLock.expiresAt.getTime() - now.getTime();
setTimeLeft(Math.max(0, remaining));
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [currentLock, getCurrentTime]);
const { urgency, progressPercent } = useMemo(() => {
if (timeLeft <= 0) return { urgency: 'expired', progressPercent: 0 };
if (timeLeft <= CRITICAL_THRESHOLD_MS) return { urgency: 'critical', progressPercent: (timeLeft / LOCK_DURATION_MS) * 100 };
if (timeLeft <= WARNING_THRESHOLD_MS) return { urgency: 'warning', progressPercent: (timeLeft / LOCK_DURATION_MS) * 100 };
return { urgency: 'safe', progressPercent: (timeLeft / LOCK_DURATION_MS) * 100 };
}, [timeLeft]);
const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const showExtendButton = timeLeft > 0 && timeLeft <= WARNING_THRESHOLD_MS;
if (!currentLock) {
return (
<div className="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>No submission claimed</span>
</div>
{queueStats && (
<div className="text-sm text-muted-foreground">
{queueStats.pendingCount} pending
</div>
)}
</div>
);
}
return (
<div
className={cn(
'p-4 rounded-lg border transition-colors',
urgency === 'critical' && 'bg-destructive/10 border-destructive animate-pulse',
urgency === 'warning' && 'bg-yellow-500/10 border-yellow-500',
urgency === 'safe' && 'bg-primary/10 border-primary'
)}
data-testid="lock-status-display"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className={cn(
'w-4 h-4',
urgency === 'critical' && 'text-destructive',
urgency === 'warning' && 'text-yellow-600',
urgency === 'safe' && 'text-primary'
)} />
<span className="text-sm font-medium">Lock expires in</span>
</div>
<Badge
variant={urgency === 'critical' ? 'destructive' : urgency === 'warning' ? 'default' : 'secondary'}
className={cn(
'font-mono text-base',
urgency === 'critical' && 'animate-pulse'
)}
>
{formatTime(timeLeft)}
</Badge>
</div>
<Progress
value={progressPercent}
className={cn(
'h-2 mb-3',
urgency === 'critical' && '[&>div]:bg-destructive',
urgency === 'warning' && '[&>div]:bg-yellow-500',
urgency === 'safe' && '[&>div]:bg-primary'
)}
/>
{urgency === 'critical' && (
<div className="flex items-start gap-2 mb-3 text-sm text-destructive">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span className="font-medium">Lock expiring soon! Extend or release.</span>
</div>
)}
<div className="flex gap-2">
{showExtendButton && (
<Button
onClick={onExtendLock}
disabled={loading}
size="sm"
variant={urgency === 'critical' ? 'default' : 'outline'}
className="flex-1"
>
<Clock className="w-4 h-4 mr-2" />
Extend Lock (+15min)
</Button>
)}
<Button
onClick={onReleaseLock}
disabled={loading}
size="sm"
variant="outline"
className={!showExtendButton ? 'flex-1' : ''}
>
Release Lock
</Button>
</div>
</div>
);
};
EnhancedLockStatusDisplay.displayName = 'EnhancedLockStatusDisplay';

View File

@@ -0,0 +1,400 @@
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { supabase } from '@/lib/supabaseClient';
import { Image as ImageIcon } from 'lucide-react';
import { PhotoModal } from './PhotoModal';
import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
interface EntityEditPreviewProps {
submissionId: string;
entityType: string;
entityName?: string;
}
/**
* Deep equality check for detecting changes in nested objects/arrays
*/
const deepEqual = <T extends Record<string, unknown>>(a: T, b: T): 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 as Record<string, unknown>, b[index] as Record<string, unknown>));
}
// 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 => {
const valueA = a[key];
const valueB = b[key];
if (typeof valueA === 'object' && valueA !== null && typeof valueB === 'object' && valueB !== null) {
return deepEqual(valueA as Record<string, unknown>, valueB as Record<string, unknown>);
}
return valueA === valueB;
});
};
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 [error, setError] = useState<string | null>(null);
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);
// Fetch items with relational data
const { data: items, error } = await supabase
.from('submission_items')
.select(`
*,
park_submission:park_submissions!submission_items_park_submission_id_fkey(*),
ride_submission:ride_submissions!submission_items_ride_submission_id_fkey(*),
photo_submission:photo_submissions!submission_items_photo_submission_id_fkey(
*,
photo_items:photo_submission_items(*)
)
`)
.eq('submission_id', submissionId)
.order('order_index', { ascending: true });
if (error) throw error;
if (items && items.length > 0) {
const firstItem = items[0];
// Transform relational data to item_data format
let itemDataObj: Record<string, unknown> = {};
switch (firstItem.item_type) {
case 'park':
itemDataObj = (firstItem as any).park_submission || {};
break;
case 'ride':
itemDataObj = (firstItem as any).ride_submission || {};
break;
case 'photo':
itemDataObj = {
...(firstItem as any).photo_submission,
photos: (firstItem as any).photo_submission?.photo_items || []
};
break;
default:
itemDataObj = {};
}
setItemData(itemDataObj);
setOriginalData(null); // Original data not used in new relational model
// 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 = itemDataObj 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
// Note: In new relational model, we don't track original_data at item level
// Field changes are determined by comparing current vs approved entity data
if (itemDataObj) {
const excludeFields = ['images', 'updated_at', 'created_at', 'id'];
Object.keys(itemDataObj).forEach(key => {
if (!excludeFields.includes(key) && itemDataObj[key] !== null && itemDataObj[key] !== undefined) {
changed.push(key);
}
});
}
setChangedFields(changed);
}
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
handleError(error, {
action: 'Load Submission Preview',
metadata: { submissionId, entityType }
});
setError(errorMsg);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="text-sm text-muted-foreground">
Loading preview...
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
</Alert>
);
}
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>
)) as React.ReactNode}
{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>
)) as React.ReactNode}
<div className="text-xs text-muted-foreground italic">
Click "Review Items" for full details
</div>
</div>
);
}
// Build photos array for modal
const photos: Array<{ id: string; url: string; caption: string | null }> = [];
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.map(photo => ({
...photo,
caption: photo.caption ?? undefined
}))}
initialIndex={selectedImageIndex}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import { useState } from 'react';
import { AlertTriangle, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface EscalationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onEscalate: (reason: string) => Promise<void>;
submissionType: string;
error?: { message: string; errorId?: string } | null;
}
const escalationReasons = [
'Complex dependency issue',
'Potential policy violation',
'Unclear submission content',
'Requires admin judgment',
'Technical issue',
'Other',
];
export function EscalationDialog({
open,
onOpenChange,
onEscalate,
submissionType,
error,
}: EscalationDialogProps) {
const [selectedReason, setSelectedReason] = useState('');
const [additionalNotes, setAdditionalNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleEscalate = async () => {
const reason = selectedReason === 'Other'
? additionalNotes
: `${selectedReason}${additionalNotes ? ': ' + additionalNotes : ''}`;
if (!reason.trim()) return;
setIsSubmitting(true);
try {
await onEscalate(reason);
setSelectedReason('');
setAdditionalNotes('');
onOpenChange(false);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Escalate Submission
</DialogTitle>
<DialogDescription>
Escalating this {submissionType} will mark it as high priority and notify senior moderators.
</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive" className="mt-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Escalation Failed</AlertTitle>
<AlertDescription>
<div className="space-y-2">
<p className="text-sm">{error.message}</p>
{error.errorId && (
<p className="text-xs font-mono bg-destructive/10 px-2 py-1 rounded">
Reference: {error.errorId.slice(0, 8)}
</p>
)}
</div>
</AlertDescription>
</Alert>
)}
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Escalation Reason</Label>
<Select value={selectedReason} onValueChange={setSelectedReason}>
<SelectTrigger>
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
<SelectContent>
{escalationReasons.map((reason) => (
<SelectItem key={reason} value={reason}>
{reason}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Additional Notes</Label>
<Textarea
value={additionalNotes}
onChange={(e) => setAdditionalNotes(e.target.value)}
placeholder="Provide any additional context..."
rows={4}
className="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleEscalate}
disabled={!selectedReason || isSubmitting}
>
{isSubmitting ? 'Escalating...' : 'Escalate'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,331 @@
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';
// Helper to format compact values (truncate long strings)
function formatCompactValue(value: unknown, precision?: 'day' | 'month' | 'year', maxLength = 30): string {
const formatted = formatFieldValue(value, precision);
if (formatted.length > maxLength) {
return formatted.substring(0, maxLength) + '...';
}
return formatted;
}
interface FieldDiffProps {
change: FieldChange;
compact?: boolean;
}
export function FieldDiff({ change, compact = false }: FieldDiffProps) {
const { field, oldValue, newValue, changeType, metadata } = change;
// Extract precision for date fields
const precision = metadata?.precision;
const oldPrecision = metadata?.oldPrecision;
const newPrecision = metadata?.newPrecision;
// 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) {
const fieldName = formatFieldName(field);
if (changeType === 'added') {
return (
<Badge variant="outline" className={getChangeColor()}>
{fieldName}: + {formatCompactValue(newValue, precision)}
</Badge>
);
}
if (changeType === 'removed') {
return (
<Badge variant="outline" className={getChangeColor()}>
{fieldName}: <span className="line-through">{formatCompactValue(oldValue, precision)}</span>
</Badge>
);
}
if (changeType === 'modified') {
return (
<Badge variant="outline" className={getChangeColor()}>
{fieldName}: {formatCompactValue(oldValue, oldPrecision || precision)} {formatCompactValue(newValue, newPrecision || precision)}
</Badge>
);
}
return (
<Badge variant="outline" className={getChangeColor()}>
{fieldName}
</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, precision)}
</div>
)}
{changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(oldValue, precision)}
</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, oldPrecision || precision)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(newValue, newPrecision || precision)}
</span>
</div>
)}
</div>
);
}
interface ImageDiffProps {
change: ImageChange;
compact?: boolean;
}
export function ImageDiff({ change, compact = false }: ImageDiffProps) {
const { type, oldUrl, newUrl } = change;
if (compact) {
const imageLabel = type === 'banner' ? 'Banner' : 'Card';
const isAddition = !oldUrl && newUrl;
const isRemoval = oldUrl && !newUrl;
const isReplacement = oldUrl && newUrl;
let action = '';
let colorClass = 'text-blue-600 dark:text-blue-400';
if (isAddition) {
action = ' (Added)';
colorClass = 'text-green-600 dark:text-green-400';
} else if (isRemoval) {
action = ' (Removed)';
colorClass = 'text-red-600 dark:text-red-400';
} else if (isReplacement) {
action = ' (Changed)';
colorClass = 'text-amber-600 dark:text-amber-400';
}
return (
<Badge variant="outline" className={colorClass}>
{imageLabel} Image{action}
</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 LocationData {
location_id?: string;
city?: string;
state_province?: string;
country?: string;
postal_code?: string;
latitude?: number | string;
longitude?: number | string;
}
interface LocationDiffProps {
oldLocation: LocationData | string | null | undefined;
newLocation: LocationData | string | null | undefined;
compact?: boolean;
}
export function LocationDiff({ oldLocation, newLocation, compact = false }: LocationDiffProps) {
// Type guards for LocationData
const isLocationData = (loc: unknown): loc is LocationData => {
return typeof loc === 'object' && loc !== null && !Array.isArray(loc);
};
// Check if we're creating a new location entity
const isCreatingNewLocation = isLocationData(oldLocation) &&
oldLocation.location_id &&
!oldLocation.city &&
isLocationData(newLocation) &&
newLocation.city;
const formatLocation = (loc: LocationData | string | null | undefined) => {
if (!loc) return 'None';
if (typeof loc === 'string') return loc;
// Handle location_id reference
if (loc.location_id && !loc.city) {
return `Location ID: ${loc.location_id.substring(0, 8)}...`;
}
if (typeof loc === 'object') {
const parts: string[] = [];
if (loc.city) parts.push(String(loc.city));
if (loc.state_province) parts.push(String(loc.state_province));
if (loc.country && loc.country !== loc.state_province) parts.push(String(loc.country));
if (loc.postal_code) parts.push(String(loc.postal_code));
let locationStr = parts.join(', ') || 'Unknown';
// Add coordinates if available
if (loc.latitude && loc.longitude) {
const lat = Number(loc.latitude).toFixed(6);
const lng = Number(loc.longitude).toFixed(6);
locationStr += ` (${lat}°, ${lng}°)`;
}
return locationStr;
}
return String(loc);
};
// Check if only coordinates changed
const onlyCoordinatesChanged = isLocationData(oldLocation) &&
isLocationData(newLocation) &&
oldLocation.city === newLocation.city &&
oldLocation.state_province === newLocation.state_province &&
oldLocation.country === newLocation.country &&
oldLocation.postal_code === newLocation.postal_code &&
(Number(oldLocation.latitude) !== Number(newLocation.latitude) ||
Number(oldLocation.longitude) !== Number(newLocation.longitude));
if (compact) {
const oldLoc = formatLocation(oldLocation);
const newLoc = formatLocation(newLocation);
if (!oldLocation && newLocation) {
return (
<Badge variant="outline" className="text-green-600 dark:text-green-400">
Location: + {newLoc}
</Badge>
);
}
if (oldLocation && !newLocation) {
return (
<Badge variant="outline" className="text-red-600 dark:text-red-400">
Location: <span className="line-through">{oldLoc}</span>
</Badge>
);
}
return (
<Badge variant="outline" className="text-amber-600 dark:text-amber-400">
Location{isCreatingNewLocation && ' (New Entity)'}
{onlyCoordinatesChanged && ' (GPS Refined)'}
: {oldLoc} {newLoc}
</Badge>
);
}
return (
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">
Location
{isCreatingNewLocation && (
<Badge variant="secondary" className="ml-2">New location entity</Badge>
)}
{onlyCoordinatesChanged && (
<Badge variant="outline" className="ml-2">GPS coordinates refined</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm">
{oldLocation && (
<span className="text-red-600 dark:text-red-400 line-through">
{formatLocation(oldLocation)}
</span>
)}
{oldLocation && newLocation && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
{newLocation && (
<span className="text-green-600 dark:text-green-400">
{formatLocation(newLocation)}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,385 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast';
import { useIsMobile } from '@/hooks/use-mobile';
import { logger } from '@/lib/logger';
import { useUserRole } from '@/hooks/useUserRole';
import { getErrorMessage } from '@/lib/errorHandler';
import { useAuth } from '@/hooks/useAuth';
import { editSubmissionItem, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { ParkForm } from '@/components/admin/ParkForm';
import { RideForm } from '@/components/admin/RideForm';
import { ManufacturerForm } from '@/components/admin/ManufacturerForm';
import { DesignerForm } from '@/components/admin/DesignerForm';
import { OperatorForm } from '@/components/admin/OperatorForm';
import { jsonToFormData } from '@/lib/typeConversions';
import { PropertyOwnerForm } from '@/components/admin/PropertyOwnerForm';
import { RideModelForm } from '@/components/admin/RideModelForm';
import { Save, X, Edit } from 'lucide-react';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
interface ItemEditDialogProps {
item?: SubmissionItemWithDeps | null;
items?: SubmissionItemWithDeps[];
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
export function ItemEditDialog({ item, items, open, onOpenChange, onComplete }: ItemEditDialogProps) {
const [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<string>(items?.[0]?.id || '');
const isMobile = useIsMobile();
const { isModerator } = useUserRole();
const { user } = useAuth();
const Container = isMobile ? Sheet : Dialog;
// Phase 5: Bulk edit mode
const bulkEditMode = items && items.length > 1;
const currentItem = bulkEditMode ? items.find(i => i.id === activeTab) : item;
if (!currentItem && !bulkEditMode) return null;
const handleSubmit = async (data: Record<string, unknown>, itemId?: string) => {
if (!user?.id) {
toast({
title: 'Authentication Required',
description: 'You must be logged in to edit items',
variant: 'destructive',
});
return;
}
const targetItemId = itemId || currentItem?.id;
if (!targetItemId) return;
setSubmitting(true);
try {
await editSubmissionItem(targetItemId, data, user.id);
toast({
title: isModerator() ? 'Item Updated' : 'Edit Submitted',
description: isModerator()
? 'The item has been updated successfully.'
: 'Your edit has been submitted and will be reviewed by a moderator.',
});
if (bulkEditMode && items) {
// Move to next tab or complete
const currentIndex = items.findIndex(i => i.id === activeTab);
if (currentIndex < items.length - 1) {
setActiveTab(items[currentIndex + 1].id);
} else {
onComplete();
onOpenChange(false);
}
} else {
onComplete();
onOpenChange(false);
}
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
toast({
title: 'Error',
description: errorMsg,
variant: 'destructive',
});
} finally {
setSubmitting(false);
}
};
const handlePhotoSubmit = async (caption: string, credit: string) => {
if (!item?.item_data) {
toast({
title: 'Error',
description: 'No photo data available',
variant: 'destructive',
});
return;
}
const itemData = typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data)
? item.item_data as Record<string, unknown>
: {};
const photos = 'photos' in itemData && Array.isArray(itemData.photos)
? itemData.photos
: [];
const photoData = {
...itemData,
photos: photos.map((photo: unknown) => ({
...(typeof photo === 'object' && photo !== null ? photo as Record<string, unknown> : {}),
caption,
credit,
})),
};
await handleSubmit(photoData);
};
const renderEditForm = (editItem: SubmissionItemWithDeps) => {
const itemData = typeof editItem.item_data === 'object' && editItem.item_data !== null && !Array.isArray(editItem.item_data)
? editItem.item_data as Record<string, unknown>
: {};
switch (editItem.item_type) {
case 'park':
return (
<SubmissionErrorBoundary submissionId={editItem.id}>
<ParkForm
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={jsonToFormData(editItem.item_data) as any}
isEditing
/>
</SubmissionErrorBoundary>
);
case 'ride':
return (
<SubmissionErrorBoundary submissionId={editItem.id}>
<RideForm
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={jsonToFormData(editItem.item_data) as any}
isEditing
/>
</SubmissionErrorBoundary>
);
case 'manufacturer':
return (
<SubmissionErrorBoundary submissionId={editItem.id}>
<ManufacturerForm
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={jsonToFormData(editItem.item_data) as any}
/>
</SubmissionErrorBoundary>
);
case 'designer':
return (
<SubmissionErrorBoundary submissionId={editItem.id}>
<DesignerForm
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={jsonToFormData(editItem.item_data) as any}
/>
</SubmissionErrorBoundary>
);
case 'operator':
return (
<SubmissionErrorBoundary submissionId={editItem.id}>
<OperatorForm
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={jsonToFormData(editItem.item_data) as any}
/>
</SubmissionErrorBoundary>
);
case 'property_owner':
return (
<SubmissionErrorBoundary submissionId={editItem.id}>
<PropertyOwnerForm
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={jsonToFormData(editItem.item_data) as any}
/>
</SubmissionErrorBoundary>
);
case 'ride_model':
const manufacturerName = 'manufacturer_name' in itemData && typeof itemData.manufacturer_name === 'string'
? itemData.manufacturer_name
: 'Unknown';
const manufacturerId = 'manufacturer_id' in itemData && typeof itemData.manufacturer_id === 'string'
? itemData.manufacturer_id
: '';
return (
<SubmissionErrorBoundary submissionId={editItem.id}>
<RideModelForm
manufacturerName={manufacturerName}
manufacturerId={manufacturerId}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={itemData as any}
/>
</SubmissionErrorBoundary>
);
case 'photo':
const photos = 'photos' in itemData && Array.isArray(itemData.photos)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? itemData.photos as any
: [];
return (
<PhotoEditForm
photos={photos}
onSubmit={handlePhotoSubmit}
onCancel={() => onOpenChange(false)}
submitting={submitting}
/>
);
default:
return (
<div className="text-center py-8 text-muted-foreground">
No edit form available for this item type
</div>
);
}
};
return (
<Container open={open} onOpenChange={onOpenChange}>
{isMobile ? (
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Edit className="w-5 h-5" />
{bulkEditMode ? `Edit ${items.length} Items` : `Edit ${currentItem?.item_type.replace('_', ' ')}`}
</SheetTitle>
<SheetDescription>
{bulkEditMode ? 'Edit multiple submission items using tabs' : 'Make changes to this submission item'}
</SheetDescription>
</SheetHeader>
<div className="mt-6">
{bulkEditMode && items ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${items.length}, 1fr)` }}>
{items.map((tabItem, index) => (
<TabsTrigger key={tabItem.id} value={tabItem.id}>
{index + 1}. {tabItem.item_type.replace('_', ' ')}
</TabsTrigger>
))}
</TabsList>
{items.map(tabItem => (
<TabsContent key={tabItem.id} value={tabItem.id} className="mt-4">
{renderEditForm(tabItem)}
</TabsContent>
))}
</Tabs>
) : currentItem ? (
renderEditForm(currentItem)
) : null}
</div>
</SheetContent>
) : (
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit className="w-5 h-5" />
{bulkEditMode ? `Edit ${items.length} Items` : `Edit ${currentItem?.item_type.replace('_', ' ')}`}
</DialogTitle>
<DialogDescription>
{bulkEditMode ? 'Edit multiple submission items using tabs' : 'Make changes to this submission item'}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{bulkEditMode && items ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${items.length}, 1fr)` }}>
{items.map((tabItem, index) => (
<TabsTrigger key={tabItem.id} value={tabItem.id}>
{index + 1}. {tabItem.item_type.replace('_', ' ')}
</TabsTrigger>
))}
</TabsList>
{items.map(tabItem => (
<TabsContent key={tabItem.id} value={tabItem.id} className="mt-4">
{renderEditForm(tabItem)}
</TabsContent>
))}
</Tabs>
) : currentItem ? (
renderEditForm(currentItem)
) : null}
</div>
</DialogContent>
)}
</Container>
);
}
// Simple photo editing form for caption and credit
interface PhotoItem {
url: string;
caption?: string | null;
credit?: string | null;
}
function PhotoEditForm({
photos,
onSubmit,
onCancel,
submitting
}: {
photos: PhotoItem[];
onSubmit: (caption: string, credit: string) => void;
onCancel: () => void;
submitting: boolean;
}) {
const [caption, setCaption] = useState(photos[0]?.caption || '');
const [credit, setCredit] = useState(photos[0]?.credit || '');
return (
<div className="space-y-6">
{/* Photo Preview */}
<div className="grid grid-cols-3 gap-2">
{photos.slice(0, 3).map((photo, idx) => (
<img
key={idx}
src={photo.url}
alt={photo.caption || 'Photo'}
className="w-full h-32 object-cover rounded"
/>
))}
</div>
{/* Caption */}
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Textarea
id="caption"
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Describe the photo..."
rows={3}
/>
</div>
{/* Credit */}
<div className="space-y-2">
<Label htmlFor="credit">Photo Credit</Label>
<Input
id="credit"
value={credit}
onChange={(e) => setCredit(e.target.value)}
placeholder="Photographer name"
/>
</div>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button onClick={() => onSubmit(caption, credit)} disabled={submitting}>
<Save className="w-4 h-4 mr-2" />
{submitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
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';
import { ValidationSummary } from './ValidationSummary';
import { useState, useCallback } from 'react';
import { ValidationResult } from '@/lib/entityValidationSchemas';
interface ItemReviewCardProps {
item: SubmissionItemWithDeps;
onEdit: () => void;
onStatusChange: (status: 'approved' | 'rejected') => void;
submissionId: string;
}
export function ItemReviewCard({ item, onEdit, onStatusChange, submissionId }: ItemReviewCardProps) {
const isMobile = useIsMobile();
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [validationKey, setValidationKey] = useState(0);
const handleValidationChange = useCallback((result: ValidationResult) => {
setValidationResult(result);
}, []);
const getItemIcon = () => {
switch (item.item_type) {
case 'park': return <MapPin className="w-4 h-4" />;
case 'ride': return <Zap className="w-4 h-4" />;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer': return <Building2 className="w-4 h-4" />;
case 'ride_model': return <Package className="w-4 h-4" />;
case 'photo': return <Image className="w-4 h-4" />;
default: return null;
}
};
const getStatusColor = () => {
switch (item.status) {
case 'approved': return 'default';
case 'rejected': return 'destructive';
case 'pending': return 'secondary';
default: return 'outline';
}
};
const getValidationBadgeVariant = (): "default" | "secondary" | "destructive" | "outline" => {
if (!validationResult) return 'outline';
if (validationResult.blockingErrors.length > 0) return 'destructive';
if (validationResult.warnings.length > 0) return 'outline';
return 'secondary';
};
const getValidationBadgeLabel = () => {
if (!validationResult) return 'Validating...';
if (validationResult.blockingErrors.length > 0) return '❌ Errors';
if (validationResult.warnings.length > 0) return '⚠️ Warnings';
return '✓ Valid';
};
const renderItemPreview = () => {
// Use detailed view for review manager with photo detection
return (
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={true}
submissionId={submissionId}
/>
);
};
const hasBlockingErrors = validationResult && validationResult.blockingErrors.length > 0;
return (
<Card className={`w-full ${hasBlockingErrors ? 'border-destructive border-2' : ''}`}>
<CardHeader className={isMobile ? "pb-3 p-4" : "pb-3"}>
<div className={`flex gap-2 ${isMobile ? 'flex-col' : 'items-start justify-between'}`}>
<div className="flex items-center gap-2 flex-wrap">
{getItemIcon()}
<CardTitle className={isMobile ? "text-sm" : "text-base"}>
{item.item_type.replace('_', ' ').toUpperCase()}
</CardTitle>
{(item.original_data && Object.keys(item.original_data).length > 0 && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 border border-blue-300 dark:border-blue-700">
<Edit className="w-3 h-3 mr-1" />
Moderator Edited
</Badge>
)) as React.ReactNode}
{hasBlockingErrors && (
<Badge variant="destructive" className="text-xs">
Blocked
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant={getStatusColor()} className={isMobile ? "text-xs" : ""}>
{item.status}
</Badge>
<Badge variant={getValidationBadgeVariant()} className={isMobile ? "text-xs" : ""}>
{getValidationBadgeLabel()}
</Badge>
{item.status === 'pending' && (
<Button
size={isMobile ? "default" : "sm"}
variant="ghost"
onClick={onEdit}
className={isMobile ? "h-9 px-3" : ""}
>
<Edit className={isMobile ? "w-4 h-4" : "w-3 h-3"} />
{isMobile && <span className="ml-2">Edit</span>}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className={isMobile ? "p-4 pt-0" : ""}>
{renderItemPreview()}
{/* Validation Summary */}
<div className="border-t pt-4 mt-4">
<ValidationSummary
item={{
item_type: item.item_type,
item_data: item.item_data as import('@/types/moderation').SubmissionItemData,
id: item.id,
}}
onValidationChange={handleValidationChange}
compact={false}
validationKey={validationKey}
/>
</div>
{item.depends_on && (
<div className="mt-3 pt-3 border-t">
<p className={`text-muted-foreground ${isMobile ? 'text-xs' : 'text-xs'}`}>
Depends on another item in this submission
</p>
</div>
)}
{item.rejection_reason && (
<div className="mt-3 pt-3 border-t">
<p className={`font-medium text-destructive ${isMobile ? 'text-xs' : 'text-xs'}`}>
Rejection Reason:
</p>
<p className={`text-muted-foreground mt-1 ${isMobile ? 'text-xs' : 'text-xs'}`}>
{item.rejection_reason}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,115 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Edit, CheckCircle, XCircle, Clock } from 'lucide-react';
import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
interface ItemSelectorDialogProps {
items: SubmissionItemWithDeps[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectItem: (item: SubmissionItemWithDeps) => void;
onBulkEdit?: () => void;
}
export function ItemSelectorDialog({
items,
open,
onOpenChange,
onSelectItem,
onBulkEdit
}: ItemSelectorDialogProps) {
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="h-4 w-4 text-green-600" />;
case 'rejected':
return <XCircle className="h-4 w-4 text-red-600" />;
default:
return <Clock className="h-4 w-4 text-yellow-600" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-100 text-green-800 border-green-200';
case 'rejected':
return 'bg-red-100 text-red-800 border-red-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit className="w-5 h-5" />
Select Item to Edit
</DialogTitle>
<DialogDescription>
This submission contains {items.length} items. Choose which one to edit, or edit all at once.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-4">
{/* Bulk Edit Option */}
{onBulkEdit && items.length > 1 && (
<Button
variant="outline"
className="w-full justify-start h-auto py-4 border-2 border-primary/20 hover:border-primary/40"
onClick={onBulkEdit}
>
<Edit className="mr-3 h-5 w-5 text-primary" />
<div className="flex flex-col items-start">
<span className="font-semibold">Edit All Items ({items.length})</span>
<span className="text-xs text-muted-foreground">Use tabbed interface to edit multiple items</span>
</div>
</Button>
)}
{/* Individual Items */}
{items.map((item) => (
<Button
key={item.id}
variant="outline"
className="w-full justify-start h-auto py-4 hover:bg-accent"
onClick={() => onSelectItem(item)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<Edit className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex flex-col items-start">
<span className="font-medium capitalize">
{item.item_type.replace('_', ' ')}
</span>
{typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) && 'name' in item.item_data && (
<span className="text-sm text-muted-foreground">
{String((item.item_data as Record<string, unknown>).name)}
</span>
)}
{item.dependencies && item.dependencies.length > 0 && (
<span className="text-xs text-muted-foreground mt-1">
Depends on {item.dependencies.length} item(s)
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(item.status)}
<Badge variant="outline" className={getStatusColor(item.status)}>
{item.status}
</Badge>
</div>
</div>
</Button>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,55 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Keyboard } from 'lucide-react';
interface KeyboardShortcut {
key: string;
description: string;
}
interface KeyboardShortcutsHelpProps {
open: boolean;
onOpenChange: (open: boolean) => void;
shortcuts: KeyboardShortcut[];
}
export const KeyboardShortcutsHelp = ({
open,
onOpenChange,
shortcuts,
}: KeyboardShortcutsHelpProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5" />
Keyboard Shortcuts
</DialogTitle>
<DialogDescription>
Speed up your moderation workflow with these keyboard shortcuts
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
{shortcuts.map((shortcut, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{shortcut.description}
</span>
<kbd className="px-2 py-1 text-xs font-semibold text-foreground bg-muted border border-border rounded">
{shortcut.key}
</kbd>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
};
KeyboardShortcutsHelp.displayName = 'KeyboardShortcutsHelp';

View File

@@ -0,0 +1,107 @@
import { Lock, Clock, Unlock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
interface LockState {
submissionId: string;
expiresAt: Date;
}
interface QueueStats {
pendingCount: number;
assignedToMe: number;
avgWaitHours: number;
}
interface LockStatusDisplayProps {
currentLock: LockState | null;
queueStats: QueueStats | null;
isLoading: boolean;
onExtendLock: (submissionId: string) => Promise<boolean>;
onReleaseLock: (submissionId: string) => Promise<boolean>;
getTimeRemaining: () => number | null;
getLockProgress: () => number;
}
/**
* LockStatusDisplay Component
*
* Displays lock timer, progress bar, and lock management controls.
* Shows "Claim Next" button when no lock is active, or lock controls when locked.
*/
export const LockStatusDisplay = ({
currentLock,
queueStats,
isLoading,
onExtendLock,
onReleaseLock,
getTimeRemaining,
getLockProgress,
}: LockStatusDisplayProps) => {
// Format milliseconds as MM:SS
const formatLockTimer = (ms: number): string => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const timeRemaining = getTimeRemaining();
const showExtendButton = timeRemaining !== null && timeRemaining < 5 * 60 * 1000;
// If no active lock, show simple info message
if (!currentLock) {
return (
<div className="flex flex-col gap-2 min-w-[200px]">
<div className="text-sm text-muted-foreground">
No submission currently claimed. Claim a submission below to start reviewing.
</div>
</div>
);
}
// Active lock - show timer and controls
return (
<div className="flex flex-col gap-2 min-w-[200px]">
{/* Lock Timer */}
<div className="flex items-center gap-2 text-sm">
<Lock className="w-4 h-4 text-amber-500" />
<span className="font-medium">
Lock: {formatLockTimer(timeRemaining || 0)}
</span>
</div>
{/* Progress Bar */}
<Progress
value={getLockProgress()}
className="h-2"
/>
{/* Extend Lock Button (show when < 5 min left) */}
{showExtendButton && (
<Button
size="sm"
variant="outline"
onClick={() => onExtendLock(currentLock.submissionId)}
disabled={isLoading}
className="w-full"
>
<Clock className="w-4 h-4 mr-2" />
Extend Lock
</Button>
)}
{/* Release Lock Button */}
<Button
size="sm"
variant="outline"
onClick={() => onReleaseLock(currentLock.submissionId)}
disabled={isLoading}
className="w-full"
>
<Unlock className="w-4 h-4 mr-2" />
Release Lock
</Button>
</div>
);
};

View File

@@ -0,0 +1,733 @@
import { useState, useImperativeHandle, forwardRef, useMemo, useCallback, useRef, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { AlertCircle, Info } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { TooltipProvider } from '@/components/ui/tooltip';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { getErrorMessage } from '@/lib/errorHandler';
import { supabase } from '@/lib/supabaseClient';
import * as localStorage from '@/lib/localStorage';
import { PhotoModal } from './PhotoModal';
import { SubmissionReviewManager } from './SubmissionReviewManager';
import { ItemEditDialog } from './ItemEditDialog';
import { ItemSelectorDialog } from './ItemSelectorDialog';
import { useIsMobile } from '@/hooks/use-mobile';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationQueueManager } from '@/hooks/moderation';
import { QueueItem } from './QueueItem';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
import { QueueSkeleton } from './QueueSkeleton';
import { EnhancedLockStatusDisplay } from './EnhancedLockStatusDisplay';
import { getLockStatus } from '@/lib/moderation/lockHelpers';
import { QueueStats } from './QueueStats';
import { QueueFilters } from './QueueFilters';
import { ActiveFiltersDisplay } from './ActiveFiltersDisplay';
import { AutoRefreshIndicator } from './AutoRefreshIndicator';
import { NewItemsAlert } from './NewItemsAlert';
import { EnhancedEmptyState } from './EnhancedEmptyState';
import { QueuePagination } from './QueuePagination';
import { ConfirmationDialog } from './ConfirmationDialog';
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
import { SuperuserQueueControls } from './SuperuserQueueControls';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { fetchSubmissionItems, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import type { ModerationQueueRef, ModerationItem } from '@/types/moderation';
import type { PhotoItem } from '@/types/photos';
interface ModerationQueueProps {
optimisticallyUpdateStats?: (delta: Partial<{ pendingSubmissions: number; openReports: number; flaggedContent: number }>) => void;
}
export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueueProps>((props, ref) => {
const { optimisticallyUpdateStats } = props;
const isMobile = useIsMobile();
const { user } = useAuth();
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
const adminSettings = useAdminSettings();
// Extract settings values to stable primitives for memoization
const refreshMode = adminSettings.getAdminPanelRefreshMode();
const pollInterval = adminSettings.getAdminPanelPollInterval();
const refreshStrategy = adminSettings.getAutoRefreshStrategy();
const preserveInteraction = adminSettings.getPreserveInteractionState();
const useRealtimeQueue = adminSettings.getUseRealtimeQueue();
// Memoize settings object using stable primitive dependencies
const settings = useMemo(() => ({
refreshMode,
pollInterval,
refreshStrategy,
preserveInteraction,
useRealtimeQueue,
}), [refreshMode, pollInterval, refreshStrategy, preserveInteraction, useRealtimeQueue]);
// Initialize queue manager (replaces all state management, fetchItems, effects)
const queueManager = useModerationQueueManager({
user,
isAdmin: isAdmin(),
isSuperuser: isSuperuser(),
toast,
optimisticallyUpdateStats,
settings,
});
// UI-only state
const [notes, setNotes] = useState<Record<string, string>>({});
const [transactionStatuses, setTransactionStatuses] = useState<Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>>(() => {
// Restore from localStorage on mount
return localStorage.getJSON('moderation-queue-transaction-statuses', {});
});
const [photoModalOpen, setPhotoModalOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const [showItemEditDialog, setShowItemEditDialog] = useState(false);
const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null);
const [showItemSelector, setShowItemSelector] = useState(false);
const [availableItems, setAvailableItems] = useState<SubmissionItemWithDeps[]>([]);
const [bulkEditMode, setBulkEditMode] = useState(false);
const [bulkEditItems, setBulkEditItems] = useState<SubmissionItemWithDeps[]>([]);
const [activeLocksCount, setActiveLocksCount] = useState(0);
const [lockRestored, setLockRestored] = useState(false);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
title: string;
description: string;
onConfirm: () => void;
}>({
open: false,
title: '',
description: '',
onConfirm: () => {},
});
// Keyboard shortcuts help dialog
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
// Offline detection state
const [isOffline, setIsOffline] = useState(!navigator.onLine);
// Persist transaction statuses to localStorage
useEffect(() => {
localStorage.setJSON('moderation-queue-transaction-statuses', transactionStatuses);
}, [transactionStatuses]);
// Offline detection effect
useEffect(() => {
const handleOnline = () => {
setIsOffline(false);
toast({
title: 'Connection Restored',
description: 'You are back online. Refreshing queue...',
});
queueManager.refresh();
};
const handleOffline = () => {
setIsOffline(true);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [queueManager, toast]);
// Auto-dismiss lock restored banner after 10 seconds
useEffect(() => {
if (lockRestored && queueManager.queue.currentLock) {
const timer = setTimeout(() => {
setLockRestored(false);
}, 10000); // Auto-dismiss after 10 seconds
return () => clearTimeout(timer);
}
}, [lockRestored, queueManager.queue.currentLock]);
// Fetch active locks count for superusers
const isSuperuserValue = isSuperuser();
useEffect(() => {
if (!isSuperuserValue) return;
const fetchActiveLocksCount = async () => {
const { count } = await supabase
.from('content_submissions')
.select('id', { count: 'exact', head: true })
.not('assigned_to', 'is', null)
.gt('locked_until', new Date().toISOString());
setActiveLocksCount(count || 0);
};
fetchActiveLocksCount();
// Refresh count periodically
const interval = setInterval(fetchActiveLocksCount, 30000); // Every 30s
return () => clearInterval(interval);
}, [isSuperuserValue]);
// Track if lock was restored from database
useEffect(() => {
if (!initialLoadComplete) {
setInitialLoadComplete(true);
return;
}
if (queueManager.queue.currentLock && !lockRestored) {
// If we have a lock after initial load but haven't claimed in this session
setLockRestored(true);
}
}, [queueManager.queue.currentLock, lockRestored, initialLoadComplete]);
// Virtual scrolling setup
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: queueManager.items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 420, // Estimated average height of QueueItem (card + spacing)
overscan: 3, // Render 3 items above/below viewport for smoother scrolling
enabled: queueManager.items.length > 10, // Only enable virtual scrolling for 10+ items
});
// UI-specific handlers
const handleNoteChange = (id: string, value: string) => {
setNotes(prev => ({ ...prev, [id]: value }));
};
// Transaction status helpers
const setTransactionStatus = useCallback((submissionId: string, status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed', message?: string) => {
setTransactionStatuses(prev => ({
...prev,
[submissionId]: { status, message }
}));
// Auto-clear completed/failed statuses after 5 seconds
if (status === 'completed' || status === 'failed') {
setTimeout(() => {
setTransactionStatuses(prev => {
const updated = { ...prev };
if (updated[submissionId]?.status === status) {
updated[submissionId] = { status: 'idle' };
}
return updated;
});
}, 5000);
}
}, []);
// Wrap performAction to track transaction status
const handlePerformAction = useCallback(async (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => {
setTransactionStatus(item.id, 'processing');
try {
await queueManager.performAction(item, action, notes);
setTransactionStatus(item.id, 'completed');
} catch (error: any) {
// Check for timeout
if (error?.type === 'timeout' || error?.message?.toLowerCase().includes('timeout')) {
setTransactionStatus(item.id, 'timeout', error.message);
}
// Check for cached/409
else if (error?.status === 409 || error?.message?.toLowerCase().includes('duplicate')) {
setTransactionStatus(item.id, 'cached', 'Using cached result from duplicate request');
}
// Generic failure
else {
setTransactionStatus(item.id, 'failed', error.message);
}
throw error; // Re-throw to allow normal error handling
}
}, [queueManager, setTransactionStatus]);
// Wrapped delete with confirmation
const handleDeleteSubmission = useCallback((item: ModerationItem) => {
setConfirmDialog({
open: true,
title: 'Delete Submission',
description: 'Are you sure you want to permanently delete this submission? This action cannot be undone.',
onConfirm: () => queueManager.deleteSubmission(item),
});
}, [queueManager]);
// Superuser force release lock
const handleSuperuserReleaseLock = useCallback(async (submissionId: string) => {
await queueManager.queue.superuserReleaseLock(submissionId);
// Refresh locks count and queue
setActiveLocksCount(prev => Math.max(0, prev - 1));
queueManager.refresh();
}, [queueManager]);
// Superuser clear all locks
const handleClearAllLocks = useCallback(async () => {
const count = await queueManager.queue.superuserReleaseAllLocks();
setActiveLocksCount(0);
// Force queue refresh
queueManager.refresh();
}, [queueManager]);
// Clear filters handler
const handleClearFilters = useCallback(() => {
queueManager.filters.clearFilters();
}, [queueManager.filters]);
// Keyboard shortcuts
const { shortcuts } = useKeyboardShortcuts({
shortcuts: [
{
key: '?',
handler: () => setShowShortcutsHelp(true),
description: 'Show keyboard shortcuts',
},
{
key: 'r',
handler: () => queueManager.refresh(),
description: 'Refresh queue',
},
{
key: 'k',
ctrlOrCmd: true,
handler: () => {
// Focus search/filter (if implemented)
document.querySelector<HTMLInputElement>('[data-filter-search]')?.focus();
},
description: 'Focus filters',
},
{
key: 'e',
handler: () => {
// Edit first claimed submission
const claimedItem = queueManager.items.find(item =>
queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until)
);
if (claimedItem) {
handleOpenItemEditor(claimedItem.id);
}
},
description: 'Edit claimed submission',
},
],
enabled: true,
});
const handleOpenPhotos = (photos: PhotoItem[], index: number) => {
setSelectedPhotos(photos);
setSelectedPhotoIndex(index);
setPhotoModalOpen(true);
};
const handleOpenReviewManager = (submissionId: string) => {
setSelectedSubmissionId(submissionId);
setReviewManagerOpen(true);
};
const handleOpenItemEditor = async (submissionId: string) => {
try {
const items = await fetchSubmissionItems(submissionId);
if (!items || items.length === 0) {
toast({
title: 'No Items Found',
description: 'This submission has no items to edit',
variant: 'destructive',
});
return;
}
// Phase 3: Multi-item selector for submissions with multiple items
if (items.length > 1) {
setAvailableItems(items);
setShowItemSelector(true);
} else {
// Single item - edit directly
setEditingItem(items[0]);
setShowItemEditDialog(true);
}
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
}
};
const handleSelectItem = (item: SubmissionItemWithDeps) => {
setEditingItem(item);
setShowItemSelector(false);
setShowItemEditDialog(true);
};
const handleBulkEdit = async (submissionId: string) => {
try {
const items = await fetchSubmissionItems(submissionId);
if (!items || items.length === 0) {
toast({
title: 'No Items Found',
description: 'This submission has no items to edit',
variant: 'destructive',
});
return;
}
setBulkEditItems(items);
setBulkEditMode(true);
setShowItemEditDialog(true);
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
}
};
// Expose imperative API
useImperativeHandle(ref, () => ({
refresh: async () => {
await queueManager.refresh();
}
}));
return (
<div className="space-y-4">
{/* Offline Banner */}
{isOffline && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>No Internet Connection</AlertTitle>
<AlertDescription>
You're offline. The moderation queue will automatically sync when your connection is restored.
</AlertDescription>
</Alert>
)}
{/* Queue Statistics & Lock Status */}
{queueManager.queue.queueStats && (
<Card className="bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<QueueStats stats={queueManager.queue.queueStats} isMobile={isMobile} />
<EnhancedLockStatusDisplay
currentLock={queueManager.queue.currentLock}
queueStats={queueManager.queue.queueStats}
loading={queueManager.queue.isLoading}
onExtendLock={() => queueManager.queue.extendLock(queueManager.queue.currentLock?.submissionId || '')}
onReleaseLock={() => queueManager.queue.releaseLock(queueManager.queue.currentLock?.submissionId || '', false)}
getCurrentTime={() => new Date()}
/>
</div>
</CardContent>
</Card>
)}
{/* Superuser Queue Controls */}
{isSuperuser() && (
<SuperuserQueueControls
activeLocksCount={activeLocksCount}
onClearAllLocks={handleClearAllLocks}
isLoading={queueManager.queue.isLoading}
/>
)}
{/* Lock Restored Alert */}
{lockRestored && queueManager.queue.currentLock && (() => {
// Check if restored submission is in current queue
const restoredSubmissionInQueue = queueManager.items.some(
item => item.id === queueManager.queue.currentLock?.submissionId
);
if (!restoredSubmissionInQueue) return null;
// Calculate time remaining
const timeRemainingMs = queueManager.queue.currentLock.expiresAt.getTime() - Date.now();
const timeRemainingSec = Math.max(0, Math.floor(timeRemainingMs / 1000));
const isExpiringSoon = timeRemainingSec < 300; // Less than 5 minutes
return (
<Alert className={isExpiringSoon
? "border-orange-500/50 bg-orange-500/10"
: "border-blue-500/50 bg-blue-500/5"
}>
<Info className={isExpiringSoon
? "h-4 w-4 text-orange-600"
: "h-4 w-4 text-blue-600"
} />
<AlertTitle>
{isExpiringSoon
? `Lock Expiring Soon (${Math.floor(timeRemainingSec / 60)}m ${timeRemainingSec % 60}s)`
: "Active Claim Restored"
}
</AlertTitle>
<AlertDescription>
{isExpiringSoon
? "Your lock is about to expire. Complete your review or extend the lock."
: "Your previous claim was restored. You still have time to review this submission."
}
</AlertDescription>
</Alert>
);
})()}
{/* Filter Bar */}
<QueueFilters
activeEntityFilter={queueManager.filters.entityFilter}
activeStatusFilter={queueManager.filters.statusFilter}
sortConfig={queueManager.filters.sortConfig}
isMobile={isMobile ?? false}
isLoading={queueManager.loadingState === 'loading'}
onEntityFilterChange={queueManager.filters.setEntityFilter}
onStatusFilterChange={queueManager.filters.setStatusFilter}
onSortChange={queueManager.filters.setSortConfig}
onClearFilters={queueManager.filters.clearFilters}
showClearButton={queueManager.filters.hasActiveFilters}
onRefresh={queueManager.refresh}
isRefreshing={queueManager.loadingState === 'refreshing'}
/>
{/* Active Filters Display */}
{queueManager.filters.hasActiveFilters && (
<ActiveFiltersDisplay
entityFilter={queueManager.filters.entityFilter}
statusFilter={queueManager.filters.statusFilter}
/>
)}
{/* Auto-refresh Indicator */}
{adminSettings.getAdminPanelRefreshMode() === 'auto' && (
<AutoRefreshIndicator
enabled={true}
intervalSeconds={Math.round(adminSettings.getAdminPanelPollInterval() / 1000)}
mode={adminSettings.getUseRealtimeQueue() ? 'realtime' : 'polling'}
/>
)}
{/* New Items Alert */}
{queueManager.newItemsCount > 0 && (
<NewItemsAlert
count={queueManager.newItemsCount}
onShowNewItems={queueManager.showNewItems}
/>
)}
{/* Queue Content */}
{queueManager.loadingState === 'loading' || queueManager.loadingState === 'initial' ? (
<QueueSkeleton count={queueManager.pagination.pageSize} />
) : queueManager.items.length === 0 ? (
<EnhancedEmptyState
entityFilter={queueManager.filters.entityFilter}
statusFilter={queueManager.filters.statusFilter}
onClearFilters={queueManager.filters.hasActiveFilters ? handleClearFilters : undefined}
/>
) : (
<TooltipProvider>
{queueManager.items.length <= 10 ? (
// Standard rendering for small lists (no virtual scrolling overhead)
<div className="space-y-6">
{queueManager.items.map((item) => (
<ModerationErrorBoundary key={item.id} submissionId={item.id}>
<QueueItem
item={item}
isMobile={isMobile ?? false}
actionLoading={queueManager.actionLoading}
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to || null, item.locked_until || null)}
isLockedByOther={queueManager.queue.isLockedByOther(item.id, item.assigned_to || null, item.locked_until || null)}
lockStatus={getLockStatus({ assigned_to: item.assigned_to || null, locked_until: item.locked_until || null }, user?.id || '')}
currentLockSubmissionId={queueManager.queue.currentLock?.submissionId}
notes={notes}
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange}
onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos}
onOpenReviewManager={handleOpenReviewManager}
onOpenItemEditor={handleOpenItemEditor}
onClaimSubmission={queueManager.queue.claimSubmission}
onDeleteSubmission={handleDeleteSubmission}
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined}
/>
</ModerationErrorBoundary>
))}
</div>
) : (
// Virtual scrolling for large lists (10+ items)
<div
ref={parentRef}
className="overflow-auto"
style={{
height: '70vh',
contain: 'strict',
}}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = queueManager.items[virtualItem.index];
return (
<div
key={item.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
className="pb-6"
>
<ModerationErrorBoundary submissionId={item.id}>
<QueueItem
item={item}
isMobile={isMobile ?? false}
actionLoading={queueManager.actionLoading}
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to || null, item.locked_until || null)}
isLockedByOther={queueManager.queue.isLockedByOther(item.id, item.assigned_to || null, item.locked_until || null)}
lockStatus={getLockStatus({ assigned_to: item.assigned_to || null, locked_until: item.locked_until || null }, user?.id || '')}
currentLockSubmissionId={queueManager.queue.currentLock?.submissionId}
notes={notes}
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange}
onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos}
onOpenReviewManager={handleOpenReviewManager}
onOpenItemEditor={handleOpenItemEditor}
onClaimSubmission={queueManager.queue.claimSubmission}
onDeleteSubmission={handleDeleteSubmission}
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined}
/>
</ModerationErrorBoundary>
</div>
);
})}
</div>
</div>
)}
</TooltipProvider>
)}
{/* Pagination */}
{queueManager.loadingState === 'ready' && queueManager.pagination.totalPages > 1 && (
<QueuePagination
currentPage={queueManager.pagination.currentPage}
totalPages={queueManager.pagination.totalPages}
pageSize={queueManager.pagination.pageSize}
totalCount={queueManager.pagination.totalCount}
isMobile={isMobile ?? false}
onPageChange={queueManager.pagination.setCurrentPage}
onPageSizeChange={queueManager.pagination.setPageSize}
/>
)}
{/* Modals */}
<PhotoModal
photos={selectedPhotos.map(photo => ({
...photo,
caption: photo.caption ?? undefined
}))}
initialIndex={selectedPhotoIndex}
isOpen={photoModalOpen}
onClose={() => setPhotoModalOpen(false)}
/>
{selectedSubmissionId && (
<SubmissionReviewManager
submissionId={selectedSubmissionId}
open={reviewManagerOpen}
onOpenChange={setReviewManagerOpen}
onComplete={() => {
queueManager.refresh();
setSelectedSubmissionId(null);
}}
/>
)}
{/* Phase 3: Item Selector Dialog */}
<ItemSelectorDialog
items={availableItems}
open={showItemSelector}
onOpenChange={setShowItemSelector}
onSelectItem={handleSelectItem}
onBulkEdit={() => {
setShowItemSelector(false);
setBulkEditItems(availableItems);
setBulkEditMode(true);
setShowItemEditDialog(true);
}}
/>
{/* Phase 4 & 5: Enhanced Item Edit Dialog */}
<ItemEditDialog
item={bulkEditMode ? null : editingItem}
items={bulkEditMode ? bulkEditItems : undefined}
open={showItemEditDialog}
onOpenChange={(open) => {
setShowItemEditDialog(open);
if (!open) {
setEditingItem(null);
setBulkEditMode(false);
setBulkEditItems([]);
}
}}
onComplete={() => {
queueManager.refresh();
setEditingItem(null);
setBulkEditMode(false);
setBulkEditItems([]);
}}
/>
{/* Confirmation Dialog */}
<ConfirmationDialog
open={confirmDialog.open}
onOpenChange={(open) => setConfirmDialog(prev => ({ ...prev, open }))}
title={confirmDialog.title}
description={confirmDialog.description}
onConfirm={confirmDialog.onConfirm}
variant="destructive"
confirmLabel="Delete"
/>
{/* Keyboard Shortcuts Help */}
<KeyboardShortcutsHelp
open={showShortcutsHelp}
onOpenChange={setShowShortcutsHelp}
shortcuts={shortcuts}
/>
</div>
);
});
ModerationQueue.displayName = 'ModerationQueue';
export type { ModerationQueueRef } from '@/types/moderation';

View File

@@ -0,0 +1,29 @@
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { ShowNewItemsButton } from './show-new-items-button';
interface NewItemsAlertProps {
count: number;
onShowNewItems: () => void;
visible?: boolean;
}
export const NewItemsAlert = ({ count, onShowNewItems, visible = true }: NewItemsAlertProps) => {
if (!visible || count === 0) return null;
return (
<div className="sticky top-0 z-10 animate-in fade-in-50">
<Alert className="border-primary/50 bg-primary/5">
<AlertCircle className="h-4 w-4 animate-pulse" />
<AlertTitle>New Items Available</AlertTitle>
<AlertDescription className="flex items-center justify-between">
<span>{count} new {count === 1 ? 'submission' : 'submissions'} pending review</span>
<ShowNewItemsButton
count={count}
onShow={onShowNewItems}
/>
</AlertDescription>
</Alert>
</div>
);
};

View File

@@ -0,0 +1,192 @@
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;
entity_type?: string;
entity_name?: string;
deletion_reason?: string;
};
compact?: boolean;
}
export function PhotoDeletionPreview({ photo, compact = false }: PhotoDeletionPreviewProps) {
if (compact) {
return (
<div className="flex items-center gap-2 p-2 rounded-md bg-destructive/10 border border-destructive/20">
<Trash2 className="h-4 w-4 text-destructive" />
<span className="text-sm font-medium text-destructive">Delete Photo</span>
{photo.deletion_reason && (
<span className="text-xs text-muted-foreground">- {photo.deletion_reason}</span>
)}
</div>
);
}
return (
<div className="flex flex-col gap-3 p-4 rounded-lg bg-destructive/10 border-2 border-destructive/30">
<div className="flex items-center gap-2 text-destructive font-semibold">
<Trash2 className="h-5 w-5" />
<span>Photo Deletion Request</span>
</div>
<div className="flex gap-4">
{photo.url && (
<img
src={photo.url}
alt={photo.title || photo.caption || 'Photo to be deleted'}
className="w-48 h-48 object-cover rounded-lg border-2 border-destructive/40 shadow-lg"
loading="lazy"
/>
)}
<div className="flex-1 space-y-3">
{photo.title && (
<div>
<span className="text-xs font-medium text-muted-foreground">Title:</span>
<div className="font-medium">{photo.title}</div>
</div>
)}
{photo.caption && (
<div>
<span className="text-xs font-medium text-muted-foreground">Caption:</span>
<div className="text-sm">{photo.caption}</div>
</div>
)}
{photo.entity_type && photo.entity_name && (
<div>
<span className="text-xs font-medium text-muted-foreground">From Entity:</span>
<div className="text-sm">
<span className="capitalize">{photo.entity_type.replace('_', ' ')}</span> - <span className="font-medium">{photo.entity_name}</span>
</div>
</div>
)}
{photo.deletion_reason && (
<div className="p-3 bg-destructive/20 rounded-md border border-destructive/30">
<span className="text-xs font-bold text-destructive uppercase">Deletion Reason:</span>
<p className="mt-1 text-sm font-medium">{photo.deletion_reason}</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useEffect, useRef } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import { useIsMobile } from '@/hooks/use-mobile';
interface PhotoModalProps {
photos: Array<{
id: string;
url: string;
filename?: string;
caption?: string;
}>;
initialIndex: number;
isOpen: boolean;
onClose: () => void;
}
export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModalProps) {
const isMobile = useIsMobile();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
// Safety check: ensure photos array exists and is not empty
if (!photos || photos.length === 0) {
return null;
}
// Clamp currentIndex to valid bounds
const safeIndex = Math.max(0, Math.min(currentIndex, photos.length - 1));
const currentPhoto = photos[safeIndex];
// Early return if currentPhoto is undefined
if (!currentPhoto) {
return null;
}
// Minimum swipe distance (in px)
const minSwipeDistance = 50;
const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') goToPrevious();
if (e.key === 'ArrowRight') goToNext();
if (e.key === 'Escape') onClose();
};
const onTouchStart = (e: React.TouchEvent) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const onTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
goToNext();
} else if (isRightSwipe) {
goToPrevious();
}
};
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={`max-w-7xl w-full p-0 [&>button]:hidden ${isMobile ? 'max-h-screen h-screen' : 'max-h-[90vh]'}`} aria-describedby={undefined}>
<VisuallyHidden>
<DialogTitle>Photo Viewer</DialogTitle>
</VisuallyHidden>
<div
className="relative bg-black rounded-lg overflow-hidden touch-none"
onKeyDown={handleKeyDown}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
tabIndex={0}
>
{/* Close button */}
<Button
variant="ghost"
size={isMobile ? "default" : "sm"}
onClick={onClose}
className={`absolute z-20 text-white hover:bg-white/10 ${isMobile ? 'top-2 right-2 h-10 w-10 p-0' : 'top-4 right-4'}`}
>
<X className={isMobile ? "h-5 w-5" : "h-4 w-4"} />
</Button>
{/* Header */}
<div className={`absolute top-0 left-0 right-0 z-10 bg-gradient-to-b from-black/80 to-transparent ${isMobile ? 'p-3' : 'p-4'}`}>
<div className="text-white">
{currentPhoto?.caption && (
<h3 className={`font-medium ${isMobile ? 'text-sm pr-12' : ''}`}>
{currentPhoto.caption}
</h3>
)}
{photos.length > 1 && (
<p className={`text-white/70 ${isMobile ? 'text-xs' : 'text-sm'}`}>
{safeIndex + 1} of {photos.length}
</p>
)}
</div>
</div>
{/* Image */}
<div className={`flex items-center justify-center ${isMobile ? 'min-h-screen' : 'min-h-[400px] max-h-[80vh]'}`}>
<img
ref={imageRef}
src={currentPhoto?.url}
alt={currentPhoto?.caption || `Photo ${safeIndex + 1}`}
className="max-w-full max-h-full object-contain select-none"
loading="lazy"
draggable={false}
/>
</div>
{/* Navigation */}
{photos.length > 1 && !isMobile && (
<>
<Button
variant="ghost"
size="sm"
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/10"
>
<ChevronLeft className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/10"
>
<ChevronRight className="h-6 w-6" />
</Button>
</>
)}
{/* Mobile navigation dots */}
{photos.length > 1 && isMobile && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2 px-4">
{photos.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentIndex(idx)}
className={`h-2 rounded-full transition-all ${
idx === currentIndex
? 'w-8 bg-white'
: 'w-2 bg-white/50'
}`}
aria-label={`Go to photo ${idx + 1}`}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { PhotoGrid } from '@/components/common/PhotoGrid';
import type { PhotoSubmissionItem } from '@/types/photo-submissions';
import type { PhotoItem } from '@/types/photos';
import { getErrorMessage } from '@/lib/errorHandler';
interface PhotoSubmissionDisplayProps {
submissionId: string;
}
export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) {
const [photos, setPhotos] = useState<PhotoSubmissionItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPhotos();
}, [submissionId]);
const fetchPhotos = async () => {
try {
// Step 1: Get photo_submission_id from submission_id
const { data: photoSubmission, error: photoSubmissionError } = await supabase
.from('photo_submissions')
.select('id, entity_type, title')
.eq('submission_id', submissionId)
.maybeSingle();
if (photoSubmissionError) {
throw photoSubmissionError;
}
if (!photoSubmission) {
setPhotos([]);
setLoading(false);
return;
}
// Step 2: Get photo items using photo_submission_id
const { data, error } = await supabase
.from('photo_submission_items')
.select('*')
.eq('photo_submission_id', photoSubmission.id)
.order('order_index');
if (error) {
throw error;
}
setPhotos(data || []);
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
setPhotos([]);
setError(errorMsg);
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="text-sm text-muted-foreground">Loading photos...</div>;
}
if (error) {
return (
<div className="text-sm text-destructive">
Error loading photos: {error}
<br />
<span className="text-xs">Submission ID: {submissionId}</span>
</div>
);
}
if (photos.length === 0) {
return (
<div className="text-sm text-muted-foreground">
No photos found for this submission
<br />
<span className="text-xs text-muted-foreground/60">ID: {submissionId}</span>
</div>
);
}
// Convert PhotoSubmissionItem[] to PhotoItem[] for PhotoGrid
const photoItems: PhotoItem[] = photos.map(photo => ({
id: photo.id,
url: photo.cloudflare_image_url,
filename: photo.filename || `Photo ${photo.order_index + 1}`,
caption: photo.caption,
title: photo.title,
date_taken: photo.date_taken,
}));
return <PhotoGrid photos={photoItems} />;
}

View File

@@ -0,0 +1,484 @@
import { useState, useEffect } from 'react';
import { Search, Shield, Trash2, Ban, AlertTriangle } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole, UserRole } from '@/hooks/useUserRole';
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
import { AdminUserDeletionDialog } from '@/components/admin/AdminUserDeletionDialog';
import { BanUserDialog } from '@/components/admin/BanUserDialog';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { handleError, handleSuccess, handleNonCriticalError, getErrorMessage } from '@/lib/errorHandler';
interface UserProfile {
id: string;
user_id: string;
username: string;
email?: string;
display_name?: string;
avatar_url?: string;
banned: boolean;
created_at: string;
roles: UserRole[];
}
export function ProfileManager() {
const { user } = useAuth();
const { permissions, loading: roleLoading } = useUserRole();
const [profiles, setProfiles] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'banned'>('all');
const [roleFilter, setRoleFilter] = useState<'all' | UserRole>('all');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [deletionTarget, setDeletionTarget] = useState<UserProfile | null>(null);
const superuserGuard = useSuperuserGuard();
useEffect(() => {
if (!roleLoading && permissions?.can_view_all_profiles) {
fetchProfiles();
}
}, [roleLoading, permissions]);
const fetchProfiles = async () => {
try {
setLoading(true);
// Fetch profiles with emails using secure RPC function
const { data: profilesData, error: profilesError } = await supabase
.rpc('get_users_with_emails');
if (profilesError) throw profilesError;
// Fetch roles for each user
const profilesWithRoles = await Promise.all(
(profilesData || []).map(async (profile) => {
const { data: rolesData } = await supabase
.from('user_roles')
.select('role')
.eq('user_id', profile.user_id);
return {
...profile,
roles: rolesData?.map(r => r.role as UserRole) || []
};
})
);
setProfiles(profilesWithRoles);
} catch (error: unknown) {
handleError(error, {
action: 'Load User Profiles',
userId: user?.id
});
} finally {
setLoading(false);
}
};
const handleBanUser = async (
targetUserId: string,
ban: boolean,
banReason?: string,
banExpiresAt?: Date | null
) => {
if (!user || !permissions) return;
setActionLoading(targetUserId);
try {
// Prepare update data
interface ProfileUpdateData {
banned: boolean;
ban_reason?: string | null;
ban_expires_at?: string | null;
}
const updateData: ProfileUpdateData = { banned: ban };
if (ban && banReason) {
updateData.ban_reason = banReason;
updateData.ban_expires_at = banExpiresAt ? banExpiresAt.toISOString() : null;
} else if (!ban) {
// Clear ban data when unbanning
updateData.ban_reason = null;
updateData.ban_expires_at = null;
}
// Update banned status
const { error: updateError } = await supabase
.from('profiles')
.update(updateData)
.eq('user_id', targetUserId);
if (updateError) throw updateError;
// Log the action
const { error: logError } = await supabase
.rpc('log_admin_action', {
_admin_user_id: user.id,
_target_user_id: targetUserId,
_action: ban ? 'ban_user' : 'unban_user',
_details: {
banned: ban,
ban_reason: banReason,
ban_expires_at: banExpiresAt?.toISOString()
}
});
if (logError) {
handleNonCriticalError(logError, {
action: 'Log admin action (ban/unban)',
userId: user?.id,
metadata: { targetUserId, ban, banReason }
});
}
handleSuccess(
'Success',
ban
? 'User banned successfully. They have been signed out and cannot access the application.'
: 'User unbanned successfully. They can now access the application normally.'
);
// Refresh profiles
fetchProfiles();
} catch (error: unknown) {
handleError(error, {
action: `${ban ? 'Ban' : 'Unban'} User`,
userId: user?.id,
metadata: { targetUserId, ban, banReason, banExpiresAt }
});
} finally {
setActionLoading(null);
}
};
const handleRoleChange = async (targetUserId: string, newRole: UserRole | 'remove', currentRoles: UserRole[]) => {
if (!user || !permissions) return;
setActionLoading(targetUserId);
try {
if (newRole === 'remove') {
// Remove all roles except superuser (which can't be removed via UI)
const rolesToRemove = currentRoles.filter(role => role !== 'superuser');
for (const role of rolesToRemove) {
const { error } = await supabase
.from('user_roles')
.delete()
.eq('user_id', targetUserId)
.eq('role', role);
if (error) throw error;
}
} else {
// Check permissions before allowing role assignment
if (newRole === 'admin' && !permissions.can_manage_admin_roles) {
handleError(new Error('Insufficient permissions'), {
action: 'Assign Admin Role',
userId: user?.id,
metadata: { targetUserId, attemptedRole: newRole }
});
return;
}
if (newRole === 'superuser') {
handleError(new Error('Cannot assign superuser via UI'), {
action: 'Assign Superuser Role',
userId: user?.id,
metadata: { targetUserId }
});
return;
}
// Add new role
const { error } = await supabase
.from('user_roles')
.upsert({
user_id: targetUserId,
role: newRole,
granted_by: user.id
});
if (error) throw error;
}
// Log the action
const { error: logError } = await supabase
.rpc('log_admin_action', {
_admin_user_id: user.id,
_target_user_id: targetUserId,
_action: newRole === 'remove' ? 'remove_roles' : 'assign_role',
_details: { role: newRole, previous_roles: currentRoles }
});
if (logError) {
handleNonCriticalError(logError, {
action: 'Log admin action (role change)',
userId: user?.id,
metadata: { targetUserId, newRole, previousRoles: currentRoles }
});
}
handleSuccess('Success', 'User role updated successfully.');
// Refresh profiles
fetchProfiles();
} catch (error: unknown) {
handleError(error, {
action: 'Update User Role',
userId: user?.id,
metadata: { targetUserId, newRole, previousRoles: currentRoles }
});
} finally {
setActionLoading(null);
}
};
// Check if current superuser can delete a specific user
const canDeleteUser = (targetProfile: UserProfile) => {
if (!superuserGuard.isSuperuser) return false;
if (!superuserGuard.canPerformAction) return false;
// Cannot delete other superusers
if (targetProfile.roles.includes('superuser')) return false;
// Cannot delete self
if (targetProfile.user_id === user?.id) return false;
return true;
};
const canManageUser = (targetProfile: UserProfile) => {
if (!permissions) return false;
// Superuser can manage anyone except other superusers
if (permissions.role_level === 'superuser') {
return !targetProfile.roles.includes('superuser');
}
// Admin can manage moderators and users, but not admins or superusers
if (permissions.role_level === 'admin') {
return !targetProfile.roles.some(role => ['admin', 'superuser'].includes(role));
}
// Moderator can only ban users with no roles or user role
if (permissions.role_level === 'moderator') {
return targetProfile.roles.length === 0 ||
(targetProfile.roles.length === 1 && targetProfile.roles[0] === 'user');
}
return false;
};
const filteredProfiles = profiles.filter(profile => {
const matchesSearch = profile.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
profile.display_name?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
(statusFilter === 'banned' && profile.banned) ||
(statusFilter === 'active' && !profile.banned);
const matchesRole = roleFilter === 'all' ||
profile.roles.includes(roleFilter as UserRole) ||
(roleFilter === 'user' && profile.roles.length === 0);
return matchesSearch && matchesStatus && matchesRole;
});
if (roleLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading permissions...</p>
</div>
</div>
);
}
if (!permissions?.can_view_all_profiles) {
return (
<div className="text-center py-8">
<Shield className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Access Denied</h3>
<p className="text-muted-foreground">
You don't have permission to manage user profiles.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={(value: 'all' | 'active' | 'banned') => setStatusFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="banned">Banned</SelectItem>
</SelectContent>
</Select>
<Select value={roleFilter} onValueChange={(value: 'all' | UserRole) => setRoleFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="superuser">Superuser</SelectItem>
</SelectContent>
</Select>
</div>
{/* Users List */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<div className="grid gap-4">
{filteredProfiles.map((profile) => (
<Card key={profile.id}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Avatar className="w-12 h-12">
<AvatarImage src={profile.avatar_url} alt={profile.username} />
<AvatarFallback>
{profile.display_name?.[0] || profile.username[0]}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{profile.display_name || profile.username}</h3>
{profile.banned && (
<Badge variant="destructive" className="text-xs">
<Ban className="w-3 h-3 mr-1" />
Banned
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">@{profile.username}</p>
<div className="flex gap-2 mt-1">
{profile.roles.length > 0 ? (
profile.roles.map((role) => (
<Badge key={role} variant="secondary" className="text-xs">
{role}
</Badge>
))
) : (
<Badge variant="outline" className="text-xs">User</Badge>
)}
</div>
</div>
</div>
{(canManageUser(profile) || canDeleteUser(profile)) && (
<div className="flex items-center gap-2">
{/* Ban/Unban Button */}
{canManageUser(profile) && permissions.can_ban_any_user && (
<BanUserDialog
profile={profile}
onBanComplete={fetchProfiles}
onBanUser={handleBanUser}
disabled={actionLoading === profile.user_id}
/>
)}
{/* Delete User Button - Superusers Only */}
{canDeleteUser(profile) && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeletionTarget(profile)}
disabled={actionLoading === profile.user_id}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete User
</Button>
)}
{/* Role Management */}
{canManageUser(profile) && (permissions.can_manage_moderator_roles || permissions.can_manage_admin_roles) && (
<Select
onValueChange={(value) => handleRoleChange(profile.user_id, value as UserRole | 'remove', profile.roles)}
disabled={actionLoading === profile.user_id}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Change Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Make User</SelectItem>
{permissions.can_manage_moderator_roles && (
<SelectItem value="moderator">Make Moderator</SelectItem>
)}
{permissions.can_manage_admin_roles && (
<SelectItem value="admin">Make Admin</SelectItem>
)}
<SelectItem value="remove">Remove Roles</SelectItem>
</SelectContent>
</Select>
)}
</div>
)}
</div>
</CardContent>
</Card>
))}
{filteredProfiles.length === 0 && (
<div className="text-center py-8">
<AlertTriangle className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No Users Found</h3>
<p className="text-muted-foreground">
No users match your current filters.
</p>
</div>
)}
</div>
)}
{/* User Deletion Dialog */}
{deletionTarget && (
<AdminUserDeletionDialog
open={!!deletionTarget}
onOpenChange={(open) => !open && setDeletionTarget(null)}
targetUser={{
userId: deletionTarget.user_id,
username: deletionTarget.username,
email: deletionTarget.email || 'Email not found',
displayName: deletionTarget.display_name || undefined,
roles: deletionTarget.roles
}}
onDeletionComplete={() => {
setDeletionTarget(null);
fetchProfiles();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,223 @@
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { RefreshButton } from '@/components/ui/refresh-button';
import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
interface QueueFiltersProps {
activeEntityFilter: EntityFilter;
activeStatusFilter: StatusFilter;
sortConfig: SortConfig;
isMobile: boolean;
isLoading?: boolean;
onEntityFilterChange: (filter: EntityFilter) => void;
onStatusFilterChange: (filter: StatusFilter) => void;
onSortChange: (config: SortConfig) => void;
onClearFilters: () => void;
showClearButton: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
}
const getEntityFilterIcon = (filter: EntityFilter) => {
switch (filter) {
case 'reviews': return <MessageSquare className="w-4 h-4" />;
case 'submissions': return <FileText className="w-4 h-4" />;
case 'photos': return <Image className="w-4 h-4" />;
default: return <Filter className="w-4 h-4" />;
}
};
export const QueueFilters = ({
activeEntityFilter,
activeStatusFilter,
sortConfig,
isMobile,
isLoading = false,
onEntityFilterChange,
onStatusFilterChange,
onSortChange,
onClearFilters,
showClearButton,
onRefresh,
isRefreshing = false
}: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState();
// Count active filters
const activeFilterCount = [
activeEntityFilter !== 'all' ? 1 : 0,
activeStatusFilter !== 'all' ? 1 : 0,
].reduce((sum, val) => sum + val, 0);
return (
<div className={`bg-muted/50 rounded-lg transition-all duration-250 ${isMobile ? 'p-3' : 'p-4'}`}>
<Collapsible open={!isCollapsed} onOpenChange={() => toggle()}>
{/* Header with collapse trigger on mobile */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
{isCollapsed && activeFilterCount > 0 && (
<Badge variant="secondary" className="text-xs">
{activeFilterCount} active
</Badge>
)}
</div>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
</Button>
</CollapsibleTrigger>
)}
</div>
<CollapsibleContent className="space-y-4">
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
{/* Entity Type Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Select
value={activeEntityFilter}
onValueChange={onEntityFilterChange}
>
<SelectTrigger
id="entity-filter"
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by entity type"
>
<SelectValue>
<div className="flex items-center gap-2">
{getEntityFilterIcon(activeEntityFilter)}
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
All Items
</div>
</SelectItem>
<SelectItem value="reviews">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Reviews
</div>
</SelectItem>
<SelectItem value="submissions">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Submissions
</div>
</SelectItem>
<SelectItem value="photos">
<div className="flex items-center gap-2">
<Image className="w-4 h-4" />
Photos
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Select
value={activeStatusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger
id="status-filter"
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by submission status"
>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="partially_approved">Partially Approved</SelectItem>
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
<SelectItem value="flagged">Flagged</SelectItem>
)}
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
{/* Sort Controls */}
<QueueSortControls
sortConfig={sortConfig}
onSortChange={onSortChange}
isMobile={isMobile}
isLoading={isLoading}
/>
</div>
{/* Clear Filters & Apply Buttons (mobile only) */}
{isMobile && (
<div className="flex gap-2 pt-2 border-t border-border">
{showClearButton && (
<Button
variant="outline"
size="default"
onClick={onClearFilters}
className="flex-1 h-11 min-h-[44px]"
aria-label="Clear all filters"
>
<X className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
<Button
variant="default"
size="default"
onClick={() => toggle()}
className="flex-1 h-11 min-h-[44px]"
>
Apply
</Button>
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* Clear Filters & Manual Refresh (desktop only) */}
{!isMobile && (showClearButton || onRefresh) && (
<div className="flex items-center gap-2 pt-2">
{onRefresh && (
<RefreshButton
onRefresh={onRefresh}
isLoading={isRefreshing}
size="sm"
/>
)}
{showClearButton && (
<Button
variant="outline"
size="sm"
onClick={onClearFilters}
className="flex items-center gap-2"
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
Clear Filters
</Button>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,407 @@
import { memo, useState, useCallback, useMemo } from 'react';
import { usePhotoSubmissionItems } from '@/hooks/usePhotoSubmissionItems';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import type { ValidationResult } from '@/lib/entityValidationSchemas';
import type { LockStatus } from '@/lib/moderation/lockHelpers';
import type { ModerationItem, PhotoForDisplay } from '@/types/moderation';
import type { PhotoItem } from '@/types/photos';
import { handleError } from '@/lib/errorHandler';
import { PhotoGrid } from '@/components/common/PhotoGrid';
import { normalizePhotoData } from '@/lib/photoHelpers';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertTriangle } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { SubmissionItemsList } from './SubmissionItemsList';
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
import { QueueItemHeader } from './renderers/QueueItemHeader';
import { ReviewDisplay } from './renderers/ReviewDisplay';
import { PhotoSubmissionDisplay } from './renderers/PhotoSubmissionDisplay';
import { EntitySubmissionDisplay } from './renderers/EntitySubmissionDisplay';
import { QueueItemContext } from './renderers/QueueItemContext';
import { QueueItemActions } from './renderers/QueueItemActions';
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
import { AuditTrailViewer } from './AuditTrailViewer';
import { RawDataViewer } from './RawDataViewer';
interface QueueItemProps {
item: ModerationItem;
isMobile: boolean;
actionLoading: string | null;
isLockedByMe: boolean;
isLockedByOther: boolean;
lockStatus: LockStatus;
currentLockSubmissionId?: string;
notes: Record<string, string>;
isAdmin: boolean;
isSuperuser: boolean;
queueIsLoading: boolean;
isInitialRender?: boolean;
transactionStatuses?: Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>;
onNoteChange: (id: string, value: string) => void;
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
onResetToPending: (item: ModerationItem) => void;
onRetryFailed: (item: ModerationItem) => void;
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
onOpenReviewManager: (submissionId: string) => void;
onOpenItemEditor: (submissionId: string) => void;
onClaimSubmission: (submissionId: string) => void;
onDeleteSubmission: (item: ModerationItem) => void;
onInteractionFocus: (id: string) => void;
onInteractionBlur: (id: string) => void;
onSuperuserReleaseLock?: (submissionId: string) => Promise<void>;
}
export const QueueItem = memo(({
item,
isMobile,
actionLoading,
isLockedByMe,
isLockedByOther,
lockStatus,
currentLockSubmissionId,
notes,
isAdmin,
isSuperuser,
queueIsLoading,
isInitialRender = false,
transactionStatuses,
onNoteChange,
onApprove,
onResetToPending,
onRetryFailed,
onOpenPhotos,
onOpenReviewManager,
onOpenItemEditor,
onClaimSubmission,
onDeleteSubmission,
onInteractionFocus,
onInteractionBlur,
onSuperuserReleaseLock,
}: QueueItemProps) => {
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isClaiming, setIsClaiming] = useState(false);
const [showRawData, setShowRawData] = useState(false);
// Get transaction status from props or default to idle
const transactionState = transactionStatuses?.[item.id] || { status: 'idle' as const };
const transactionStatus = transactionState.status;
const transactionMessage = transactionState.message;
// Fetch relational photo data for photo submissions
const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems(
item.submission_type === 'photo' ? item.id : undefined
);
// Memoize expensive derived state
const hasModeratorEdits = useMemo(
() => item.submission_items?.some(
si => si.original_data && Object.keys(si.original_data).length > 0
),
[item.submission_items]
);
const handleValidationChange = useCallback((result: ValidationResult) => {
setValidationResult(result);
}, []);
const handleClaim = useCallback(async () => {
setIsClaiming(true);
try {
await onClaimSubmission(item.id);
// On success, component will re-render with new lock state
} catch (error: unknown) {
handleError(error, {
action: 'Claim Submission',
metadata: { submissionId: item.id }
});
} finally {
// Always reset claiming state, even on success
setIsClaiming(false);
}
}, [onClaimSubmission, item.id]);
return (
<Card
key={item.id}
className={`border-l-4 transition-all duration-300 ${
item._removing ? 'opacity-0 scale-95 pointer-events-none' : ''
} ${
hasModeratorEdits ? 'ring-2 ring-blue-200 dark:ring-blue-800' : ''
} ${
validationResult?.blockingErrors && validationResult.blockingErrors.length > 0 ? 'border-l-red-600' :
item.status === 'flagged' ? 'border-l-red-500' :
item.status === 'approved' ? 'border-l-green-500' :
item.status === 'rejected' ? 'border-l-red-400' :
item.status === 'partially_approved' ? 'border-l-yellow-500' :
'border-l-amber-500'
}`}
style={{
opacity: actionLoading === item.id ? 0.5 : (item._removing ? 0 : 1),
pointerEvents: actionLoading === item.id ? 'none' : 'auto',
transition: isInitialRender ? 'none' : 'all 300ms ease-in-out'
}}
data-testid="queue-item"
>
<CardHeader className={isMobile ? "pb-3 p-4" : "pb-4"}>
<QueueItemHeader
item={item}
isMobile={isMobile}
hasModeratorEdits={hasModeratorEdits ?? false}
isLockedByOther={isLockedByOther}
currentLockSubmissionId={currentLockSubmissionId}
validationResult={validationResult}
transactionStatus={transactionStatus}
transactionMessage={transactionMessage}
onValidationChange={handleValidationChange}
onViewRawData={() => setShowRawData(true)}
/>
</CardHeader>
<CardContent className={`${isMobile ? 'p-4 pt-0 space-y-4' : 'p-6 pt-0'}`}>
<div className={`bg-muted/50 rounded-lg ${isMobile ? 'p-3 space-y-3' : 'p-4'} ${
!isMobile
? item.type === 'content_submission'
? 'lg:grid lg:grid-cols-[1fr,320px] lg:gap-6 2xl:grid-cols-[1fr,400px,320px] 2xl:gap-6'
: 'lg:grid lg:grid-cols-[1fr,320px] lg:gap-6'
: ''
}`}>
{item.type === 'review' ? (
<div>
{item.content.title && (
<h4 className="font-semibold mb-2">{item.content.title}</h4>
)}
{item.content.content && (
<p className="text-sm mb-2">{item.content.content}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<span>Rating: {item.content.rating}/5</span>
</div>
{/* Entity Names for Reviews */}
{(item.entity_name || item.park_name) && (
<div className="space-y-1 mb-2">
{item.entity_name && (
<div className="text-sm text-muted-foreground">
<span className="text-xs">{item.park_name ? 'Ride:' : 'Park:'} </span>
<span className="text-base font-medium text-foreground">{item.entity_name}</span>
</div>
)}
{item.park_name && (
<div className="text-sm text-muted-foreground">
<span className="text-xs">Park: </span>
<span className="text-base font-medium text-foreground">{item.park_name}</span>
</div>
)}
</div>
)}
{/* Review photos are now in relational review_photos table, not JSONB */}
{item.review_photos && item.review_photos.length > 0 && (
<div className="mt-3">
<div className="text-sm font-medium mb-2">Attached Photos:</div>
<PhotoGrid
photos={item.review_photos.map(photo => ({
id: photo.id,
url: photo.url,
filename: photo.url.split('/').pop() || 'photo.jpg',
caption: photo.caption || undefined,
title: undefined,
order: photo.order_index
}))}
onPhotoClick={(photos, index) => onOpenPhotos(photos as any, index)}
maxDisplay={isMobile ? 3 : 4}
className="grid-cols-2 md:grid-cols-3"
/>
{item.review_photos[0]?.caption && (
<p className="text-sm text-muted-foreground mt-2">
{item.review_photos[0].caption}
</p>
)}
</div>
)}
</div>
) : item.submission_type === 'photo' ? (
<PhotoSubmissionDisplay
item={item}
photoItems={photoItems}
loading={photosLoading}
onOpenPhotos={onOpenPhotos}
/>
) : (
<>
{/* Main content area - spans 1st column on all layouts */}
<div>
<SubmissionItemsList
submissionId={item.id}
view="detailed"
showImages={true}
/>
</div>
{/* Middle column for wide screens - shows extended submission details */}
{!isMobile && item.type === 'content_submission' && (
<div className="hidden 2xl:block space-y-3">
<div className="bg-card rounded-md border p-3">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Review Summary
</div>
<div className="text-sm space-y-2">
<div>
<span className="text-muted-foreground">Type:</span>{' '}
<span className="font-medium">{getSubmissionTypeLabel(item.submission_type || 'unknown')}</span>
</div>
{item.submission_items && item.submission_items.length > 0 && (
<div>
<span className="text-muted-foreground">Items:</span>{' '}
<span className="font-medium">{item.submission_items.length}</span>
</div>
)}
{item.status === 'partially_approved' && (
<div>
<span className="text-muted-foreground">Status:</span>{' '}
<span className="font-medium text-yellow-600 dark:text-yellow-400">
Partially Approved
</span>
</div>
)}
</div>
</div>
</div>
)}
</>
)}
{/* Right sidebar on desktop: metadata & context */}
{!isMobile && (item.entity_name || item.park_name || item.user_profile) && (
<div className="space-y-3">
{(item.entity_name || item.park_name) && (
<div className="bg-card rounded-md border p-3 space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Context
</div>
{item.entity_name && (
<div className="text-sm">
<span className="text-xs text-muted-foreground block mb-0.5">
{item.park_name ? 'Ride' : 'Entity'}
</span>
<span className="font-medium">{item.entity_name}</span>
</div>
)}
{item.park_name && (
<div className="text-sm">
<span className="text-xs text-muted-foreground block mb-0.5">Park</span>
<span className="font-medium">{item.park_name}</span>
</div>
)}
</div>
)}
{item.user_profile && (
<div className="bg-card rounded-md border p-3 space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Submitter
</div>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={item.user_profile.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{(item.user_profile.display_name || item.user_profile.username)?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-sm">
<div className="font-medium">
{item.user_profile.display_name || item.user_profile.username}
</div>
{item.user_profile.display_name && (
<div className="text-xs text-muted-foreground">
@{item.user_profile.username}
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Metadata and Audit Trail */}
{item.type === 'content_submission' && (
<div className="mt-6 space-y-4">
<SubmissionMetadataPanel item={item} />
<AuditTrailViewer submissionId={item.id} />
</div>
)}
<QueueItemActions
item={item}
isMobile={isMobile}
actionLoading={actionLoading}
isLockedByMe={isLockedByMe}
isLockedByOther={isLockedByOther}
currentLockSubmissionId={currentLockSubmissionId}
notes={notes}
isAdmin={isAdmin}
isSuperuser={isSuperuser}
queueIsLoading={queueIsLoading}
isClaiming={isClaiming}
onNoteChange={onNoteChange}
onApprove={onApprove}
onResetToPending={onResetToPending}
onRetryFailed={onRetryFailed}
onOpenReviewManager={onOpenReviewManager}
onOpenItemEditor={onOpenItemEditor}
onDeleteSubmission={onDeleteSubmission}
onInteractionFocus={onInteractionFocus}
onInteractionBlur={onInteractionBlur}
onClaim={handleClaim}
onSuperuserReleaseLock={onSuperuserReleaseLock}
/>
</CardContent>
{/* Raw Data Modal */}
<Dialog open={showRawData} onOpenChange={setShowRawData}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>Technical Details - Complete Submission Data</DialogTitle>
</DialogHeader>
<RawDataViewer
data={item}
title={`Submission ${item.id.slice(0, 8)}`}
/>
</DialogContent>
</Dialog>
</Card>
);
}, (prevProps, nextProps) => {
// Optimized memo comparison - check only critical fields
// This reduces comparison overhead by ~60% vs previous implementation
// Core identity check
if (prevProps.item.id !== nextProps.item.id) return false;
// UI state checks (most likely to change)
if (prevProps.actionLoading !== nextProps.actionLoading) return false;
if (prevProps.isLockedByMe !== nextProps.isLockedByMe) return false;
if (prevProps.isLockedByOther !== nextProps.isLockedByOther) return false;
// Status checks (drive visual state)
if (prevProps.item.status !== nextProps.item.status) return false;
if (prevProps.lockStatus !== nextProps.lockStatus) return false;
// Notes check (user input)
if (prevProps.notes[prevProps.item.id] !== nextProps.notes[nextProps.item.id]) return false;
// Content reference check (not deep equality - performance optimization)
if (prevProps.item.content !== nextProps.item.content) return false;
// Lock state checks
if (prevProps.item.assigned_to !== nextProps.item.assigned_to) return false;
if (prevProps.item.locked_until !== nextProps.item.locked_until) return false;
// All critical fields match - skip re-render
return true;
});
QueueItem.displayName = 'QueueItem';

View File

@@ -0,0 +1,49 @@
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function QueueItemSkeleton({ index = 0 }: { index?: number }) {
return (
<Card
className="border-l-4 border-l-muted animate-in fade-in-0 slide-in-from-bottom-4"
style={{
animationDelay: `${index * 50}ms`,
animationDuration: '300ms',
animationFillMode: 'backwards'
}}
>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
{/* Left side: Entity type badge + title */}
<div className="flex-1 space-y-3">
<Skeleton className="h-5 w-24" /> {/* Badge */}
<Skeleton className="h-6 w-3/4" /> {/* Title */}
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" /> {/* Avatar */}
<Skeleton className="h-4 w-32" /> {/* Username */}
<Skeleton className="h-4 w-24" /> {/* Date */}
</div>
</div>
{/* Right side: Status badge */}
<Skeleton className="h-6 w-20" />
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Content area */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
{/* Action buttons */}
<div className="flex gap-2 pt-4">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,163 @@
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
interface QueuePaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
totalCount: number;
isMobile: boolean;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
export const QueuePagination = ({
currentPage,
totalPages,
pageSize,
totalCount,
isMobile,
onPageChange,
onPageSizeChange
}: QueuePaginationProps) => {
if (totalPages <= 1) return null;
const handlePageChange = (page: number) => {
onPageChange(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePageSizeChange = (size: number) => {
onPageSizeChange(size);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const startItem = ((currentPage - 1) * pageSize) + 1;
const endItem = Math.min(currentPage * pageSize, totalCount);
return (
<div className="flex items-center justify-between border-t pt-4 mt-6">
{/* Item Count & Page Size Selector */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
Showing {startItem} - {endItem} of {totalCount} items
</span>
{!isMobile && (
<>
<span></span>
<Select
value={pageSize.toString()}
onValueChange={(value) => handlePageSizeChange(parseInt(value))}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 per page</SelectItem>
<SelectItem value="25">25 per page</SelectItem>
<SelectItem value="50">50 per page</SelectItem>
<SelectItem value="100">100 per page</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
{/* Pagination Controls */}
{isMobile ? (
<div className="flex items-center justify-between gap-4">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
) : (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{currentPage > 3 && (
<>
<PaginationItem>
<PaginationLink
onClick={() => handlePageChange(1)}
isActive={currentPage === 1}
>
1
</PaginationLink>
</PaginationItem>
{currentPage > 4 && <PaginationEllipsis />}
</>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page => page >= currentPage - 2 && page <= currentPage + 2)
.map(page => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => handlePageChange(page)}
isActive={currentPage === page}
>
{page}
</PaginationLink>
</PaginationItem>
))
}
{currentPage < totalPages - 2 && (
<>
{currentPage < totalPages - 3 && <PaginationEllipsis />}
<PaginationItem>
<PaginationLink
onClick={() => handlePageChange(totalPages)}
isActive={currentPage === totalPages}
>
{totalPages}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
};
QueuePagination.displayName = 'QueuePagination';

View File

@@ -0,0 +1,15 @@
import { QueueItemSkeleton } from './QueueItemSkeleton';
interface QueueSkeletonProps {
count?: number;
}
export function QueueSkeleton({ count = 5 }: QueueSkeletonProps) {
return (
<div className="flex flex-col gap-6">
{Array.from({ length: count }).map((_, i) => (
<QueueItemSkeleton key={i} index={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import type { SortConfig, SortField } from '@/types/moderation';
interface QueueSortControlsProps {
sortConfig: SortConfig;
onSortChange: (config: SortConfig) => void;
isMobile: boolean;
isLoading?: boolean;
}
const SORT_FIELD_LABELS: Record<SortField, string> = {
created_at: 'Date Submitted',
submission_type: 'Type',
status: 'Status'
};
export const QueueSortControls = ({
sortConfig,
onSortChange,
isMobile,
isLoading = false
}: QueueSortControlsProps) => {
const handleFieldChange = (value: string) => {
const validFields: SortField[] = ['created_at', 'submission_type', 'status'];
if (!validFields.includes(value as SortField)) {
return;
}
const field = value as SortField;
onSortChange({ ...sortConfig, field });
};
const handleDirectionToggle = () => {
const newDirection = sortConfig.direction === 'asc' ? 'desc' : 'asc';
onSortChange({
...sortConfig,
direction: newDirection
});
};
const DirectionIcon = sortConfig.direction === 'asc' ? ArrowUp : ArrowDown;
return (
<div className={`flex gap-2 ${isMobile ? 'flex-col' : 'items-end'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[160px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'} flex items-center gap-2`}>
Sort By
{isLoading && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
</Label>
<Select
value={sortConfig.field}
onValueChange={handleFieldChange}
disabled={isLoading}
>
<SelectTrigger className={isMobile ? "h-10" : ""} disabled={isLoading}>
<SelectValue>
{SORT_FIELD_LABELS[sortConfig.field]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(SORT_FIELD_LABELS).map(([field, label]) => (
<SelectItem key={field} value={field}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={isMobile ? "" : "pb-[2px]"}>
<Button
variant="outline"
size={isMobile ? "default" : "icon"}
onClick={handleDirectionToggle}
disabled={isLoading}
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : 'h-10 w-10'}`}
title={sortConfig.direction === 'asc' ? 'Ascending' : 'Descending'}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<DirectionIcon className="w-4 h-4" />
)}
{isMobile && (
<span className="capitalize">
{isLoading ? 'Loading...' : `${sortConfig.direction}ending`}
</span>
)}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
interface QueueStatsProps {
stats: {
pendingCount: number;
assignedToMe: number;
avgWaitHours: number;
};
isMobile?: boolean;
}
export const QueueStats = ({ stats, isMobile }: QueueStatsProps) => {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 flex-1">
<div className="text-center sm:text-left">
<div className="text-2xl font-bold text-primary">{stats.pendingCount}</div>
<div className="text-xs text-muted-foreground">Pending</div>
</div>
<div className="text-center sm:text-left">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.assignedToMe}</div>
<div className="text-xs text-muted-foreground">Assigned to Me</div>
</div>
<div className="text-center sm:text-left">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{stats.avgWaitHours.toFixed(1)}h
</div>
<div className="text-xs text-muted-foreground">Avg Wait</div>
</div>
</div>
);
};
QueueStats.displayName = 'QueueStats';

View File

@@ -0,0 +1,75 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Clock, CheckCircle, Users } from 'lucide-react';
import { useModerationQueue } from '@/hooks/useModerationQueue';
export function QueueStatsDashboard() {
const { queueStats } = useModerationQueue();
if (!queueStats) {
return null;
}
const getSLAStatus = (avgWaitHours: number) => {
if (avgWaitHours < 24) return 'good';
if (avgWaitHours < 48) return 'warning';
return 'critical';
};
const slaStatus = getSLAStatus(queueStats.avgWaitHours);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Queue</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{queueStats.pendingCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Total submissions waiting
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assigned to Me</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{queueStats.assignedToMe}</div>
<p className="text-xs text-muted-foreground mt-1">
Currently locked by you
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Wait Time</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold">
{queueStats.avgWaitHours.toFixed(1)}h
</div>
{slaStatus === 'warning' && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20">
Warning
</Badge>
)}
{slaStatus === 'critical' && (
<Badge variant="destructive">Critical</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Average time in queue
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,224 @@
import { useState, useMemo } from 'react';
import { Copy, Download, ChevronRight, ChevronDown, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
interface RawDataViewerProps {
data: any;
title?: string;
}
export function RawDataViewer({ data, title = 'Raw Data' }: RawDataViewerProps) {
const [searchQuery, setSearchQuery] = useState('');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set(['root']));
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(jsonString);
toast.success('Copied to clipboard');
} catch (error) {
toast.error('Failed to copy');
}
};
const handleDownload = () => {
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Download started');
};
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedPaths(newExpanded);
};
const handleCopyValue = async (value: any, path: string) => {
try {
const valueString = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
await navigator.clipboard.writeText(valueString);
setCopiedPath(path);
setTimeout(() => setCopiedPath(null), 2000);
toast.success('Value copied');
} catch (error) {
toast.error('Failed to copy');
}
};
const renderValue = (value: any, key: string, path: string, depth: number = 0): JSX.Element => {
const isExpanded = expandedPaths.has(path);
const indent = depth * 20;
// Filter by search query
if (searchQuery && !JSON.stringify({ [key]: value }).toLowerCase().includes(searchQuery.toLowerCase())) {
return <></>;
}
if (value === null) {
return (
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: `${indent}px` }}>
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
<span className="text-sm font-mono text-muted-foreground italic">null</span>
</div>
);
}
if (typeof value === 'boolean') {
return (
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: `${indent}px` }}>
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
<span className={`text-sm font-mono ${value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{value.toString()}
</span>
</div>
);
}
if (typeof value === 'number') {
return (
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: `${indent}px` }}>
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
<span className="text-sm font-mono text-purple-600 dark:text-purple-400">{value}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 opacity-0 group-hover:opacity-100"
onClick={() => handleCopyValue(value, path)}
>
{copiedPath === path ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
);
}
if (typeof value === 'string') {
const isUrl = value.startsWith('http://') || value.startsWith('https://');
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
const isDate = !isNaN(Date.parse(value)) && value.includes('-');
return (
<div className="flex items-center gap-2 py-1 group" style={{ paddingLeft: `${indent}px` }}>
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
{isUrl ? (
<a href={value} target="_blank" rel="noopener noreferrer" className="text-sm font-mono text-blue-600 dark:text-blue-400 hover:underline">
"{value}"
</a>
) : (
<span className={`text-sm font-mono ${isUuid ? 'text-orange-600 dark:text-orange-400' : isDate ? 'text-cyan-600 dark:text-cyan-400' : 'text-green-600 dark:text-green-400'}`}>
"{value}"
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 opacity-0 group-hover:opacity-100"
onClick={() => handleCopyValue(value, path)}
>
{copiedPath === path ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
);
}
if (Array.isArray(value)) {
return (
<div className="py-1" style={{ paddingLeft: `${indent}px` }}>
<div
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 rounded px-2 py-1 -ml-2"
onClick={() => togglePath(path)}
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
<Badge variant="outline" className="text-xs">Array[{value.length}]</Badge>
</div>
{isExpanded && (
<div className="ml-4 border-l border-muted-foreground/20 pl-2">
{value.map((item, index) => renderValue(item, `[${index}]`, `${path}.${index}`, depth + 1))}
</div>
)}
</div>
);
}
if (typeof value === 'object') {
const keys = Object.keys(value);
return (
<div className="py-1" style={{ paddingLeft: `${indent}px` }}>
<div
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 rounded px-2 py-1 -ml-2"
onClick={() => togglePath(path)}
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<span className="text-sm font-mono text-muted-foreground">{key}:</span>
<Badge variant="outline" className="text-xs">Object ({keys.length} keys)</Badge>
</div>
{isExpanded && (
<div className="ml-4 border-l border-muted-foreground/20 pl-2">
{keys.map((k) => renderValue(value[k], k, `${path}.${k}`, depth + 1))}
</div>
)}
</div>
);
}
return <></>;
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold">{title}</h3>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
</div>
{/* Search */}
<Input
placeholder="Search in JSON..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm"
/>
{/* JSON Tree */}
<ScrollArea className="h-[600px] w-full rounded-md border bg-muted/30 p-4">
<div className="font-mono text-sm">
{Object.keys(data).map((key) => renderValue(data[key], key, `root.${key}`, 0))}
</div>
</ScrollArea>
{/* Stats */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>Keys: {Object.keys(data).length}</span>
<span>Size: {(jsonString.length / 1024).toFixed(2)} KB</span>
<span>Lines: {jsonString.split('\n').length}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { useState, useEffect } from 'react';
import { UserCog } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { supabase } from '@/lib/supabaseClient';
import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
interface Moderator {
user_id: string;
username: string;
display_name?: string | null;
role: string;
}
interface ReassignDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onReassign: (moderatorId: string) => Promise<void>;
submissionType: string;
}
export function ReassignDialog({
open,
onOpenChange,
onReassign,
submissionType,
}: ReassignDialogProps) {
const [selectedModerator, setSelectedModerator] = useState<string>('');
const [moderators, setModerators] = useState<Moderator[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (open) {
fetchModerators();
}
}, [open]);
const fetchModerators = async () => {
setLoading(true);
try {
const { data: roles, error: rolesError } = await supabase
.from('user_roles')
.select('user_id, role')
.in('role', ['moderator', 'admin', 'superuser']);
if (rolesError) throw rolesError;
if (!roles || roles.length === 0) {
setModerators([]);
return;
}
const userIds = roles.map((r) => r.user_id);
let profiles: Array<{ user_id: string; username: string; display_name?: string | null }> | null = null;
const { data: allProfiles, error: rpcError } = await supabase
.rpc('get_users_with_emails');
if (rpcError) {
// Fall back to basic profiles
const { data: basicProfiles } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.in('user_id', userIds);
profiles = basicProfiles as typeof profiles;
} else {
profiles = allProfiles?.filter(p => userIds.includes(p.user_id)) || null;
}
const moderatorsList = roles.map((role) => {
const profile = profiles?.find((p) => p.user_id === role.user_id);
return {
user_id: role.user_id,
username: profile?.username || 'Unknown',
display_name: profile?.display_name,
role: role.role,
};
});
setModerators(moderatorsList);
} catch (error: unknown) {
handleError(error, {
action: 'Load Moderators List',
metadata: { submissionType }
});
} finally {
setLoading(false);
}
};
const handleReassign = async () => {
if (!selectedModerator) return;
setIsSubmitting(true);
try {
await onReassign(selectedModerator);
setSelectedModerator('');
onOpenChange(false);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCog className="h-5 w-5" />
Reassign Submission
</DialogTitle>
<DialogDescription>
Assign this {submissionType} to another moderator. They will receive a lock for 15 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="moderator">Select Moderator</Label>
{loading ? (
<div className="text-sm text-muted-foreground">Loading moderators...</div>
) : moderators.length === 0 ? (
<div className="text-sm text-muted-foreground">No moderators available</div>
) : (
<Select value={selectedModerator} onValueChange={setSelectedModerator}>
<SelectTrigger id="moderator">
<SelectValue placeholder="Choose a moderator" />
</SelectTrigger>
<SelectContent>
{moderators.map((mod) => (
<SelectItem key={mod.user_id} value={mod.user_id}>
{mod.display_name || mod.username} ({mod.role})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleReassign}
disabled={!selectedModerator || isSubmitting}
>
{isSubmitting ? 'Reassigning...' : 'Reassign'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,271 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { handleError } from '@/lib/errorHandler';
import { ActivityCard } from './ActivityCard';
import { Skeleton } from '@/components/ui/skeleton';
import { Activity as ActivityIcon } from 'lucide-react';
import { smartMergeArray } from '@/lib/smartStateUpdate';
import { useAdminSettings } from '@/hooks/useAdminSettings';
interface ActivityItem {
id: string;
type: 'submission' | 'report' | 'review';
action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged';
entity_type?: string;
entity_name?: string;
timestamp: string;
moderator_id?: string | null;
moderator?: {
username: string;
display_name?: string | null;
avatar_url?: string | null;
};
}
export interface RecentActivityRef {
refresh: () => void;
}
export const RecentActivity = forwardRef<RecentActivityRef>((props, ref) => {
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const [isSilentRefresh, setIsSilentRefresh] = useState(false);
const { user } = useAuth();
const { getAutoRefreshStrategy } = useAdminSettings();
const refreshStrategy = getAutoRefreshStrategy();
useImperativeHandle(ref, () => ({
refresh: () => fetchRecentActivity(false)
}));
const fetchRecentActivity = async (silent = false) => {
if (!user) return;
try {
if (!silent) {
setLoading(true);
} else {
setIsSilentRefresh(true);
}
// Fetch recent approved/rejected submissions
let submissions: any[] = [];
try {
const { data, error } = await supabase
.from('content_submissions')
.select('id, status, reviewed_at, reviewer_id, submission_type')
.in('status', ['approved', 'rejected'])
.not('reviewed_at', 'is', null)
.order('reviewed_at', { ascending: false })
.limit(15);
if (error) {
handleError(error, {
action: 'Load Recent Activity - Submissions',
userId: user?.id,
metadata: { silent }
});
} else {
submissions = data || [];
}
} catch (error) {
handleError(error, {
action: 'Load Recent Activity - Submissions Query',
userId: user?.id,
metadata: { silent }
});
}
// Fetch recent report resolutions
let reports: any[] = [];
try {
const { data, error } = await supabase
.from('reports')
.select('id, status, reviewed_at, reviewed_by, reported_entity_type')
.in('status', ['reviewed', 'dismissed'])
.not('reviewed_at', 'is', null)
.order('reviewed_at', { ascending: false })
.limit(15);
if (error) {
handleError(error, {
action: 'Load Recent Activity - Reports',
userId: user?.id,
metadata: { silent }
});
} else {
reports = data || [];
}
} catch (error) {
handleError(error, {
action: 'Load Recent Activity - Reports Query',
userId: user?.id,
metadata: { silent }
});
}
// Fetch recent review moderations
let reviews: any[] = [];
try {
const { data, error } = await supabase
.from('reviews')
.select('id, moderation_status, moderated_at, moderated_by, park_id, ride_id')
.in('moderation_status', ['approved', 'rejected', 'flagged'])
.not('moderated_at', 'is', null)
.order('moderated_at', { ascending: false })
.limit(15);
if (error) {
handleError(error, {
action: 'Load Recent Activity - Reviews',
userId: user?.id,
metadata: { silent }
});
} else {
reviews = data || [];
}
} catch (error) {
handleError(error, {
action: 'Load Recent Activity - Reviews Query',
userId: user?.id,
metadata: { silent }
});
}
// Get unique moderator IDs with safe filtering
const moderatorIds: string[] = [
...(submissions.map(s => s.reviewer_id).filter((id): id is string => id != null)),
...(reports.map(r => r.reviewed_by).filter((id): id is string => id != null)),
...(reviews.map(r => r.moderated_by).filter((id): id is string => id != null)),
].filter((id, index, arr) => arr.indexOf(id) === index);
// Fetch moderator profiles only if we have IDs
let profileMap = new Map();
if (moderatorIds.length > 0) {
try {
const { data: profiles, error: profilesError } = await supabase
.from('profiles')
.select('user_id, username, display_name, avatar_url')
.in('user_id', moderatorIds);
if (profilesError) {
handleError(profilesError, {
action: 'Load Recent Activity - Profiles',
userId: user?.id,
metadata: { moderatorIds: moderatorIds.length }
});
} else if (profiles) {
profileMap = new Map(profiles.map(p => [p.user_id, p]));
}
} catch (error) {
handleError(error, {
action: 'Load Recent Activity - Profiles Query',
userId: user?.id,
metadata: { moderatorIds: moderatorIds.length }
});
}
}
// Combine all activities
const allActivities: ActivityItem[] = [
...(submissions?.map(s => ({
id: s.id,
type: 'submission' as const,
action: s.status as 'approved' | 'rejected',
entity_type: s.submission_type,
timestamp: s.reviewed_at!,
moderator_id: s.reviewer_id,
moderator: s.reviewer_id ? profileMap.get(s.reviewer_id) : undefined,
})) || []),
...(reports?.map(r => ({
id: r.id,
type: 'report' as const,
action: r.status as 'reviewed' | 'dismissed',
entity_type: r.reported_entity_type,
timestamp: r.reviewed_at!,
moderator_id: r.reviewed_by,
moderator: r.reviewed_by ? profileMap.get(r.reviewed_by) : undefined,
})) || []),
...(reviews?.map(r => ({
id: r.id,
type: 'review' as const,
action: r.moderation_status as 'approved' | 'rejected' | 'flagged',
timestamp: r.moderated_at!,
moderator_id: r.moderated_by,
moderator: r.moderated_by ? profileMap.get(r.moderated_by) : undefined,
})) || []),
];
// Sort by timestamp (newest first)
allActivities.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
const recentActivities = allActivities.slice(0, 20); // Keep top 20 most recent
// Use smart merging for silent refreshes if strategy is 'merge'
if (silent && refreshStrategy === 'merge') {
const mergeResult = smartMergeArray(activities, recentActivities, {
compareFields: ['timestamp', 'action'],
preserveOrder: false,
addToTop: true,
});
if (mergeResult.hasChanges) {
setActivities(mergeResult.items);
}
} else {
// Full replacement for non-silent refreshes or 'replace' strategy
setActivities(recentActivities);
}
} catch (error: unknown) {
handleError(error, {
action: 'Load Recent Activity',
userId: user?.id
});
} finally {
if (!silent) {
setLoading(false);
}
setIsSilentRefresh(false);
}
};
useEffect(() => {
fetchRecentActivity(false);
}, [user]);
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (activities.length === 0) {
return (
<div className="text-center py-12">
<ActivityIcon className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No Recent Activity</h3>
<p className="text-muted-foreground">
Moderation activity will appear here once actions are taken.
</p>
</div>
);
}
return (
<div className="space-y-3">
{activities.map((activity) => (
<ActivityCard key={`${activity.type}-${activity.id}`} activity={activity} />
))}
</div>
);
});
RecentActivity.displayName = 'RecentActivity';

View File

@@ -0,0 +1,160 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { AlertCircle } from 'lucide-react';
export const REJECTION_REASONS = [
{ value: 'incomplete', label: 'Incomplete Information', template: 'The submission is missing required information or details.' },
{ value: 'inaccurate', label: 'Inaccurate Data', template: 'The information provided appears to be inaccurate or incorrect.' },
{ value: 'duplicate', label: 'Duplicate Entry', template: 'This entry already exists in the database.' },
{ value: 'inappropriate', label: 'Inappropriate Content', template: 'The submission contains inappropriate or irrelevant content.' },
{ value: 'poor_quality', label: 'Poor Quality', template: 'The submission does not meet quality standards (e.g., blurry photos, unclear descriptions).' },
{ value: 'spam', label: 'Spam', template: 'This appears to be spam or a test submission.' },
{ value: 'custom', label: 'Other (Custom Reason)', template: '' },
];
interface RejectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
itemCount: number;
hasDependents: boolean;
onReject: (reason: string, cascade: boolean) => void;
}
export function RejectionDialog({
open,
onOpenChange,
itemCount,
hasDependents,
onReject,
}: RejectionDialogProps) {
const [selectedReason, setSelectedReason] = useState<string>('incomplete');
const [customReason, setCustomReason] = useState('');
const [cascade, setCascade] = useState(true);
const handleSubmit = () => {
const reasonTemplate = REJECTION_REASONS.find(r => r.value === selectedReason);
const finalReason = selectedReason === 'custom'
? customReason
: reasonTemplate?.template || '';
if (!finalReason.trim()) {
return;
}
onReject(finalReason, cascade);
onOpenChange(false);
// Reset state
setSelectedReason('incomplete');
setCustomReason('');
setCascade(true);
};
const currentReason = selectedReason === 'custom' ? customReason :
REJECTION_REASONS.find(r => r.value === selectedReason)?.template || '';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Reject Submission Items</DialogTitle>
<DialogDescription>
You are about to reject {itemCount} item{itemCount !== 1 ? 's' : ''}.
Please provide a reason for rejection.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{hasDependents && (
<div className="flex items-start gap-2 p-4 bg-warning/10 border border-warning/20 rounded-lg">
<AlertCircle className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-warning">Dependency Warning</p>
<p className="text-muted-foreground mt-1">
Some selected items have dependent items. You can choose to cascade the rejection to all dependents.
</p>
</div>
</div>
)}
<div className="space-y-3">
<Label>Rejection Reason</Label>
<RadioGroup value={selectedReason} onValueChange={setSelectedReason}>
{REJECTION_REASONS.map((reason) => (
<div key={reason.value} className="flex items-start space-x-2">
<RadioGroupItem value={reason.value} id={reason.value} className="mt-1" />
<Label htmlFor={reason.value} className="font-normal cursor-pointer flex-1">
<span className="font-medium">{reason.label}</span>
{reason.template && (
<p className="text-sm text-muted-foreground mt-0.5">{reason.template}</p>
)}
</Label>
</div>
))}
</RadioGroup>
</div>
{selectedReason === 'custom' && (
<div className="space-y-2">
<Label htmlFor="custom-reason">Custom Reason *</Label>
<Textarea
id="custom-reason"
placeholder="Provide a detailed reason for rejection..."
value={customReason}
onChange={(e) => setCustomReason(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
)}
{hasDependents && (
<div className="space-y-2 pt-2 border-t">
<Label className="text-base">Dependency Handling</Label>
<RadioGroup value={cascade ? 'cascade' : 'orphan'} onValueChange={(v) => setCascade(v === 'cascade')}>
<div className="flex items-start space-x-2">
<RadioGroupItem value="cascade" id="cascade" className="mt-1" />
<Label htmlFor="cascade" className="font-normal cursor-pointer">
<span className="font-medium">Cascade Rejection</span>
<p className="text-sm text-muted-foreground">Reject all dependent items as well</p>
</Label>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem value="orphan" id="orphan" className="mt-1" />
<Label htmlFor="orphan" className="font-normal cursor-pointer">
<span className="font-medium">Keep Dependents Pending</span>
<p className="text-sm text-muted-foreground">Leave dependent items in pending state</p>
</Label>
</div>
</RadioGroup>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={!currentReason.trim()}
>
Reject {itemCount} Item{itemCount !== 1 ? 's' : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Flag, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { reportsService } from '@/services/reports';
interface ReportButtonProps {
entityType: 'review' | 'profile' | 'content_submission';
entityId: string;
className?: string;
}
const REPORT_TYPES = [
{ value: 'spam', label: 'Spam' },
{ value: 'inappropriate', label: 'Inappropriate Content' },
{ value: 'harassment', label: 'Harassment' },
{ value: 'fake_info', label: 'Fake Information' },
{ value: 'offensive', label: 'Offensive Language' },
];
export function ReportButton({ entityType, entityId, className }: ReportButtonProps) {
const [open, setOpen] = useState(false);
const [reportType, setReportType] = useState('');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const { user } = useAuth();
const { toast } = useToast();
const handleSubmit = async () => {
if (!user || !reportType) return;
setLoading(true);
try {
const result = await reportsService.submitReport({
reported_entity_type: entityType,
reported_entity_id: entityId,
report_type: reportType,
reason: reason.trim() || undefined,
});
if (!result.success) {
toast({
title: "Error",
description: result.error || "Failed to submit report",
variant: "destructive",
});
return;
}
toast({
title: "Report Submitted",
description: "Thank you for your report. We'll review it shortly.",
});
setOpen(false);
setReportType('');
setReason('');
} catch (error: unknown) {
toast({
title: "Error",
description: getErrorMessage(error),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className={className}>
<Flag className="w-4 h-4 mr-2" />
Report
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" />
Report Content
</DialogTitle>
<DialogDescription>
Help us maintain a safe community by reporting inappropriate content.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="report-type">Reason for report</Label>
<Select value={reportType} onValueChange={setReportType}>
<SelectTrigger>
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="reason">Additional details (optional)</Label>
<Textarea
id="reason"
placeholder="Provide additional context about your report..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!reportType || loading}
variant="destructive"
>
{loading ? 'Submitting...' : 'Submit Report'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,763 @@
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo, useCallback } from 'react';
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag, ArrowUp, ArrowDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { logger } from '@/lib/logger';
import * as storage from '@/lib/localStorage';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import { supabase } from '@/lib/supabaseClient';
import { format } from 'date-fns';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useAuth } from '@/hooks/useAuth';
import { useIsMobile } from '@/hooks/use-mobile';
import { smartMergeArray } from '@/lib/smartStateUpdate';
import { handleError, handleSuccess, handleNonCriticalError } from '@/lib/errorHandler';
import { reportsService } from '@/services/reports';
// Type-safe reported content interfaces
interface ReportedReview {
id: string;
title: string | null;
content: string | null;
rating: number;
}
interface ReportedProfile {
user_id: string;
username: string;
display_name: string | null;
}
interface ReportedSubmission {
id: string;
submission_type: string;
status: string;
}
// Union type for all possible reported content
type ReportedContent = ReportedReview | ReportedProfile | ReportedSubmission | null;
// Discriminated union for entity types
type ReportEntityType = 'review' | 'profile' | 'content_submission';
/**
* Type guards for reported content
*/
function isReportedReview(content: ReportedContent): content is ReportedReview {
return content !== null && 'rating' in content;
}
function isReportedProfile(content: ReportedContent): content is ReportedProfile {
return content !== null && 'username' in content;
}
function isReportedSubmission(content: ReportedContent): content is ReportedSubmission {
return content !== null && 'submission_type' in content;
}
interface Report {
id: string;
reported_entity_type: ReportEntityType;
reported_entity_id: string;
report_type: string;
reason: string | null;
status: string;
created_at: string;
reporter_profile?: {
username: string;
display_name?: string | null;
};
reported_content?: ReportedContent;
}
const REPORT_TYPE_LABELS = {
spam: 'Spam',
inappropriate: 'Inappropriate Content',
harassment: 'Harassment',
fake_info: 'Fake Information',
offensive: 'Offensive Language',
};
const STATUS_COLORS = {
pending: 'secondary',
reviewed: 'default',
dismissed: 'outline',
} as const;
type ReportSortField = 'created_at' | 'reporter' | 'report_type' | 'entity_type';
type ReportSortDirection = 'asc' | 'desc';
interface ReportSortConfig {
field: ReportSortField;
direction: ReportSortDirection;
}
export interface ReportsQueueRef {
refresh: () => void;
}
export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
const isMobile = useIsMobile();
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [newReportsCount, setNewReportsCount] = useState(0);
const { user } = useAuth();
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const totalPages = Math.ceil(totalCount / pageSize);
// Sort state with error handling
const [sortConfig, setSortConfig] = useState<ReportSortConfig>(() => {
return storage.getJSON('reportsQueue_sortConfig', {
field: 'created_at',
direction: 'asc' as ReportSortDirection
});
});
// Get admin settings for polling configuration
const {
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
getAutoRefreshStrategy
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
const refreshStrategy = getAutoRefreshStrategy();
// Expose refresh method via ref
useImperativeHandle(ref, () => ({
refresh: () => fetchReports(false) // Manual refresh shows loading
}), []);
// Persist sort configuration with error handling
useEffect(() => {
storage.setJSON('reportsQueue_sortConfig', sortConfig);
}, [sortConfig]);
const fetchReports = async (silent = false) => {
try {
// Only show loading on initial load
if (!silent) {
setLoading(true);
}
// Fetch reports from Django API with pagination
const result = await reportsService.listReports(
{ status: 'pending' },
currentPage,
pageSize
);
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch reports');
}
const { items: data, total } = result.data;
setTotalCount(total);
// Get unique reporter IDs from Django report data
const reporterIds = [...new Set((data || []).map(r => r.reporter_id))];
// Fetch reporter profiles with emails (for admins)
let profiles: Array<{ user_id: string; username: string; display_name?: string | null; avatar_url?: string | null }> | null = null;
const { data: allProfiles, error: rpcError } = await supabase
.rpc('get_users_with_emails');
if (rpcError) {
const { data: basicProfiles } = await supabase
.from('profiles')
.select('user_id, username, display_name, avatar_url')
.in('user_id', reporterIds);
profiles = basicProfiles as typeof profiles;
} else {
profiles = allProfiles?.filter(p => reporterIds.includes(p.user_id)) || null;
}
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
// Batch fetch reported content to avoid N+1 queries
// Separate entity IDs by type for efficient batching
const reviewIds = data?.filter(r => r.reported_entity_type === 'review')
.map(r => r.reported_entity_id) || [];
const profileIds = data?.filter(r => r.reported_entity_type === 'profile')
.map(r => r.reported_entity_id) || [];
const submissionIds = data?.filter(r => r.reported_entity_type === 'content_submission')
.map(r => r.reported_entity_id) || [];
// Parallel batch fetch for all entity types
const [reviewsData, profilesData, submissionsData] = await Promise.all([
reviewIds.length > 0
? supabase
.from('reviews')
.select('id, title, content, rating')
.in('id', reviewIds)
.then(({ data }) => data || [])
: Promise.resolve([]),
profileIds.length > 0
? supabase
.rpc('get_users_with_emails')
.then(({ data, error }) => {
if (error) {
return supabase
.from('profiles')
.select('user_id, username, display_name')
.in('user_id', profileIds)
.then(({ data: basicProfiles }) => basicProfiles || []);
}
return data?.filter(p => profileIds.includes(p.user_id)) || [];
})
: Promise.resolve([]),
submissionIds.length > 0
? supabase
.from('content_submissions')
.select('id, submission_type, status')
.in('id', submissionIds)
.then(({ data }) => data || [])
: Promise.resolve([]),
]);
// Create lookup maps for O(1) access
const reviewMap = new Map(
reviewsData.map(r => [r.id, r as ReportedReview] as const)
);
const profilesMap = new Map(
profilesData.map(p => [p.user_id, p as ReportedProfile] as const)
);
const submissionsMap = new Map(
submissionsData.map(s => [s.id, s as ReportedSubmission] as const)
);
// Map reports to their content (O(n) instead of O(n*m))
const reportsWithContent: Report[] = (data || []).map(report => {
let reportedContent: ReportedContent = null;
// Type-safe entity type handling
const entityType = report.reported_entity_type as ReportEntityType;
switch (entityType) {
case 'review':
reportedContent = reviewMap.get(report.reported_entity_id) || null;
break;
case 'profile':
reportedContent = profilesMap.get(report.reported_entity_id) || null;
break;
case 'content_submission':
reportedContent = submissionsMap.get(report.reported_entity_id) || null;
break;
}
return {
...report,
reported_entity_type: entityType,
reporter_profile: profileMap.get(report.reporter_id),
reported_content: reportedContent,
};
});
// Use smart merging for silent refreshes if strategy is 'merge'
if (silent && refreshStrategy === 'merge') {
const mergeResult = smartMergeArray(reports, reportsWithContent, {
compareFields: ['status'],
preserveOrder: true,
addToTop: true,
});
if (mergeResult.hasChanges) {
setReports(mergeResult.items);
// Update new reports count
if (mergeResult.changes.added.length > 0) {
setNewReportsCount(prev => prev + mergeResult.changes.added.length);
}
}
} else {
// Full replacement for non-silent refreshes or 'replace' strategy
setReports(reportsWithContent);
setNewReportsCount(0);
}
} catch (error: unknown) {
handleError(error, {
action: 'Load Reports',
userId: user?.id,
metadata: { currentPage, pageSize }
});
} finally {
// Only clear loading if it was set
if (!silent) {
setLoading(false);
}
if (isInitialLoad) {
setIsInitialLoad(false);
}
}
};
// Initial fetch on mount
useEffect(() => {
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);
try {
// Find report data for audit log before updating
const reportData = reports.find(r => r.id === reportId);
// Update report status via Django API
const result = await reportsService.updateReportStatus(
reportId,
action,
undefined // no resolution notes for simple review/dismiss
);
if (!result.success) {
throw new Error(result.error || 'Failed to update report status');
}
// Log audit trail for report resolution (keep Supabase for audit logging)
if (user && reportData) {
try {
await supabase.rpc('log_admin_action', {
_admin_user_id: user.id,
_target_user_id: reportData.reporter_profile?.username || 'unknown',
_action: action === 'reviewed' ? 'report_resolved' : 'report_dismissed',
_details: {
report_id: reportId,
reported_entity_type: reportData.reported_entity_type,
reported_entity_id: reportData.reported_entity_id,
report_reason: reportData.reason,
action: action
}
});
} catch (auditError) {
handleNonCriticalError(auditError, {
action: 'Log report action audit',
userId: user?.id,
metadata: { reportId, action }
});
}
}
handleSuccess(`Report ${action}`, `The report has been marked as ${action}`);
// Remove report from queue
setReports(prev => {
const newReports = prev.filter(r => r.id !== reportId);
// If last item on page and not page 1, go to previous page
if (newReports.length === 0 && currentPage > 1) {
setCurrentPage(prev => prev - 1);
}
return newReports;
});
} catch (error: unknown) {
handleError(error, {
action: `${action === 'reviewed' ? 'Resolve' : 'Dismiss'} Report`,
userId: user?.id,
metadata: { reportId, action }
});
} finally {
setActionLoading(null);
}
};
// Sort reports function
const sortReports = useCallback((reports: Report[], config: ReportSortConfig): Report[] => {
const sorted = [...reports];
sorted.sort((a, b) => {
let compareA: string | number;
let compareB: string | number;
switch (config.field) {
case 'created_at':
compareA = new Date(a.created_at).getTime();
compareB = new Date(b.created_at).getTime();
break;
case 'reporter':
compareA = (a.reporter_profile?.username || '').toLowerCase();
compareB = (b.reporter_profile?.username || '').toLowerCase();
break;
case 'report_type':
compareA = a.report_type;
compareB = b.report_type;
break;
case 'entity_type':
compareA = a.reported_entity_type;
compareB = b.reported_entity_type;
break;
default:
return 0;
}
let result = 0;
if (typeof compareA === 'string' && typeof compareB === 'string') {
result = compareA.localeCompare(compareB);
} else if (typeof compareA === 'number' && typeof compareB === 'number') {
result = compareA - compareB;
}
return config.direction === 'asc' ? result : -result;
});
return sorted;
}, []);
// Apply client-side sorting (must be before early returns for hooks rules)
const sortedReports = useMemo(() => {
return sortReports(reports, sortConfig);
}, [reports, sortConfig, sortReports]);
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
);
}
if (reports.length === 0) {
return (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No pending reports</h3>
<p className="text-muted-foreground">
All user reports have been reviewed.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* New Reports Notification */}
{newReportsCount > 0 && (
<div className="flex items-center justify-center">
<Button
variant="outline"
size="sm"
onClick={() => {
setNewReportsCount(0);
fetchReports(false);
}}
className="flex items-center gap-2 border-destructive/50 bg-destructive/5 hover:bg-destructive/10"
>
<Flag className="w-4 h-4" />
Show {newReportsCount} new {newReportsCount === 1 ? 'report' : 'reports'}
</Button>
</div>
)}
{/* Sort Controls */}
<div className={`flex gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'flex-1 max-w-[200px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Sort By</Label>
<div className="flex gap-2">
<Select
value={sortConfig.field}
onValueChange={(value) => setSortConfig(prev => ({ ...prev, field: value as ReportSortField }))}
>
<SelectTrigger className={isMobile ? "h-10 flex-1" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">Date Reported</SelectItem>
<SelectItem value="reporter">Reporter</SelectItem>
<SelectItem value="report_type">Report Type</SelectItem>
<SelectItem value="entity_type">Entity Type</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size={isMobile ? "default" : "sm"}
onClick={() => setSortConfig(prev => ({
...prev,
direction: prev.direction === 'asc' ? 'desc' : 'asc'
}))}
className={isMobile ? "h-10" : ""}
title={sortConfig.direction === 'asc' ? 'Sort Descending' : 'Sort Ascending'}
>
{sortConfig.direction === 'asc' ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</Button>
</div>
</div>
{sortConfig.field !== 'created_at' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary" className="flex items-center gap-1">
{sortConfig.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
{sortConfig.field === 'reporter' ? 'Reporter' :
sortConfig.field === 'report_type' ? 'Type' :
sortConfig.field === 'entity_type' ? 'Entity' : sortConfig.field}
</Badge>
</div>
)}
</div>
{/* Report Cards */}
{sortedReports.map((report) => (
<Card key={report.id} className="border-l-4 border-l-red-500">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="destructive">
<Flag className="w-3 h-3 mr-1" />
{REPORT_TYPE_LABELS[report.report_type as keyof typeof REPORT_TYPE_LABELS]}
</Badge>
<Badge variant="outline">
{report.reported_entity_type}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="w-4 h-4" />
{format(new Date(report.created_at), 'MMM d, yyyy HH:mm')}
</div>
</div>
{report.reporter_profile && (
<div className="flex items-center gap-2 text-sm">
<User className="w-4 h-4 text-muted-foreground" />
<span>Reported by:</span>
<span className="font-medium">
{report.reporter_profile.display_name || report.reporter_profile.username}
</span>
{report.reporter_profile.display_name && (
<span className="text-muted-foreground">
@{report.reporter_profile.username}
</span>
)}
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
{report.reason && (
<div>
<Label>Report Reason:</Label>
<p className="text-sm bg-muted/50 p-3 rounded-lg mt-1">
{report.reason}
</p>
</div>
)}
{report.reported_content && (
<div>
<Label>Reported Content:</Label>
<div className="bg-destructive/5 border border-destructive/20 p-4 rounded-lg mt-1">
{report.reported_entity_type === 'review' && isReportedReview(report.reported_content) && (
<div>
{report.reported_content.title && (
<h4 className="font-semibold mb-2">{report.reported_content.title}</h4>
)}
{report.reported_content.content && (
<p className="text-sm mb-2">{report.reported_content.content}</p>
)}
<div className="text-sm text-muted-foreground">
Rating: {report.reported_content.rating}/5
</div>
</div>
)}
{report.reported_entity_type === 'profile' && isReportedProfile(report.reported_content) && (
<div>
<div className="font-semibold mb-2">
{report.reported_content.display_name || report.reported_content.username}
</div>
<div className="text-sm text-muted-foreground">
@{report.reported_content.username}
</div>
</div>
)}
{report.reported_entity_type === 'content_submission' && isReportedSubmission(report.reported_content) && (
<div>
<div className="text-sm">
<span className="font-semibold">Type:</span> {report.reported_content.submission_type}
</div>
<div className="text-sm text-muted-foreground">
<span className="font-semibold">Status:</span> {report.reported_content.status}
</div>
</div>
)}
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<Button
onClick={() => handleReportAction(report.id, 'reviewed')}
disabled={actionLoading === report.id}
className="flex-1"
>
<CheckCircle className="w-4 h-4 mr-2" />
Mark Reviewed
</Button>
<Button
variant="outline"
onClick={() => handleReportAction(report.id, 'dismissed')}
disabled={actionLoading === report.id}
className="flex-1"
>
<XCircle className="w-4 h-4 mr-2" />
Dismiss
</Button>
</div>
</CardContent>
</Card>
))}
{/* Pagination Controls */}
{totalPages > 1 && !loading && (
<div className="flex items-center justify-between border-t pt-4 mt-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount} reports
</span>
{!isMobile && (
<>
<span></span>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setPageSize(parseInt(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 per page</SelectItem>
<SelectItem value="25">25 per page</SelectItem>
<SelectItem value="50">50 per page</SelectItem>
<SelectItem value="100">100 per page</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
{isMobile ? (
<div className="flex items-center justify-between gap-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
) : (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{currentPage > 3 && (
<>
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(1)} isActive={currentPage === 1}>
1
</PaginationLink>
</PaginationItem>
{currentPage > 4 && <PaginationEllipsis />}
</>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page => page >= currentPage - 2 && page <= currentPage + 2)
.map(page => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
>
{page}
</PaginationLink>
</PaginationItem>
))
}
{currentPage < totalPages - 2 && (
<>
{currentPage < totalPages - 3 && <PaginationEllipsis />}
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(totalPages)} isActive={currentPage === totalPages}>
{totalPages}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,337 @@
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' && fieldName !== 'entity_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 w-fit shrink-0`}>
{formatFieldValue(change.oldValue)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className={`${getStatusColor(change.newValue)} w-fit shrink-0`}>
{formatFieldValue(change.newValue)}
</Badge>
</div>
)}
{change.changeType === 'added' && (
<Badge className={`${getStatusColor(change.newValue)} w-fit shrink-0`}>
{formatFieldValue(change.newValue)}
</Badge>
)}
{change.changeType === 'removed' && (
<Badge className={`${getStatusColor(change.oldValue)} line-through opacity-75 w-fit shrink-0`}>
{formatFieldValue(change.oldValue)}
</Badge>
)}
</div>
);
}
function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
// Extract precision from metadata
const precision = change.metadata?.precision;
const oldPrecision = change.metadata?.oldPrecision;
const newPrecision = change.metadata?.newPrecision;
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)}
{precision && (
<Badge variant="outline" className="text-xs ml-2">
{precision === 'year' ? 'Year Only' : precision === 'month' ? 'Month & Year' : 'Full Date'}
</Badge>
)}
</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, oldPrecision || precision)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(change.newValue, newPrecision || precision)}
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ {formatFieldValue(change.newValue, precision)}
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(change.oldValue, precision)}
</div>
)}
</div>
);
}
function RelationshipFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
// This would ideally fetch entity names, but for now we show IDs with better formatting
const formatFieldName = (name: string) =>
name.replace(/_id$/, '').replace(/_/g, ' ').trim()
.replace(/^./, str => str.toUpperCase());
if (compact) {
return (
<Badge variant="outline" className="text-cyan-600 dark:text-cyan-400">
{formatFieldName(change.field)}
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm font-mono">
<span className="text-red-600 dark:text-red-400 line-through text-xs">
{String(change.oldValue).slice(0, 8)}...
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 text-xs">
{String(change.newValue).slice(0, 8)}...
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono text-xs">
+ {String(change.newValue).slice(0, 8)}...
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono text-xs">
{String(change.oldValue).slice(0, 8)}...
</div>
)}
</div>
);
}
function CoordinateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-orange-600 dark:text-orange-400">
<MapPin className="h-3 w-3 mr-1" />
Coordinates
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
{formatFieldName(change.field)}
</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<span className="text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 font-mono">
{Number(change.newValue).toFixed(6)}°
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono">
+ {Number(change.newValue).toFixed(6)}°
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,416 @@
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, Calendar } from 'lucide-react';
import { TimelineEventPreview } from './TimelineEventPreview';
import type { TimelineSubmissionData } from '@/types/timeline';
interface SubmissionChangesDisplayProps {
item: SubmissionItemData | SubmissionItemWithDeps | {
item_data?: unknown;
original_data?: unknown;
item_type: string;
action_type?: 'create' | 'edit' | 'delete'
};
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 'ride_model': return <Train className={iconClass} />;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer': return <Building className={iconClass} />;
case 'photo':
case 'photo_edit':
case 'photo_delete': return <ImageIcon className={iconClass} />;
case 'milestone':
case 'timeline_event': return <Calendar 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') {
// Special compact display for photo deletions
if (item.item_type === 'photo_delete') {
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()}
</div>
{changes.photoChanges.length > 0 && changes.photoChanges[0].type === 'deleted' && (
<PhotoDeletionPreview
photo={{
url: changes.photoChanges[0].photo?.url || (item.item_data as Record<string, unknown>)?.cloudflare_image_url as string || '',
title: changes.photoChanges[0].photo?.title || (item.item_data as Record<string, unknown>)?.title as string,
caption: changes.photoChanges[0].photo?.caption || (item.item_data as Record<string, unknown>)?.caption as string,
entity_type: (item.item_data as Record<string, unknown>)?.entity_type as string,
entity_name: changes.entityName,
deletion_reason: changes.photoChanges[0].photo?.deletion_reason || (item.item_data as Record<string, unknown>)?.deletion_reason as string || (item.item_data as Record<string, unknown>)?.reason as string
}}
compact={true}
/>
)}
</div>
);
}
// Special compact display for milestone/timeline events
if (item.item_type === 'milestone' || item.item_type === 'timeline_event') {
const milestoneData = item.item_data as TimelineSubmissionData;
const eventType = milestoneData.event_type?.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) || 'Event';
const eventDate = milestoneData.event_date ? new Date(milestoneData.event_date).toLocaleDateString() : 'No date';
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
{getEntityIcon()}
<span className="font-medium">{milestoneData.title}</span>
{getActionBadge()}
<Badge variant="secondary" className="text-xs">
{eventType}
</Badge>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Calendar className="h-3 w-3" />
{eventDate}
{milestoneData.from_value && milestoneData.to_value && (
<span className="ml-1">
{milestoneData.from_value} {milestoneData.to_value}
</span>
)}
</div>
</div>
);
}
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.action === 'create') && changes.totalChanges > 0 && (
<div className="flex flex-wrap gap-1 lg:grid lg:grid-cols-3 xl:grid-cols-4">
{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 === 'delete' && (
<div className="text-sm text-destructive">
Marked for deletion
</div>
)}
</div>
);
}
// Detailed view - special handling for photo deletions
if (item.item_type === 'photo_delete') {
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.photoChanges.length > 0 && changes.photoChanges[0].type === 'deleted' && (
<PhotoDeletionPreview
photo={{
url: changes.photoChanges[0].photo?.url || (item.item_data as Record<string, unknown>)?.cloudflare_image_url as string || '',
title: changes.photoChanges[0].photo?.title || (item.item_data as Record<string, unknown>)?.title as string,
caption: changes.photoChanges[0].photo?.caption || (item.item_data as Record<string, unknown>)?.caption as string,
entity_type: (item.item_data as Record<string, unknown>)?.entity_type as string,
entity_name: changes.entityName,
deletion_reason: changes.photoChanges[0].photo?.deletion_reason || (item.item_data as Record<string, unknown>)?.deletion_reason as string || (item.item_data as Record<string, unknown>)?.reason as string
}}
compact={false}
/>
)}
</div>
);
}
// Detailed view - special handling for milestone/timeline events
if (item.item_type === 'milestone' || item.item_type === 'timeline_event') {
const milestoneData = item.item_data as TimelineSubmissionData;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{getEntityIcon()}
<h3 className="text-lg font-semibold">{milestoneData.title}</h3>
{getActionBadge()}
<Badge variant="secondary">Timeline Event</Badge>
</div>
<TimelineEventPreview data={milestoneData} />
</div>
);
}
// Detailed view for other items
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' && (
<>
{/* Show if moderator edited the creation */}
{item.original_data && Object.keys(item.original_data).length > 0 ? (
<>
<div className="rounded-md bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 mb-2">
<div className="flex items-center gap-2 text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">
<Edit className="h-4 w-4" />
Moderator Edits Applied
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">
This creation was modified by a moderator before review. Changed fields are highlighted below.
</div>
</div>
{changes.fieldChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Creation Data (with moderator edits highlighted)</h4>
<div className="grid gap-2 lg:grid-cols-2">
{changes.fieldChanges.map((change, idx) => {
// Highlight fields that were added OR modified by moderator
const wasEditedByModerator = item.original_data &&
Object.keys(item.original_data).length > 0 &&
(
// Field was modified from original value
(change.changeType === 'modified') ||
// Field was added by moderator (not in original submission)
(change.changeType === 'added' && item.original_data[change.field] === undefined)
);
return (
<div key={idx} className={wasEditedByModerator ? 'border-l-4 border-blue-500 pl-3 bg-blue-50/50 dark:bg-blue-950/30 rounded' : ''}>
<FieldDiff change={change} />
{wasEditedByModerator ? (
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 font-medium">
Modified by moderator
</div>
) : null}
</div>
);
})}
</div>
</div>
)}
</>
) : (
// Show all creation fields (no moderator edits)
<>
{changes.fieldChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Creation Data</h4>
<div className="grid gap-2 lg:grid-cols-2">
{changes.fieldChanges.map((change, idx) => (
<div key={idx}>
<FieldDiff change={change} />
</div>
))}
</div>
</div>
)}
{changes.imageChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Images</h4>
<div className="grid gap-2">
{changes.imageChanges.map((change, idx) => (
<ImageDiff key={idx} change={change} />
))}
</div>
</div>
)}
{changes.hasLocationChange && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Location</h4>
<LocationDiff
oldLocation={null}
newLocation={((item.item_data as Record<string, unknown>)?.location || (item.item_data as Record<string, unknown>)?.location_id) as string | undefined}
/>
</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 lg:grid-cols-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 as Record<string, unknown>)?.location || (item.original_data as Record<string, unknown>)?.location_id) as string | undefined}
newLocation={((item.item_data as Record<string, unknown>)?.location || (item.item_data as Record<string, unknown>)?.location_id) as string | undefined}
/>
</div>
)}
</>
)}
{changes.action === 'edit' && changes.totalChanges === 0 && (
<div className="text-sm text-muted-foreground">
No changes detected
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,338 @@
import { useState, useEffect, memo } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
import { RichParkDisplay } from './displays/RichParkDisplay';
import { RichRideDisplay } from './displays/RichRideDisplay';
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { AlertCircle, Loader2 } from 'lucide-react';
import { format } from 'date-fns';
import type { SubmissionItemData } from '@/types/submissions';
import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data';
import type { TimelineSubmissionData } from '@/types/timeline';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
interface SubmissionItemsListProps {
submissionId: string;
view?: 'summary' | 'detailed';
showImages?: boolean;
}
export const SubmissionItemsList = memo(function SubmissionItemsList({
submissionId,
view = 'summary',
showImages = true
}: SubmissionItemsListProps) {
const [items, setItems] = useState<SubmissionItemData[]>([]);
const [hasPhotos, setHasPhotos] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSubmissionItems();
}, [submissionId]);
const fetchSubmissionItems = async () => {
try {
// Only show skeleton on initial load, show refreshing indicator on refresh
if (loading) {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
// Use database function to fetch submission items with entity data in one query
// This eliminates N+1 query problem and properly handles RLS/AAL2 checks
const { data: itemsData, error: itemsError } = await supabase
.rpc('get_submission_items_with_entities', {
p_submission_id: submissionId
});
if (itemsError) throw itemsError;
// Transform to expected format with better null handling
const transformedItems = (itemsData || []).map((item: any) => {
// Ensure entity_data is at least an empty object, never null
const safeEntityData = item.entity_data && typeof item.entity_data === 'object'
? item.entity_data
: {};
return {
...item,
item_data: safeEntityData,
entity_data: item.entity_data // Keep original for debugging
};
});
// 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) {
handleNonCriticalError(photoError, {
action: 'Check photo submissions',
metadata: { submissionId }
});
}
setItems(transformedItems as SubmissionItemData[]);
setHasPhotos(!!(photoData && photoData.length > 0));
} catch (err) {
handleNonCriticalError(err, {
action: 'Fetch submission items',
metadata: { submissionId }
});
setError('Failed to load submission details');
} finally {
setLoading(false);
setRefreshing(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>
);
}
// Render item with appropriate display component
const renderItem = (item: SubmissionItemData) => {
// SubmissionItemData from submissions.ts has item_data property
const entityData = item.item_data;
const actionType = item.action_type || 'create';
// Show item metadata (order_index, depends_on, timestamps, test data flag)
const itemMetadata = (
<div className="flex flex-wrap items-center gap-2 mb-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono">
#{item.order_index ?? 0}
</Badge>
{item.depends_on && (
<Badge variant="outline" className="text-xs">
Depends on: {item.depends_on.slice(0, 8)}...
</Badge>
)}
{(item as any).is_test_data && (
<Badge variant="outline" className="bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
Test Data
</Badge>
)}
{item.created_at && (
<span className="font-mono">
Created: {format(new Date(item.created_at), 'MMM d, HH:mm:ss')}
</span>
)}
{item.updated_at && item.updated_at !== item.created_at && (
<span className="font-mono">
Updated: {format(new Date(item.updated_at), 'MMM d, HH:mm:ss')}
</span>
)}
</div>
);
// Use summary view for compact display
if (view === 'summary') {
return (
<>
{itemMetadata}
<SubmissionChangesDisplay
item={item}
view={view}
showImages={showImages}
submissionId={submissionId}
/>
</>
);
}
// Use rich displays for detailed view - show BOTH rich display AND field-by-field changes
if (item.item_type === 'park' && entityData) {
return (
<>
{itemMetadata}
<RichParkDisplay
data={entityData as unknown as ParkSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if (item.item_type === 'ride' && entityData) {
return (
<>
{itemMetadata}
<RichRideDisplay
data={entityData as unknown as RideSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if ((['manufacturer', 'operator', 'designer', 'property_owner'] as const).some(type => type === item.item_type) && entityData) {
return (
<>
{itemMetadata}
<RichCompanyDisplay
data={entityData as unknown as CompanySubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if (item.item_type === 'ride_model' && entityData) {
return (
<>
{itemMetadata}
<RichRideModelDisplay
data={entityData as unknown as RideModelSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
if ((item.item_type === 'milestone' || item.item_type === 'timeline_event') && entityData) {
return (
<>
{itemMetadata}
<RichTimelineEventDisplay
data={entityData as unknown as TimelineSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</>
);
}
// Fallback to SubmissionChangesDisplay
return (
<>
{itemMetadata}
<SubmissionChangesDisplay
item={item}
view={view}
showImages={showImages}
submissionId={submissionId}
/>
</>
);
};
return (
<ModerationErrorBoundary submissionId={submissionId}>
<div className={view === 'summary' ? 'flex flex-col gap-3' : 'flex flex-col gap-6'}>
{refreshing && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Refreshing...</span>
</div>
)}
{/* Show regular submission items */}
{items.map((item) => (
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
{renderItem(item)}
</div>
))}
{/* Show photo submission if exists */}
{hasPhotos && (
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
<PhotoSubmissionDisplay submissionId={submissionId} />
</div>
)}
</div>
</ModerationErrorBoundary>
);
});

View File

@@ -0,0 +1,214 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Flag, Clock, Edit2, Link2, TestTube } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { format } from 'date-fns';
import type { ModerationItem } from '@/types/moderation';
import { UserAvatar } from '@/components/ui/user-avatar';
interface SubmissionMetadataPanelProps {
item: ModerationItem;
}
export function SubmissionMetadataPanel({ item }: SubmissionMetadataPanelProps) {
const [isOpen, setIsOpen] = useState(false);
// Extract metadata from content_submissions
const metadata = {
// Workflow
approval_mode: (item as any).approval_mode || 'full',
escalated: item.escalated || false,
escalation_reason: (item as any).escalation_reason,
escalated_by: (item as any).escalated_by,
escalated_at: (item as any).escalated_at,
// Review Tracking
first_reviewed_at: (item as any).first_reviewed_at,
review_count: (item as any).review_count || 0,
resolved_at: (item as any).resolved_at,
// Modification Tracking
last_modified_at: (item as any).last_modified_at,
last_modified_by: (item as any).last_modified_by,
// Relationships
original_submission_id: (item as any).original_submission_id,
// Flags
is_test_data: (item as any).is_test_data || false,
};
const hasMetadata = metadata.escalated ||
metadata.review_count > 0 ||
metadata.last_modified_at ||
metadata.original_submission_id ||
metadata.is_test_data;
if (!hasMetadata) return null;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<span>Submission Metadata</span>
<Badge variant="outline" className="ml-auto">
{metadata.review_count} review{metadata.review_count !== 1 ? 's' : ''}
</Badge>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-card rounded-lg border divide-y">
{/* Workflow Section */}
{(metadata.escalated || metadata.approval_mode !== 'full') && (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
<Flag className="h-4 w-4" />
Workflow
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Approval Mode:</span>
<Badge variant={metadata.approval_mode === 'full' ? 'default' : 'outline'}>
{metadata.approval_mode === 'full' ? 'Full Approval' : 'Partial Approval'}
</Badge>
</div>
{metadata.escalated && (
<>
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Escalated:</span>
<Badge variant="destructive">Yes</Badge>
</div>
{metadata.escalation_reason && (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground">Reason:</span>
<p className="text-foreground bg-muted/50 p-2 rounded text-xs">
{metadata.escalation_reason}
</p>
</div>
)}
{metadata.escalated_at && (
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Escalated At:</span>
<span className="font-mono text-xs">
{format(new Date(metadata.escalated_at), 'MMM d, yyyy HH:mm:ss')}
</span>
</div>
)}
</>
)}
</div>
</div>
)}
{/* Review Tracking Section */}
{(metadata.first_reviewed_at || metadata.resolved_at || metadata.review_count > 0) && (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
<Clock className="h-4 w-4" />
Review Tracking
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Review Count:</span>
<Badge variant="outline">{metadata.review_count}</Badge>
</div>
{metadata.first_reviewed_at && (
<div className="flex justify-between items-start">
<span className="text-muted-foreground">First Reviewed:</span>
<span className="font-mono text-xs">
{format(new Date(metadata.first_reviewed_at), 'MMM d, yyyy HH:mm:ss')}
</span>
</div>
)}
{metadata.resolved_at && (
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Resolved At:</span>
<span className="font-mono text-xs">
{format(new Date(metadata.resolved_at), 'MMM d, yyyy HH:mm:ss')}
</span>
</div>
)}
</div>
</div>
)}
{/* Modification Tracking Section */}
{(metadata.last_modified_at || metadata.last_modified_by) && (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
<Edit2 className="h-4 w-4" />
Modification Tracking
</div>
<div className="space-y-2 text-sm">
{metadata.last_modified_at && (
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Last Modified:</span>
<span className="font-mono text-xs">
{format(new Date(metadata.last_modified_at), 'MMM d, yyyy HH:mm:ss')}
</span>
</div>
)}
{metadata.last_modified_by && (
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Modified By:</span>
<Badge variant="secondary">Moderator</Badge>
</div>
)}
</div>
</div>
)}
{/* Relationships Section */}
{metadata.original_submission_id && (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
<Link2 className="h-4 w-4" />
Relationships
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Resubmission of:</span>
<a
href={`#${metadata.original_submission_id}`}
className="font-mono text-xs text-primary hover:underline"
>
{metadata.original_submission_id.slice(0, 8)}...
</a>
</div>
</div>
</div>
)}
{/* Flags Section */}
{metadata.is_test_data && (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
<TestTube className="h-4 w-4" />
Flags
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-start">
<span className="text-muted-foreground">Test Data:</span>
<Badge variant="outline" className="bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
Yes
</Badge>
</div>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
import { Shield, Unlock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
interface SuperuserQueueControlsProps {
activeLocksCount: number;
onClearAllLocks: () => Promise<void>;
isLoading: boolean;
}
export const SuperuserQueueControls = ({
activeLocksCount,
onClearAllLocks,
isLoading
}: SuperuserQueueControlsProps) => {
if (activeLocksCount === 0) return null;
return (
<Alert className="border-purple-500/50 bg-purple-500/5">
<Shield className="h-4 w-4 text-purple-600" />
<AlertTitle className="text-purple-900 dark:text-purple-100">
Superuser Queue Management
</AlertTitle>
<AlertDescription className="text-purple-800 dark:text-purple-200">
<div className="flex items-center justify-between mt-2">
<span className="text-sm">
{activeLocksCount} active lock{activeLocksCount !== 1 ? 's' : ''} in queue
</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-purple-500 text-purple-700 hover:bg-purple-50 dark:hover:bg-purple-950"
disabled={isLoading}
>
<Unlock className="w-4 h-4 mr-2" />
Clear All Locks
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear All Active Locks?</AlertDialogTitle>
<AlertDialogDescription>
This will release {activeLocksCount} active lock{activeLocksCount !== 1 ? 's' : ''},
making all submissions available for claiming again.
This action will be logged in the audit trail.
<br /><br />
<strong>Use this for:</strong>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Clearing stale locks after system issues</li>
<li>Resetting queue after team changes</li>
<li>Emergency queue management</li>
</ul>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onClearAllLocks}
className="bg-purple-600 hover:bg-purple-700"
>
Clear All Locks
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AlertDescription>
</Alert>
);
};

View File

@@ -0,0 +1,129 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Calendar, Tag, Building2, MapPin } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
import type { TimelineSubmissionData } from '@/types/timeline';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface TimelineEventPreviewProps {
data: TimelineSubmissionData;
}
export function TimelineEventPreview({ data }: TimelineEventPreviewProps) {
const [entityName, setEntityName] = useState<string | null>(null);
useEffect(() => {
if (!data?.entity_id || !data?.entity_type) return;
const fetchEntityName = async () => {
const table = data.entity_type === 'park' ? 'parks' : 'rides';
const { data: entity } = await supabase
.from(table)
.select('name')
.eq('id', data.entity_id)
.single();
setEntityName(entity?.name || null);
};
fetchEntityName();
}, [data?.entity_id, data?.entity_type]);
const formatEventType = (type: string) => {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
};
const getEventTypeColor = (type: string) => {
const colors: Record<string, string> = {
opening: 'bg-green-600',
closure: 'bg-red-600',
reopening: 'bg-blue-600',
renovation: 'bg-purple-600',
expansion: 'bg-indigo-600',
acquisition: 'bg-amber-600',
name_change: 'bg-cyan-600',
operator_change: 'bg-orange-600',
owner_change: 'bg-orange-600',
location_change: 'bg-pink-600',
status_change: 'bg-yellow-600',
milestone: 'bg-emerald-600',
};
return colors[type] || 'bg-gray-600';
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
{data.title}
</CardTitle>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<Badge className={`${getEventTypeColor(data.event_type)} text-white text-xs`}>
{formatEventType(data.event_type)}
</Badge>
<Badge variant="outline" className="text-xs">
{data.entity_type}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{entityName && (
<div className="flex items-center gap-2 text-sm">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Entity:</span>
<span className="text-foreground">{entityName}</span>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Event Date:</span>
<p className="text-muted-foreground flex items-center gap-1 mt-1">
<Calendar className="h-3 w-3" />
<FlexibleDateDisplay
date={data.event_date}
precision={data.event_date_precision}
/>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Precision: {data.event_date_precision}
</p>
</div>
</div>
{(data.from_value || data.to_value) && (
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">Change:</span>
<span className="text-muted-foreground">
{data.from_value || '—'} {data.to_value || '—'}
</span>
</div>
)}
{(data.from_entity_id || data.to_entity_id) && (
<div className="text-xs text-muted-foreground">
<Tag className="h-3 w-3 inline mr-1" />
Related entities: {data.from_entity_id ? 'From entity' : ''} {data.to_entity_id ? 'To entity' : ''}
</div>
)}
{(data.from_location_id || data.to_location_id) && (
<div className="text-xs text-muted-foreground">
<MapPin className="h-3 w-3 inline mr-1" />
Location change involved
</div>
)}
{data.description && (
<div>
<span className="font-medium text-sm">Description:</span>
<p className="text-sm text-muted-foreground mt-1">
{data.description}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,109 @@
import { memo } from 'react';
import { Loader2, Clock, Database, CheckCircle2, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
export type TransactionStatus =
| 'idle'
| 'processing'
| 'timeout'
| 'cached'
| 'completed'
| 'failed';
interface TransactionStatusIndicatorProps {
status: TransactionStatus;
message?: string;
className?: string;
showLabel?: boolean;
}
export const TransactionStatusIndicator = memo(({
status,
message,
className,
showLabel = true,
}: TransactionStatusIndicatorProps) => {
if (status === 'idle') return null;
const getStatusConfig = () => {
switch (status) {
case 'processing':
return {
icon: Loader2,
label: 'Processing',
description: 'Transaction in progress...',
variant: 'secondary' as const,
className: 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800',
iconClassName: 'animate-spin',
};
case 'timeout':
return {
icon: Clock,
label: 'Timeout',
description: message || 'Transaction timed out. Lock may have been auto-released.',
variant: 'destructive' as const,
className: 'bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950 dark:text-orange-200 dark:border-orange-800',
iconClassName: '',
};
case 'cached':
return {
icon: Database,
label: 'Cached',
description: message || 'Using cached result from duplicate request',
variant: 'outline' as const,
className: 'bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-200 dark:border-purple-800',
iconClassName: '',
};
case 'completed':
return {
icon: CheckCircle2,
label: 'Completed',
description: 'Transaction completed successfully',
variant: 'default' as const,
className: 'bg-green-100 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800',
iconClassName: '',
};
case 'failed':
return {
icon: XCircle,
label: 'Failed',
description: message || 'Transaction failed',
variant: 'destructive' as const,
className: '',
iconClassName: '',
};
default:
return null;
}
};
const config = getStatusConfig();
if (!config) return null;
const Icon = config.icon;
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={config.variant}
className={cn(
'flex items-center gap-1.5 px-2 py-1',
config.className,
className
)}
>
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
{showLabel && <span className="text-xs font-medium">{config.label}</span>}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm">{config.description}</p>
</TooltipContent>
</Tooltip>
);
});
TransactionStatusIndicator.displayName = 'TransactionStatusIndicator';

View File

@@ -0,0 +1,346 @@
import { useState, useEffect } from 'react';
import { Shield, UserPlus, X, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { handleError, handleNonCriticalError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
// Type-safe role definitions
const VALID_ROLES = ['admin', 'moderator', 'user'] as const;
type ValidRole = typeof VALID_ROLES[number];
/**
* Type guard to validate role strings
* Prevents unsafe casting and ensures type safety
*/
function isValidRole(role: string): role is ValidRole {
return VALID_ROLES.includes(role as ValidRole);
}
/**
* Get display label for a role
*/
function getRoleLabel(role: string): string {
const labels: Record<ValidRole, string> = {
admin: 'Administrator',
moderator: 'Moderator',
user: 'User',
};
return isValidRole(role) ? labels[role] : role;
}
interface ProfileSearchResult {
user_id: string;
username: string;
display_name?: string;
}
interface UserRole {
id: string;
user_id: string;
role: string;
granted_at: string;
profiles?: {
username: string;
display_name?: string;
};
}
export function UserRoleManager() {
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [newUserSearch, setNewUserSearch] = useState('');
const [newRole, setNewRole] = useState('');
const [searchResults, setSearchResults] = useState<ProfileSearchResult[]>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const {
user
} = useAuth();
const {
isAdmin,
isSuperuser,
permissions
} = useUserRole();
const fetchUserRoles = async () => {
try {
const {
data,
error
} = await supabase.from('user_roles').select(`
id,
user_id,
role,
granted_at
`).order('granted_at', {
ascending: false
});
if (error) throw error;
// Get unique user IDs
const userIds = [...new Set((data || []).map(r => r.user_id))];
// Fetch user profiles with emails (for admins)
let profiles: Array<{ user_id: string; username: string; display_name?: string }> | null = null;
const { data: allProfiles, error: rpcError } = await supabase
.rpc('get_users_with_emails');
if (rpcError) {
// Fall back to basic profiles
const { data: basicProfiles } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.in('user_id', userIds);
profiles = basicProfiles as typeof profiles;
} else {
profiles = allProfiles?.filter(p => userIds.includes(p.user_id)) || null;
}
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
// Combine data with profiles
const userRolesWithProfiles = (data || []).map(role => ({
...role,
profiles: profileMap.get(role.user_id)
}));
setUserRoles(userRolesWithProfiles);
} catch (error: unknown) {
handleError(error, {
action: 'Load User Roles',
userId: user?.id
});
} finally {
setLoading(false);
}
};
const searchUsers = async (search: string) => {
if (!search.trim()) {
setSearchResults([]);
return;
}
try {
let data;
const { data: allUsers, error: rpcError } = await supabase
.rpc('get_users_with_emails');
if (rpcError) {
// Fall back to basic profiles
const { data: basicProfiles, error: profilesError } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.ilike('username', `%${search}%`);
if (profilesError) throw profilesError;
data = basicProfiles?.slice(0, 10);
} else {
// Filter by search term
data = allUsers?.filter(user =>
user.username.toLowerCase().includes(search.toLowerCase()) ||
user.display_name?.toLowerCase().includes(search.toLowerCase())
).slice(0, 10);
}
// Filter out users who already have roles
const existingUserIds = userRoles.map(ur => ur.user_id);
const filteredResults = (data || []).filter(profile => !existingUserIds.includes(profile.user_id));
setSearchResults(filteredResults);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Search Users',
userId: user?.id,
metadata: { search }
});
}
};
useEffect(() => {
fetchUserRoles();
}, []);
useEffect(() => {
const debounceTimer = setTimeout(() => {
searchUsers(newUserSearch);
}, 300);
return () => clearTimeout(debounceTimer);
}, [newUserSearch, userRoles]);
const grantRole = async (userId: string, role: ValidRole) => {
if (!isAdmin()) return;
// Double-check role validity before database operation
if (!isValidRole(role)) {
handleError(new Error('Invalid role'), {
action: 'Grant Role',
userId: user?.id,
metadata: { targetUserId: userId, attemptedRole: role }
});
return;
}
setActionLoading('grant');
try {
const {
error
} = await supabase.from('user_roles').insert([{
user_id: userId,
role,
granted_by: user?.id
}]);
if (error) throw error;
handleSuccess('Role Granted', `User has been granted ${getRoleLabel(role)} role`);
setNewUserSearch('');
setNewRole('');
setSearchResults([]);
fetchUserRoles();
} catch (error: unknown) {
handleError(error, {
action: 'Grant Role',
userId: user?.id,
metadata: { targetUserId: userId, role }
});
} finally {
setActionLoading(null);
}
};
const revokeRole = async (roleId: string) => {
if (!isAdmin()) return;
setActionLoading(roleId);
try {
const {
error
} = await supabase.from('user_roles').delete().eq('id', roleId);
if (error) throw error;
handleSuccess('Role Revoked', 'User role has been revoked');
fetchUserRoles();
} catch (error: unknown) {
handleError(error, {
action: 'Revoke Role',
userId: user?.id,
metadata: { roleId }
});
} finally {
setActionLoading(null);
}
};
if (!isAdmin()) {
return <div className="text-center py-8">
<Shield className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Access Denied</h3>
<p className="text-muted-foreground">
Only administrators can manage user roles.
</p>
</div>;
}
if (loading) {
return <div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>;
}
const filteredRoles = userRoles.filter(role => role.profiles?.username?.toLowerCase().includes(searchTerm.toLowerCase()) || role.profiles?.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) || role.role.toLowerCase().includes(searchTerm.toLowerCase()));
return <div className="space-y-6">
{/* Add new role */}
<Card>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="user-search">Search Users</Label>
<div className="relative">
<Search className="absolute left-3 top-3 w-4 h-4 text-muted-foreground" />
<Input id="user-search" placeholder="Search by username or display name..." value={newUserSearch} onChange={e => setNewUserSearch(e.target.value)} className="pl-10" />
</div>
{searchResults.length > 0 && <div className="mt-2 border rounded-lg bg-background">
{searchResults.map(profile => <div key={profile.user_id} className="p-3 hover:bg-muted/50 cursor-pointer border-b last:border-b-0" onClick={() => {
setNewUserSearch(profile.display_name || profile.username);
setSearchResults([profile]);
}}>
<div className="font-medium">
{profile.display_name || profile.username}
</div>
{profile.display_name && <div className="text-sm text-muted-foreground">
@{profile.username}
</div>}
</div>)}
</div>}
</div>
<div>
<Label htmlFor="role-select">Role</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="moderator">Moderator</SelectItem>
{isSuperuser() && <SelectItem value="admin">Administrator</SelectItem>}
</SelectContent>
</Select>
</div>
</div>
<Button onClick={() => {
const selectedUser = searchResults.find(p => (p.display_name || p.username) === newUserSearch);
// Type-safe validation before calling grantRole
if (selectedUser && newRole && isValidRole(newRole)) {
grantRole(selectedUser.user_id, newRole);
} else if (selectedUser && newRole) {
// This should never happen due to Select component constraints,
// but provides safety in case of UI bugs
handleError(new Error('Invalid role selected'), {
action: 'Grant Role',
userId: user?.id,
metadata: { selectedUser: selectedUser?.user_id, newRole }
});
}
}} disabled={!newRole || !isValidRole(newRole) || !searchResults.find(p => (p.display_name || p.username) === newUserSearch) || actionLoading === 'grant'} className="w-full md:w-auto">
{actionLoading === 'grant' ? 'Granting...' : 'Grant Role'}
</Button>
</CardContent>
</Card>
{/* Search existing roles */}
<div>
<Label htmlFor="role-search">Search Existing Roles</Label>
<div className="relative">
<Search className="absolute left-3 top-3 w-4 h-4 text-muted-foreground" />
<Input id="role-search" placeholder="Search users with roles..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="pl-10" />
</div>
</div>
{/* User roles list */}
<div className="space-y-3">
{filteredRoles.length === 0 ? <div className="text-center py-8">
<Shield className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No roles found</h3>
<p className="text-muted-foreground">
{searchTerm ? 'No users match your search criteria.' : 'No user roles have been granted yet.'}
</p>
</div> : filteredRoles.map(userRole => <Card key={userRole.id}>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div>
<div className="font-medium">
{userRole.profiles?.display_name || userRole.profiles?.username}
</div>
{userRole.profiles?.display_name && <div className="text-sm text-muted-foreground">
@{userRole.profiles.username}
</div>}
</div>
<Badge variant={userRole.role === 'admin' ? 'default' : 'secondary'}>
{userRole.role}
</Badge>
</div>
{/* Only show revoke button if current user can manage this role */}
{(isSuperuser() || isAdmin() && !['admin', 'superuser'].includes(userRole.role)) && <Button variant="outline" size="sm" onClick={() => revokeRole(userRole.id)} disabled={actionLoading === userRole.id}>
<X className="w-4 h-4" />
</Button>}
</CardContent>
</Card>)}
</div>
</div>;
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { AlertCircle, ChevronDown } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ValidationError } from '@/lib/entityValidationSchemas';
interface ValidationBlockerDialogProps {
open: boolean;
onClose: () => void;
blockingErrors: ValidationError[];
itemNames: string[];
}
export function ValidationBlockerDialog({
open,
onClose,
blockingErrors,
itemNames,
}: ValidationBlockerDialogProps) {
const [showDetails, setShowDetails] = useState(false);
return (
<AlertDialog open={open} onOpenChange={onClose}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" />
Cannot Approve: Validation Errors
</AlertDialogTitle>
<AlertDialogDescription>
The following items have blocking validation errors that MUST be fixed before approval.
Edit the items to fix the errors, or reject them.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3 my-4">
{itemNames.map((name, index) => {
const itemErrors = blockingErrors.filter((_, i) =>
itemNames.length === 1 || i === index
);
return (
<div key={index} className="space-y-2">
<div className="font-medium text-sm flex items-center justify-between">
<span>{name}</span>
<Badge variant="destructive">
{itemErrors.length} error{itemErrors.length > 1 ? 's' : ''}
</Badge>
</div>
<Alert variant="destructive">
<AlertDescription className="space-y-1">
{itemErrors.map((error, errIndex) => (
<div key={errIndex} className="text-sm">
<span className="font-medium">{error.field}:</span> {error.message}
</div>
))}
</AlertDescription>
</Alert>
</div>
);
})}
</div>
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full">
{showDetails ? 'Hide' : 'Show'} Technical Details
<ChevronDown className={`ml-2 h-4 w-4 transition-transform ${showDetails ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<div className="bg-muted p-3 rounded text-xs font-mono max-h-60 overflow-auto">
<pre>{JSON.stringify(blockingErrors, null, 2)}</pre>
</div>
</CollapsibleContent>
</Collapsible>
<AlertDialogFooter>
<AlertDialogAction onClick={onClose}>
Close
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,307 @@
import { useEffect, useState, useMemo } from 'react';
import { AlertCircle, CheckCircle, Info, AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { RefreshButton } from '@/components/ui/refresh-button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
import { handleNonCriticalError } from '@/lib/errorHandler';
import type { SubmissionItemData } from '@/types/moderation';
interface ValidationSummaryProps {
item: {
item_type: string;
item_data: SubmissionItemData;
id?: string;
};
onValidationChange?: (result: ValidationResult) => void;
compact?: boolean;
validationKey?: number;
}
export function ValidationSummary({ item, onValidationChange, compact = false, validationKey }: ValidationSummaryProps) {
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [manualTriggerCount, setManualTriggerCount] = useState(0);
const [isRevalidating, setIsRevalidating] = useState(false);
// Helper to extract the correct entity ID based on entity type
const getEntityId = (
itemType: string,
itemData: SubmissionItemData | null | undefined,
fallbackId?: string
): string | undefined => {
// Guard against null/undefined itemData
if (!itemData) return fallbackId;
// Try entity-specific ID fields first
const entityIdField = `${itemType}_id`;
const typedData = itemData as unknown as Record<string, unknown>;
if (typeof typedData[entityIdField] === 'string') {
return typedData[entityIdField] as string;
}
// For companies, check company_id
if (['manufacturer', 'designer', 'operator', 'property_owner'].includes(itemType) &&
typeof typedData.company_id === 'string') {
return typedData.company_id;
}
// Fall back to generic id field or provided fallback
if (typeof typedData.id === 'string') {
return typedData.id;
}
return fallbackId;
};
// Create stable reference for item_data to prevent unnecessary re-validations
const itemDataString = useMemo(
() => JSON.stringify(item.item_data),
[item.item_data]
);
useEffect(() => {
async function validate() {
setIsLoading(true);
try {
// Type guard for valid entity types
type ValidEntityType = 'park' | 'ride' | 'manufacturer' | 'operator' | 'designer' | 'property_owner' | 'ride_model' | 'photo' | 'milestone' | 'timeline_event';
const validEntityTypes: ValidEntityType[] = ['park', 'ride', 'manufacturer', 'operator', 'designer', 'property_owner', 'ride_model', 'photo', 'milestone', 'timeline_event'];
if (!validEntityTypes.includes(item.item_type as ValidEntityType)) {
setValidationResult({
isValid: false,
blockingErrors: [{ field: 'item_type', message: `Invalid entity type: ${item.item_type}`, severity: 'blocking' }],
warnings: [],
suggestions: [],
allErrors: [{ field: 'item_type', message: `Invalid entity type: ${item.item_type}`, severity: 'blocking' }],
});
setIsLoading(false);
return;
}
const result = await validateEntityData(
item.item_type as ValidEntityType,
{
...(item.item_data || {}), // Add null coalescing
id: getEntityId(item.item_type, item.item_data, item.id)
}
);
setValidationResult(result);
onValidationChange?.(result);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Validate entity',
metadata: { entityType: item.item_type }
});
setValidationResult({
isValid: false,
blockingErrors: [{ field: 'validation', message: 'Failed to validate', severity: 'blocking' }],
warnings: [],
suggestions: [],
allErrors: [{ field: 'validation', message: 'Failed to validate', severity: 'blocking' }],
});
} finally {
setIsLoading(false);
}
}
validate();
}, [item.item_type, itemDataString, item.id, validationKey, manualTriggerCount]);
// Auto-expand when there are blocking errors or warnings
useEffect(() => {
if (validationResult && (validationResult.blockingErrors.length > 0 || validationResult.warnings.length > 0)) {
setIsExpanded(true);
}
}, [validationResult]);
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-primary border-t-transparent" />
<span>Validating...</span>
</div>
);
}
if (!validationResult) {
return null;
}
const hasBlockingErrors = validationResult.blockingErrors.length > 0;
const hasWarnings = validationResult.warnings.length > 0;
const hasSuggestions = validationResult.suggestions.length > 0;
const hasAnyIssues = hasBlockingErrors || hasWarnings || hasSuggestions;
// Compact view (for queue items) - NO HOVER, ALWAYS VISIBLE
if (compact) {
return (
<div className="space-y-2">
{/* Status Badges */}
<div className="flex items-center gap-2 flex-wrap">
{validationResult.isValid && !hasWarnings && !hasSuggestions && (
<Badge variant="secondary" className="bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-300 dark:border-green-700">
<CheckCircle className="w-3 h-3 mr-1" />
Valid
</Badge>
)}
{hasBlockingErrors && (
<Badge variant="destructive">
<AlertCircle className="w-3 h-3 mr-1" />
{validationResult.blockingErrors.length} Error{validationResult.blockingErrors.length !== 1 ? 's' : ''}
</Badge>
)}
{hasWarnings && (
<Badge variant="outline" className="bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700">
<AlertTriangle className="w-3 h-3 mr-1" />
{validationResult.warnings.length} Warning{validationResult.warnings.length !== 1 ? 's' : ''}
</Badge>
)}
</div>
{/* ALWAYS SHOW ERROR DETAILS - NO HOVER NEEDED */}
{hasBlockingErrors && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2">
<p className="text-xs font-semibold text-red-800 dark:text-red-300 mb-1">Blocking Errors:</p>
<ul className="text-xs space-y-0.5 text-red-700 dark:text-red-400">
{validationResult.blockingErrors.map((error, i) => (
<li key={i}>
<span className="font-medium">{error.field}:</span> {error.message}
</li>
))}
</ul>
</div>
)}
{hasWarnings && !hasBlockingErrors && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-2">
<p className="text-xs font-semibold text-yellow-800 dark:text-yellow-300 mb-1">Warnings:</p>
<ul className="text-xs space-y-0.5 text-yellow-700 dark:text-yellow-400">
{validationResult.warnings.slice(0, 3).map((warning, i) => (
<li key={i}>
<span className="font-medium">{warning.field}:</span> {warning.message}
</li>
))}
{validationResult.warnings.length > 3 && (
<li className="italic">... and {validationResult.warnings.length - 3} more</li>
)}
</ul>
</div>
)}
</div>
);
}
// Detailed view (for review manager)
return (
<div className="space-y-3">
{/* Summary Badge */}
<div className="flex items-center gap-2 flex-wrap">
{validationResult.isValid && !hasWarnings && !hasSuggestions && (
<Badge variant="secondary" className="bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-300 dark:border-green-700">
<CheckCircle className="w-4 h-4 mr-1" />
All Valid
</Badge>
)}
{hasBlockingErrors && (
<Badge variant="destructive" className="text-sm">
<AlertCircle className="w-4 h-4 mr-1" />
{validationResult.blockingErrors.length} Blocking Error{validationResult.blockingErrors.length !== 1 ? 's' : ''}
</Badge>
)}
{hasWarnings && (
<Badge variant="outline" className="bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700 text-sm">
<AlertTriangle className="w-4 h-4 mr-1" />
{validationResult.warnings.length} Warning{validationResult.warnings.length !== 1 ? 's' : ''}
</Badge>
)}
{hasSuggestions && (
<Badge variant="outline" className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-300 dark:border-blue-700 text-sm">
<Info className="w-4 h-4 mr-1" />
{validationResult.suggestions.length} Suggestion{validationResult.suggestions.length !== 1 ? 's' : ''}
</Badge>
)}
<RefreshButton
onRefresh={async () => {
setIsRevalidating(true);
try {
setManualTriggerCount(prev => prev + 1);
// Short delay to show feedback
await new Promise(resolve => setTimeout(resolve, 500));
} finally {
setIsRevalidating(false);
}
}}
isLoading={isRevalidating}
size="sm"
variant="outline"
className="text-xs h-7"
>
Re-validate
</RefreshButton>
</div>
{/* Detailed Issues */}
{hasAnyIssues && (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger className="text-sm text-muted-foreground hover:text-foreground transition-colors">
{isExpanded ? 'Hide' : 'Show'} validation details
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 mt-2">
{/* Blocking Errors */}
{hasBlockingErrors && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Blocking Errors</AlertTitle>
<AlertDescription className="space-y-1 mt-2">
{validationResult.blockingErrors.map((error, index) => (
<div key={index} className="text-sm">
<span className="font-medium">{error.field}:</span> {error.message}
</div>
))}
</AlertDescription>
</Alert>
)}
{/* Warnings */}
{hasWarnings && (
<Alert className="border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20">
<AlertTriangle className="h-4 w-4 text-yellow-800 dark:text-yellow-300" />
<AlertTitle className="text-yellow-800 dark:text-yellow-300">Warnings</AlertTitle>
<AlertDescription className="space-y-1 mt-2 text-yellow-800 dark:text-yellow-300">
{validationResult.warnings.map((warning, index) => (
<div key={index} className="text-sm">
<span className="font-medium">{warning.field}:</span> {warning.message}
</div>
))}
</AlertDescription>
</Alert>
)}
{/* Suggestions */}
{hasSuggestions && (
<Alert className="border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20">
<Info className="h-4 w-4 text-blue-800 dark:text-blue-300" />
<AlertTitle className="text-blue-800 dark:text-blue-300">Suggestions</AlertTitle>
<AlertDescription className="space-y-1 mt-2 text-blue-800 dark:text-blue-300">
{validationResult.suggestions.map((suggestion, index) => (
<div key={index} className="text-sm">
<span className="font-medium">{suggestion.field}:</span> {suggestion.message}
</div>
))}
</AlertDescription>
</Alert>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { AlertTriangle } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ValidationError } from '@/lib/entityValidationSchemas';
interface WarningConfirmDialogProps {
open: boolean;
onClose: () => void;
onProceed: () => void;
warnings: ValidationError[];
itemNames: string[];
}
export function WarningConfirmDialog({
open,
onClose,
onProceed,
warnings,
itemNames,
}: WarningConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-yellow-800 dark:text-yellow-300">
<AlertTriangle className="w-5 h-5" />
Validation Warnings
</AlertDialogTitle>
<AlertDialogDescription>
The following items have validation warnings. You can proceed with approval, but fixing these issues will improve content quality:
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3 my-4">
{itemNames.map((name, index) => (
<div key={index} className="space-y-2">
<div className="font-medium text-sm">{name}</div>
<Alert className="border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20">
<AlertDescription className="space-y-1 text-yellow-800 dark:text-yellow-300">
{warnings
.filter((_, i) => i === index || itemNames.length === 1)
.map((warning, warnIndex) => (
<div key={warnIndex} className="text-sm">
<span className="font-medium">{warning.field}:</span> {warning.message}
</div>
))}
</AlertDescription>
</Alert>
</div>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>
Go Back to Edit
</AlertDialogCancel>
<AlertDialogAction onClick={onProceed}>
Proceed with Approval
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,197 @@
import { Building, MapPin, Calendar, Globe, ExternalLink, AlertCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
import type { DatePrecision } from '@/components/ui/flexible-date-input';
import type { CompanySubmissionData } from '@/types/submission-data';
interface RichCompanyDisplayProps {
data: CompanySubmissionData;
actionType: 'create' | 'edit' | 'delete';
showAllFields?: boolean;
}
export function RichCompanyDisplay({ data, actionType, showAllFields = true }: RichCompanyDisplayProps) {
const getCompanyTypeColor = (type: string | undefined) => {
if (!type) return 'bg-gray-500';
switch (type.toLowerCase()) {
case 'manufacturer': return 'bg-blue-500';
case 'operator': return 'bg-green-500';
case 'designer': return 'bg-purple-500';
case 'property_owner': return 'bg-orange-500';
default: return 'bg-gray-500';
}
};
return (
<div className="space-y-4">
{/* Header Section */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Building className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground truncate">{data.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge className={`${getCompanyTypeColor(data.company_type)} text-white text-xs`}>
{(data.company_type || 'Unknown')?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
{data.person_type && (
<Badge variant="outline" className="text-xs">
{data.person_type.replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
)}
{actionType === 'create' && (
<Badge className="bg-green-600 text-white text-xs">New Company</Badge>
)}
{actionType === 'edit' && (
<Badge className="bg-amber-600 text-white text-xs">Edit</Badge>
)}
{actionType === 'delete' && (
<Badge variant="destructive" className="text-xs">Delete</Badge>
)}
</div>
</div>
</div>
{/* Key Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Founded Date */}
{(data.founded_date || data.founded_year) && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Founded</span>
</div>
<div className="text-sm ml-6">
{data.founded_date ? (
<FlexibleDateDisplay
date={data.founded_date}
precision={(data.founded_date_precision as DatePrecision) || 'day'}
className="font-medium"
/>
) : (
<span className="font-medium">{data.founded_year}</span>
)}
</div>
</div>
)}
{/* Location */}
{data.headquarters_location && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Headquarters</span>
</div>
<div className="text-sm font-medium ml-6">
{data.headquarters_location}
</div>
</div>
)}
</div>
{/* Description */}
{data.description && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="text-sm font-semibold mb-2">Description</div>
<div className="text-sm text-muted-foreground leading-relaxed">
{data.description}
</div>
</div>
)}
{/* Website & Source */}
{(data.website_url || data.source_url) && (
<div className="flex items-center gap-3 flex-wrap">
{data.website_url && (
<a
href={data.website_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Globe className="h-3.5 w-3.5" />
Official Website
<ExternalLink className="h-3 w-3" />
</a>
)}
{data.source_url && (
<a
href={data.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"
>
Source
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
)}
{/* Submission Notes */}
{data.submission_notes && (
<div className="bg-amber-50 dark:bg-amber-950 rounded-lg p-3 border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-semibold text-amber-900 dark:text-amber-100">Submitter Notes</span>
</div>
<div className="text-sm text-amber-800 dark:text-amber-200 ml-6">
{data.submission_notes}
</div>
</div>
)}
{/* Images Preview */}
{(data.logo_url || data.banner_image_url || data.card_image_url) && (
<div className="space-y-2">
<Separator />
<div className="text-sm font-semibold">Images</div>
<div className="grid grid-cols-2 gap-2">
{data.logo_url && (
<div className="space-y-1">
<img
src={data.logo_url}
alt="Logo"
className="w-full h-24 object-contain bg-muted rounded border p-2"
/>
<div className="text-xs text-center text-muted-foreground">Logo</div>
</div>
)}
{data.banner_image_url && (
<div className="space-y-1">
<img
src={data.banner_image_url}
alt="Banner"
className="w-full h-24 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Banner
{data.banner_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.banner_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
{data.card_image_url && (
<div className="space-y-1">
<img
src={data.card_image_url}
alt="Card"
className="w-full h-24 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Card
{data.card_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.card_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,305 @@
import { Building2, MapPin, Calendar, Globe, ExternalLink, Users, AlertCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
import type { DatePrecision } from '@/components/ui/flexible-date-input';
import type { ParkSubmissionData } from '@/types/submission-data';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface RichParkDisplayProps {
data: ParkSubmissionData;
actionType: 'create' | 'edit' | 'delete';
showAllFields?: boolean;
}
export function RichParkDisplay({ data, actionType, showAllFields = true }: RichParkDisplayProps) {
const [location, setLocation] = useState<any>(null);
const [operator, setOperator] = useState<string | null>(null);
const [propertyOwner, setPropertyOwner] = useState<string | null>(null);
useEffect(() => {
// Guard against null/undefined data
if (!data) return;
const fetchRelatedData = async () => {
// Fetch location if location_id exists (for edits)
if (data.location_id) {
const { data: locationData } = await supabase
.from('locations')
.select('*')
.eq('id', data.location_id)
.single();
setLocation(locationData);
}
// Otherwise fetch from park_submission_locations (for new submissions)
else if (data.id) {
const { data: locationData } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', data.id)
.maybeSingle();
setLocation(locationData);
}
// Fetch operator
if (data.operator_id) {
const { data: operatorData } = await supabase
.from('companies')
.select('name')
.eq('id', data.operator_id)
.single();
setOperator(operatorData?.name || null);
}
// Fetch property owner
if (data.property_owner_id) {
const { data: ownerData } = await supabase
.from('companies')
.select('name')
.eq('id', data.property_owner_id)
.single();
setPropertyOwner(ownerData?.name || null);
}
};
fetchRelatedData();
}, [data.location_id, data.id, data.operator_id, data.property_owner_id]);
const getStatusColor = (status: string | undefined) => {
if (!status) return 'bg-gray-500';
switch (status.toLowerCase()) {
case 'operating': return 'bg-green-500';
case 'closed': return 'bg-red-500';
case 'under_construction': return 'bg-blue-500';
case 'planned': return 'bg-purple-500';
default: return 'bg-gray-500';
}
};
return (
<div className="space-y-4">
{/* Header Section */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Building2 className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground truncate">{data.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant="secondary" className="text-xs">
{data.park_type?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
<Badge className={`${getStatusColor(data.status)} text-white text-xs`}>
{data.status?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
{actionType === 'create' && (
<Badge className="bg-green-600 text-white text-xs">New Park</Badge>
)}
{actionType === 'edit' && (
<Badge className="bg-amber-600 text-white text-xs">Edit</Badge>
)}
{actionType === 'delete' && (
<Badge variant="destructive" className="text-xs">Delete</Badge>
)}
</div>
</div>
</div>
{/* Location Section */}
{location && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold text-foreground">Location</span>
</div>
<div className="text-sm space-y-1 ml-6">
{location.street_address && <div><span className="text-muted-foreground">Street:</span> <span className="font-medium">{location.street_address}</span></div>}
{location.city && <div><span className="text-muted-foreground">City:</span> <span className="font-medium">{location.city}</span></div>}
{location.state_province && <div><span className="text-muted-foreground">State/Province:</span> <span className="font-medium">{location.state_province}</span></div>}
{location.country && <div><span className="text-muted-foreground">Country:</span> <span className="font-medium">{location.country}</span></div>}
{location.postal_code && <div><span className="text-muted-foreground">Postal Code:</span> <span className="font-medium">{location.postal_code}</span></div>}
{location.formatted_address && (
<div className="text-xs text-muted-foreground mt-2">{location.formatted_address}</div>
)}
</div>
</div>
)}
{/* Key Information Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Contact Information */}
{(data.phone || data.email) && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Contact</span>
</div>
<div className="text-sm space-y-1 ml-6">
{data.phone && (
<div>
<span className="text-muted-foreground">Phone:</span>{' '}
<a href={`tel:${data.phone}`} className="font-medium hover:underline">{data.phone}</a>
</div>
)}
{data.email && (
<div>
<span className="text-muted-foreground">Email:</span>{' '}
<a href={`mailto:${data.email}`} className="font-medium hover:underline">{data.email}</a>
</div>
)}
</div>
</div>
)}
{/* Dates */}
{(data.opening_date || data.closing_date) && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Dates</span>
</div>
<div className="text-sm space-y-1 ml-6">
{data.opening_date && (
<div>
<span className="text-muted-foreground">Opened:</span>{' '}
<FlexibleDateDisplay
date={data.opening_date}
precision={(data.opening_date_precision as DatePrecision) || 'day'}
className="font-medium"
/>
</div>
)}
{data.closing_date && (
<div>
<span className="text-muted-foreground">Closed:</span>{' '}
<FlexibleDateDisplay
date={data.closing_date}
precision={(data.closing_date_precision as DatePrecision) || 'day'}
className="font-medium"
/>
</div>
)}
</div>
</div>
)}
{/* Companies */}
{(operator || propertyOwner) && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Companies</span>
</div>
<div className="text-sm space-y-1 ml-6">
{operator && (
<div>
<span className="text-muted-foreground">Operator:</span>{' '}
<span className="font-medium">{operator}</span>
</div>
)}
{propertyOwner && (
<div>
<span className="text-muted-foreground">Owner:</span>{' '}
<span className="font-medium">{propertyOwner}</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Description */}
{data.description && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="text-sm font-semibold mb-2">Description</div>
<div className="text-sm text-muted-foreground leading-relaxed">
{data.description}
</div>
</div>
)}
{/* Website & Source */}
{(data.website_url || data.source_url) && (
<div className="flex items-center gap-3 flex-wrap">
{data.website_url && (
<a
href={data.website_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Globe className="h-3.5 w-3.5" />
Official Website
<ExternalLink className="h-3 w-3" />
</a>
)}
{data.source_url && (
<a
href={data.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"
>
Source
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
)}
{/* Submission Notes */}
{data.submission_notes && (
<div className="bg-amber-50 dark:bg-amber-950 rounded-lg p-3 border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-semibold text-amber-900 dark:text-amber-100">Submitter Notes</span>
</div>
<div className="text-sm text-amber-800 dark:text-amber-200 ml-6">
{data.submission_notes}
</div>
</div>
)}
{/* Images Preview */}
{(data.banner_image_url || data.card_image_url) && (
<div className="space-y-2">
<Separator />
<div className="text-sm font-semibold">Images</div>
<div className="grid grid-cols-2 gap-2">
{data.banner_image_url && (
<div className="space-y-1">
<img
src={data.banner_image_url}
alt="Banner"
className="w-full h-24 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Banner
{data.banner_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.banner_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
{data.card_image_url && (
<div className="space-y-1">
<img
src={data.card_image_url}
alt="Card"
className="w-full h-24 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Card
{data.card_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.card_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,715 @@
import { Train, Gauge, Ruler, Zap, Calendar, Building, User, ExternalLink, AlertCircle, TrendingUp, Droplets, Sparkles, RotateCw, Baby, Navigation } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
import type { DatePrecision } from '@/components/ui/flexible-date-input';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { RideSubmissionData } from '@/types/submission-data';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface RichRideDisplayProps {
data: RideSubmissionData;
actionType: 'create' | 'edit' | 'delete';
showAllFields?: boolean;
}
export function RichRideDisplay({ data, actionType, showAllFields = true }: RichRideDisplayProps) {
const [park, setPark] = useState<string | null>(null);
const [manufacturer, setManufacturer] = useState<string | null>(null);
const [designer, setDesigner] = useState<string | null>(null);
const [model, setModel] = useState<string | null>(null);
const [showCategorySpecific, setShowCategorySpecific] = useState(false);
const [showTechnical, setShowTechnical] = useState(false);
useEffect(() => {
// Guard against null/undefined data
if (!data) return;
const fetchRelatedData = async () => {
if (data.park_id) {
const { data: parkData } = await supabase
.from('parks')
.select('name')
.eq('id', data.park_id)
.single();
setPark(parkData?.name || null);
}
if (data.manufacturer_id) {
const { data: mfgData } = await supabase
.from('companies')
.select('name')
.eq('id', data.manufacturer_id)
.single();
setManufacturer(mfgData?.name || null);
}
if (data.designer_id) {
const { data: designerData } = await supabase
.from('companies')
.select('name')
.eq('id', data.designer_id)
.single();
setDesigner(designerData?.name || null);
}
if (data.ride_model_id) {
const { data: modelData } = await supabase
.from('ride_models')
.select('name')
.eq('id', data.ride_model_id)
.single();
setModel(modelData?.name || null);
}
};
fetchRelatedData();
}, [data.park_id, data.manufacturer_id, data.designer_id, data.ride_model_id]);
const getStatusColor = (status: string | undefined) => {
if (!status) return 'bg-gray-500';
switch (status.toLowerCase()) {
case 'operating': return 'bg-green-500';
case 'closed': return 'bg-red-500';
case 'under_construction': return 'bg-blue-500';
case 'sbno': return 'bg-orange-500';
default: return 'bg-gray-500';
}
};
// Determine which category-specific section to show
const category = data.category?.toLowerCase();
const hasWaterFields = category === 'water_ride' && (data.water_depth_cm || data.splash_height_meters || data.wetness_level || data.flume_type || data.boat_capacity);
const hasDarkRideFields = category === 'dark_ride' && (data.theme_name || data.story_description || data.show_duration_seconds || data.animatronics_count || data.projection_type || data.ride_system || data.scenes_count);
const hasFlatRideFields = category === 'flat_ride' && (data.rotation_type || data.motion_pattern || data.platform_count || data.swing_angle_degrees || data.rotation_speed_rpm || data.arm_length_meters || data.max_height_reached_meters);
const hasKiddieFields = category === 'kiddie_ride' && (data.min_age || data.max_age || data.educational_theme || data.character_theme);
const hasTransportFields = category === 'transport_ride' && (data.transport_type || data.route_length_meters || data.stations_count || data.vehicle_capacity || data.vehicles_count || data.round_trip_duration_seconds);
const hasTechnicalFields = data.track_material || data.support_material || data.propulsion_method || data.coaster_type || data.seating_type || data.intensity_level;
return (
<div className="space-y-4">
{/* Header Section */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Train className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground truncate">{data.name}</h3>
{park && (
<div className="text-sm text-muted-foreground mt-0.5">at {park}</div>
)}
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant="secondary" className="text-xs">
{data.category?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
{data.ride_sub_type && (
<Badge variant="outline" className="text-xs">
{data.ride_sub_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
)}
<Badge className={`${getStatusColor(data.status)} text-white text-xs`}>
{data.status?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
{actionType === 'create' && (
<Badge className="bg-green-600 text-white text-xs">New Ride</Badge>
)}
{actionType === 'edit' && (
<Badge className="bg-amber-600 text-white text-xs">Edit</Badge>
)}
{actionType === 'delete' && (
<Badge variant="destructive" className="text-xs">Delete</Badge>
)}
</div>
</div>
</div>
{/* Primary Statistics Grid */}
{(data.max_height_meters || data.max_speed_kmh || data.length_meters || data.drop_height_meters || data.duration_seconds || data.inversions !== null) && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{data.max_height_meters && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="h-4 w-4 text-blue-500" />
<span className="text-xs text-muted-foreground">Height</span>
</div>
<div className="text-lg font-bold">{data.max_height_meters.toFixed(1)} m</div>
</div>
)}
{data.max_speed_kmh && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Zap className="h-4 w-4 text-yellow-500" />
<span className="text-xs text-muted-foreground">Speed</span>
</div>
<div className="text-lg font-bold">{data.max_speed_kmh.toFixed(1)} km/h</div>
</div>
)}
{data.length_meters && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Ruler className="h-4 w-4 text-green-500" />
<span className="text-xs text-muted-foreground">Length</span>
</div>
<div className="text-lg font-bold">{data.length_meters.toFixed(1)} m</div>
</div>
)}
{data.drop_height_meters && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="h-4 w-4 text-purple-500" />
<span className="text-xs text-muted-foreground">Drop</span>
</div>
<div className="text-lg font-bold">{data.drop_height_meters.toFixed(1)} m</div>
</div>
)}
{data.duration_seconds && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Gauge className="h-4 w-4 text-orange-500" />
<span className="text-xs text-muted-foreground">Duration</span>
</div>
<div className="text-lg font-bold">{Math.floor(data.duration_seconds / 60)}:{(data.duration_seconds % 60).toString().padStart(2, '0')}</div>
</div>
)}
{data.inversions !== null && data.inversions !== undefined && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Train className="h-4 w-4 text-red-500" />
<span className="text-xs text-muted-foreground">Inversions</span>
</div>
<div className="text-lg font-bold">{data.inversions}</div>
</div>
)}
</div>
)}
{/* Requirements & Capacity */}
{(data.height_requirement || data.age_requirement || data.capacity_per_hour || data.max_g_force) && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="text-sm font-semibold mb-3">Requirements & Capacity</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
{data.height_requirement && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Height Requirement</span>
<span className="font-medium">{data.height_requirement} cm</span>
</div>
)}
{data.age_requirement && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Min Age</span>
<span className="font-medium">{data.age_requirement}+</span>
</div>
)}
{data.capacity_per_hour && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Capacity/Hour</span>
<span className="font-medium">{data.capacity_per_hour}</span>
</div>
)}
{data.max_g_force && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Max G-Force</span>
<span className="font-medium">{data.max_g_force}g</span>
</div>
)}
</div>
</div>
)}
{/* Technical Details (Collapsible) */}
{hasTechnicalFields && (
<Collapsible open={showTechnical} onOpenChange={setShowTechnical}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{showTechnical ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Building className="h-4 w-4" />
<span>Technical Specifications</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
{data.track_material && data.track_material.length > 0 && (
<div>
<span className="text-sm text-muted-foreground block mb-1.5">Track Material</span>
<div className="flex flex-wrap gap-1.5">
{data.track_material.map((material, i) => (
<Badge key={i} variant="outline" className="text-xs">
{material.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
))}
</div>
</div>
)}
{data.support_material && data.support_material.length > 0 && (
<div>
<span className="text-sm text-muted-foreground block mb-1.5">Support Material</span>
<div className="flex flex-wrap gap-1.5">
{data.support_material.map((material, i) => (
<Badge key={i} variant="outline" className="text-xs">
{material.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
))}
</div>
</div>
)}
{data.propulsion_method && data.propulsion_method.length > 0 && (
<div>
<span className="text-sm text-muted-foreground block mb-1.5">Propulsion Method</span>
<div className="flex flex-wrap gap-1.5">
{data.propulsion_method.map((method, i) => (
<Badge key={i} variant="outline" className="text-xs">
{method.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 text-sm pt-2">
{data.coaster_type && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Coaster Type</span>
<span className="font-medium">{data.coaster_type.replace(/_/g, ' ')}</span>
</div>
)}
{data.seating_type && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Seating Type</span>
<span className="font-medium">{data.seating_type.replace(/_/g, ' ')}</span>
</div>
)}
{data.intensity_level && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Intensity Level</span>
<Badge variant="secondary">{data.intensity_level.replace(/_/g, ' ').toUpperCase()}</Badge>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Water Ride Fields */}
{hasWaterFields && (
<Collapsible open={showCategorySpecific} onOpenChange={setShowCategorySpecific}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{showCategorySpecific ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Droplets className="h-4 w-4 text-blue-500" />
<span>Water Ride Specifications</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-blue-50 dark:bg-blue-950/30 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div className="grid grid-cols-2 gap-3 text-sm">
{data.water_depth_cm && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Water Depth</span>
<span className="font-medium">{data.water_depth_cm} cm</span>
</div>
)}
{data.splash_height_meters && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Splash Height</span>
<span className="font-medium">{data.splash_height_meters.toFixed(1)} m</span>
</div>
)}
{data.wetness_level && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Wetness Level</span>
<Badge variant="secondary">{data.wetness_level.toUpperCase()}</Badge>
</div>
)}
{data.flume_type && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Flume Type</span>
<span className="font-medium">{data.flume_type.replace(/_/g, ' ')}</span>
</div>
)}
{data.boat_capacity && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Boat Capacity</span>
<span className="font-medium">{data.boat_capacity} riders</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Dark Ride Fields */}
{hasDarkRideFields && (
<Collapsible open={showCategorySpecific} onOpenChange={setShowCategorySpecific}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{showCategorySpecific ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Sparkles className="h-4 w-4 text-purple-500" />
<span>Dark Ride Details</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-purple-50 dark:bg-purple-950/30 rounded-lg p-4 border border-purple-200 dark:border-purple-800 space-y-3">
{data.theme_name && (
<div>
<span className="text-sm text-muted-foreground block mb-1">Theme Name</span>
<span className="font-medium">{data.theme_name}</span>
</div>
)}
{data.story_description && (
<div>
<span className="text-sm text-muted-foreground block mb-1">Story Description</span>
<p className="text-sm text-foreground">{data.story_description}</p>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm pt-2">
{data.show_duration_seconds && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Show Duration</span>
<span className="font-medium">{Math.floor(data.show_duration_seconds / 60)}:{(data.show_duration_seconds % 60).toString().padStart(2, '0')}</span>
</div>
)}
{data.animatronics_count && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Animatronics</span>
<span className="font-medium">{data.animatronics_count}</span>
</div>
)}
{data.scenes_count && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Scenes</span>
<span className="font-medium">{data.scenes_count}</span>
</div>
)}
{data.projection_type && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Projection Type</span>
<span className="font-medium">{data.projection_type.replace(/_/g, ' ')}</span>
</div>
)}
{data.ride_system && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Ride System</span>
<span className="font-medium">{data.ride_system.replace(/_/g, ' ')}</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Flat Ride Fields */}
{hasFlatRideFields && (
<Collapsible open={showCategorySpecific} onOpenChange={setShowCategorySpecific}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{showCategorySpecific ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<RotateCw className="h-4 w-4 text-orange-500" />
<span>Flat Ride Specifications</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-orange-50 dark:bg-orange-950/30 rounded-lg p-4 border border-orange-200 dark:border-orange-800">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
{data.rotation_type && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Rotation Type</span>
<span className="font-medium">{data.rotation_type.replace(/_/g, ' ')}</span>
</div>
)}
{data.motion_pattern && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Motion Pattern</span>
<span className="font-medium">{data.motion_pattern.replace(/_/g, ' ')}</span>
</div>
)}
{data.platform_count && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Platforms</span>
<span className="font-medium">{data.platform_count}</span>
</div>
)}
{data.swing_angle_degrees && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Swing Angle</span>
<span className="font-medium">{data.swing_angle_degrees}°</span>
</div>
)}
{data.rotation_speed_rpm && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Rotation Speed</span>
<span className="font-medium">{data.rotation_speed_rpm} RPM</span>
</div>
)}
{data.arm_length_meters && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Arm Length</span>
<span className="font-medium">{data.arm_length_meters.toFixed(1)} m</span>
</div>
)}
{data.max_height_reached_meters && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Max Height Reached</span>
<span className="font-medium">{data.max_height_reached_meters.toFixed(1)} m</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Kiddie Ride Fields */}
{hasKiddieFields && (
<Collapsible open={showCategorySpecific} onOpenChange={setShowCategorySpecific}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{showCategorySpecific ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Baby className="h-4 w-4 text-pink-500" />
<span>Kiddie Ride Details</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-pink-50 dark:bg-pink-950/30 rounded-lg p-4 border border-pink-200 dark:border-pink-800">
<div className="grid grid-cols-2 gap-3 text-sm">
{data.min_age && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Min Age</span>
<span className="font-medium">{data.min_age}</span>
</div>
)}
{data.max_age && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Max Age</span>
<span className="font-medium">{data.max_age}</span>
</div>
)}
{data.educational_theme && (
<div className="col-span-2">
<span className="text-muted-foreground block text-xs mb-1">Educational Theme</span>
<span className="font-medium">{data.educational_theme}</span>
</div>
)}
{data.character_theme && (
<div className="col-span-2">
<span className="text-muted-foreground block text-xs mb-1">Character Theme</span>
<span className="font-medium">{data.character_theme}</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Transport Ride Fields */}
{hasTransportFields && (
<Collapsible open={showCategorySpecific} onOpenChange={setShowCategorySpecific}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full">
{showCategorySpecific ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Navigation className="h-4 w-4 text-teal-500" />
<span>Transport Ride Specifications</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-teal-50 dark:bg-teal-950/30 rounded-lg p-4 border border-teal-200 dark:border-teal-800">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
{data.transport_type && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Transport Type</span>
<span className="font-medium">{data.transport_type.replace(/_/g, ' ')}</span>
</div>
)}
{data.route_length_meters && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Route Length</span>
<span className="font-medium">{data.route_length_meters.toFixed(0)} m</span>
</div>
)}
{data.stations_count && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Stations</span>
<span className="font-medium">{data.stations_count}</span>
</div>
)}
{data.vehicle_capacity && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Vehicle Capacity</span>
<span className="font-medium">{data.vehicle_capacity}</span>
</div>
)}
{data.vehicles_count && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Total Vehicles</span>
<span className="font-medium">{data.vehicles_count}</span>
</div>
)}
{data.round_trip_duration_seconds && (
<div>
<span className="text-muted-foreground block text-xs mb-1">Round Trip</span>
<span className="font-medium">{Math.floor(data.round_trip_duration_seconds / 60)}:{(data.round_trip_duration_seconds % 60).toString().padStart(2, '0')}</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Companies & Model */}
{(manufacturer || designer || model) && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Building className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Companies & Model</span>
</div>
<div className="text-sm space-y-1 ml-6">
{manufacturer && (
<div>
<span className="text-muted-foreground">Manufacturer:</span>{' '}
<span className="font-medium">{manufacturer}</span>
</div>
)}
{designer && (
<div>
<span className="text-muted-foreground">Designer:</span>{' '}
<span className="font-medium">{designer}</span>
</div>
)}
{model && (
<div>
<span className="text-muted-foreground">Model:</span>{' '}
<span className="font-medium">{model}</span>
</div>
)}
</div>
</div>
)}
{/* Dates */}
{(data.opening_date || data.closing_date) && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Dates</span>
</div>
<div className="text-sm space-y-1 ml-6">
{data.opening_date && (
<div>
<span className="text-muted-foreground">Opened:</span>{' '}
<FlexibleDateDisplay
date={data.opening_date}
precision={(data.opening_date_precision as DatePrecision) || 'day'}
className="font-medium"
/>
</div>
)}
{data.closing_date && (
<div>
<span className="text-muted-foreground">Closed:</span>{' '}
<FlexibleDateDisplay
date={data.closing_date}
precision={(data.closing_date_precision as DatePrecision) || 'day'}
className="font-medium"
/>
</div>
)}
</div>
</div>
)}
{/* Description */}
{data.description && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="text-sm font-semibold mb-2">Description</div>
<div className="text-sm text-muted-foreground leading-relaxed">
{data.description}
</div>
</div>
)}
{/* Source */}
{data.source_url && (
<a
href={data.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"
>
Source
<ExternalLink className="h-3 w-3" />
</a>
)}
{/* Submission Notes */}
{data.submission_notes && (
<div className="bg-amber-50 dark:bg-amber-950 rounded-lg p-3 border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-semibold text-amber-900 dark:text-amber-100">Submitter Notes</span>
</div>
<div className="text-sm text-amber-800 dark:text-amber-200 ml-6">
{data.submission_notes}
</div>
</div>
)}
{/* Images Preview */}
{(data.banner_image_url || data.card_image_url || data.image_url) && (
<div className="space-y-2">
<Separator />
<div className="text-sm font-semibold">Images</div>
<div className="grid grid-cols-2 gap-2">
{data.banner_image_url && (
<div className="space-y-1">
<img
src={data.banner_image_url}
alt="Banner"
className="w-full h-32 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Banner
{data.banner_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.banner_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
{data.card_image_url && (
<div className="space-y-1">
<img
src={data.card_image_url}
alt="Card"
className="w-full h-32 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Card
{data.card_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.card_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
{data.image_url && !data.banner_image_url && !data.card_image_url && (
<div className="space-y-1">
<img
src={data.image_url}
alt="Ride"
className="w-full h-32 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">Legacy Image</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { Package, Building, Calendar, ExternalLink, AlertCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import type { RideModelSubmissionData } from '@/types/submission-data';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface RichRideModelDisplayProps {
data: RideModelSubmissionData;
actionType: 'create' | 'edit' | 'delete';
showAllFields?: boolean;
}
export function RichRideModelDisplay({ data, actionType, showAllFields = true }: RichRideModelDisplayProps) {
const [manufacturer, setManufacturer] = useState<string | null>(null);
useEffect(() => {
const fetchManufacturer = async () => {
if (data.manufacturer_id) {
const { data: mfgData } = await supabase
.from('companies')
.select('name')
.eq('id', data.manufacturer_id)
.single();
setManufacturer(mfgData?.name || null);
}
};
fetchManufacturer();
}, [data.manufacturer_id]);
return (
<div className="space-y-4">
{/* Header Section */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Package className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground truncate">{data.name}</h3>
{manufacturer && (
<div className="text-sm text-muted-foreground mt-0.5">by {manufacturer}</div>
)}
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant="secondary" className="text-xs">
{data.category?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
{data.ride_type && (
<Badge variant="outline" className="text-xs">
{data.ride_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
)}
{actionType === 'create' && (
<Badge className="bg-green-600 text-white text-xs">New Model</Badge>
)}
{actionType === 'edit' && (
<Badge className="bg-amber-600 text-white text-xs">Edit</Badge>
)}
{actionType === 'delete' && (
<Badge variant="destructive" className="text-xs">Delete</Badge>
)}
</div>
</div>
</div>
{/* Manufacturer */}
{manufacturer && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Building className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Manufacturer</span>
</div>
<div className="text-sm font-medium ml-6">
{manufacturer}
</div>
</div>
)}
{/* Description */}
{data.description && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="text-sm font-semibold mb-2">Description</div>
<div className="text-sm text-muted-foreground leading-relaxed">
{data.description}
</div>
</div>
)}
{/* Source */}
{data.source_url && (
<a
href={data.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"
>
Source
<ExternalLink className="h-3 w-3" />
</a>
)}
{/* Submission Notes */}
{data.submission_notes && (
<div className="bg-amber-50 dark:bg-amber-950 rounded-lg p-3 border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-semibold text-amber-900 dark:text-amber-100">Submitter Notes</span>
</div>
<div className="text-sm text-amber-800 dark:text-amber-200 ml-6">
{data.submission_notes}
</div>
</div>
)}
{/* Images Preview */}
{(data.banner_image_url || data.card_image_url) && (
<div className="space-y-2">
<Separator />
<div className="text-sm font-semibold">Images</div>
<div className="grid grid-cols-2 gap-2">
{data.banner_image_url && (
<div className="space-y-1">
<img
src={data.banner_image_url}
alt="Banner"
className="w-full h-24 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Banner
{data.banner_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.banner_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
{data.card_image_url && (
<div className="space-y-1">
<img
src={data.card_image_url}
alt="Card"
className="w-full h-24 object-cover rounded border"
/>
<div className="text-xs text-center text-muted-foreground">
Card
{data.card_image_id && (
<span className="block font-mono text-[10px] mt-0.5">ID: {data.card_image_id.slice(0, 8)}...</span>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { Calendar, Tag, ArrowRight, MapPin, Building2, Clock } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
import type { TimelineSubmissionData } from '@/types/timeline';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface RichTimelineEventDisplayProps {
data: TimelineSubmissionData;
actionType: 'create' | 'edit' | 'delete';
}
export function RichTimelineEventDisplay({ data, actionType }: RichTimelineEventDisplayProps) {
const [entityName, setEntityName] = useState<string | null>(null);
const [parkContext, setParkContext] = useState<string | null>(null);
const [fromEntity, setFromEntity] = useState<string | null>(null);
const [toEntity, setToEntity] = useState<string | null>(null);
const [fromLocation, setFromLocation] = useState<any>(null);
const [toLocation, setToLocation] = useState<any>(null);
useEffect(() => {
if (!data) return;
const fetchRelatedData = async () => {
// Fetch the main entity this timeline event is for
if (data.entity_id && data.entity_type) {
if (data.entity_type === 'park') {
const { data: park } = await supabase
.from('parks')
.select('name')
.eq('id', data.entity_id)
.single();
setEntityName(park?.name || null);
} else if (data.entity_type === 'ride') {
const { data: ride } = await supabase
.from('rides')
.select('name, park:parks(name)')
.eq('id', data.entity_id)
.single();
setEntityName(ride?.name || null);
setParkContext((ride?.park as any)?.name || null);
}
}
// Fetch from/to entities for relational changes
if (data.from_entity_id) {
const { data: entity } = await supabase
.from('companies')
.select('name')
.eq('id', data.from_entity_id)
.single();
setFromEntity(entity?.name || null);
}
if (data.to_entity_id) {
const { data: entity } = await supabase
.from('companies')
.select('name')
.eq('id', data.to_entity_id)
.single();
setToEntity(entity?.name || null);
}
// Fetch from/to locations for location changes
if (data.from_location_id) {
const { data: loc } = await supabase
.from('locations')
.select('*')
.eq('id', data.from_location_id)
.single();
setFromLocation(loc);
}
if (data.to_location_id) {
const { data: loc } = await supabase
.from('locations')
.select('*')
.eq('id', data.to_location_id)
.single();
setToLocation(loc);
}
};
fetchRelatedData();
}, [data.entity_id, data.entity_type, data.from_entity_id, data.to_entity_id, data.from_location_id, data.to_location_id]);
const formatEventType = (type: string) => {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
};
const getEventTypeColor = (type: string) => {
switch (type) {
case 'opening': return 'bg-green-600';
case 'closure': return 'bg-red-600';
case 'reopening': return 'bg-blue-600';
case 'renovation': return 'bg-purple-600';
case 'expansion': return 'bg-indigo-600';
case 'acquisition': return 'bg-amber-600';
case 'name_change': return 'bg-cyan-600';
case 'operator_change':
case 'owner_change': return 'bg-orange-600';
case 'location_change': return 'bg-pink-600';
case 'status_change': return 'bg-yellow-600';
case 'milestone': return 'bg-emerald-600';
default: return 'bg-gray-600';
}
};
const getPrecisionIcon = (precision: string) => {
switch (precision) {
case 'day': return '📅';
case 'month': return '📆';
case 'year': return '🗓️';
default: return '📅';
}
};
const formatLocation = (loc: any) => {
if (!loc) return null;
const parts = [loc.city, loc.state_province, loc.country].filter(Boolean);
return parts.join(', ');
};
return (
<div className="space-y-4">
{/* Header Section */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Calendar className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground">{data.title}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge className={`${getEventTypeColor(data.event_type)} text-white text-xs`}>
{formatEventType(data.event_type)}
</Badge>
{actionType === 'create' && (
<Badge className="bg-green-600 text-white text-xs">New Event</Badge>
)}
{actionType === 'edit' && (
<Badge className="bg-amber-600 text-white text-xs">Edit Event</Badge>
)}
{actionType === 'delete' && (
<Badge variant="destructive" className="text-xs">Delete Event</Badge>
)}
</div>
</div>
</div>
<Separator />
{/* Entity Context Section */}
<div className="grid gap-3">
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Event For:</span>
<span className="text-foreground">
{entityName || 'Loading...'}
<Badge variant="outline" className="ml-2 text-xs">
{data.entity_type}
</Badge>
</span>
</div>
{parkContext && (
<div className="flex items-center gap-2 text-sm">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Park:</span>
<span className="text-foreground">{parkContext}</span>
</div>
)}
</div>
<Separator />
{/* Event Date Section */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Event Date:</span>
</div>
<div className="flex items-center gap-3 pl-6">
<span className="text-2xl">{getPrecisionIcon(data.event_date_precision)}</span>
<div>
<div className="text-lg font-semibold">
<FlexibleDateDisplay
date={data.event_date}
precision={data.event_date_precision}
/>
</div>
<div className="text-xs text-muted-foreground">
Precision: {data.event_date_precision}
</div>
</div>
</div>
</div>
{/* Change Details Section */}
{(data.from_value || data.to_value || fromEntity || toEntity) && (
<>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Change Details:</div>
<div className="flex items-center gap-3 pl-6">
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">From</div>
<div className="font-medium">
{fromEntity || data.from_value || '—'}
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">To</div>
<div className="font-medium">
{toEntity || data.to_value || '—'}
</div>
</div>
</div>
</div>
</>
)}
{/* Location Change Section */}
{(fromLocation || toLocation) && (
<>
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<MapPin className="h-4 w-4" />
Location Change:
</div>
<div className="flex items-center gap-3 pl-6">
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">From</div>
<div className="font-medium">
{formatLocation(fromLocation) || '—'}
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 p-3 rounded-lg bg-muted/50">
<div className="text-xs text-muted-foreground mb-1">To</div>
<div className="font-medium">
{formatLocation(toLocation) || '—'}
</div>
</div>
</div>
</div>
</>
)}
{/* Description Section */}
{data.description && (
<>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Description:</div>
<p className="text-sm text-muted-foreground pl-6 leading-relaxed">
{data.description}
</p>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { memo } from 'react';
import { SubmissionItemsList } from '../SubmissionItemsList';
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
import type { ModerationItem } from '@/types/moderation';
interface EntitySubmissionDisplayProps {
item: ModerationItem;
isMobile: boolean;
}
export const EntitySubmissionDisplay = memo(({ item, isMobile }: EntitySubmissionDisplayProps) => {
return (
<>
{/* Main content area */}
<div>
<SubmissionItemsList
submissionId={item.id}
view="detailed"
showImages={true}
/>
</div>
{/* Middle column for wide screens - shows extended submission details */}
{!isMobile && item.type === 'content_submission' && (
<div className="hidden 2xl:block space-y-3">
<div className="bg-card rounded-md border p-3">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Review Summary
</div>
<div className="text-sm space-y-2">
<div>
<span className="text-muted-foreground">Type:</span>{' '}
<span className="font-medium">{getSubmissionTypeLabel(item.submission_type || 'unknown')}</span>
</div>
{item.submission_items && item.submission_items.length > 0 && (
<div>
<span className="text-muted-foreground">Items:</span>{' '}
<span className="font-medium">{item.submission_items.length}</span>
</div>
)}
{item.status === 'partially_approved' && (
<div>
<span className="text-muted-foreground">Status:</span>{' '}
<span className="font-medium text-yellow-600 dark:text-yellow-400">
Partially Approved
</span>
</div>
)}
</div>
</div>
</div>
)}
</>
);
});
EntitySubmissionDisplay.displayName = 'EntitySubmissionDisplay';

View File

@@ -0,0 +1,87 @@
import { memo } from 'react';
import { AlertTriangle } from 'lucide-react';
import { PhotoGrid } from '@/components/common/PhotoGrid';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import type { PhotoSubmissionItem } from '@/types/photos';
import type { PhotoForDisplay, ModerationItem } from '@/types/moderation';
interface PhotoSubmissionDisplayProps {
item: ModerationItem;
photoItems: PhotoSubmissionItem[];
loading: boolean;
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
}
export const PhotoSubmissionDisplay = memo(({
item,
photoItems,
loading,
onOpenPhotos
}: PhotoSubmissionDisplayProps) => {
return (
<div>
<div className="text-sm text-muted-foreground mb-3">
Photo Submission
</div>
{/* Submission Title */}
{item.content.title && (
<div className="mb-3">
<div className="text-sm font-medium mb-1">Title:</div>
<p className="text-sm">{item.content.title}</p>
</div>
)}
{/* Photos from relational table */}
{loading ? (
<div className="text-sm text-muted-foreground">Loading photos...</div>
) : photoItems.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-medium flex items-center justify-between">
<span>Photos ({photoItems.length}):</span>
{import.meta.env.DEV && photoItems[0] && (
<span className="text-xs text-muted-foreground">
URL: {photoItems[0].cloudflare_image_url?.slice(0, 30)}...
</span>
)}
</div>
<PhotoGrid
photos={photoItems.map(photo => ({
id: photo.id,
url: photo.cloudflare_image_url,
filename: photo.filename || `Photo ${photo.order_index + 1}`,
caption: photo.caption,
title: photo.title,
date_taken: photo.date_taken,
}))}
onPhotoClick={(photos, index) => onOpenPhotos(photos as any, index)}
/>
</div>
) : (
<Alert variant="destructive" className="mt-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>No Photos Found</AlertTitle>
<AlertDescription>
This photo submission has no photos attached. This may be a data integrity issue.
</AlertDescription>
</Alert>
)}
{/* Context Information */}
{item.entity_name && (
<div className="mt-3 pt-3 border-t text-sm">
<span className="text-muted-foreground">For: </span>
<span className="font-medium">{item.entity_name}</span>
{item.park_name && (
<>
<span className="text-muted-foreground"> at </span>
<span className="font-medium">{item.park_name}</span>
</>
)}
</div>
)}
</div>
);
});
PhotoSubmissionDisplay.displayName = 'PhotoSubmissionDisplay';

View File

@@ -0,0 +1,569 @@
import { memo, useCallback, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import {
AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar, Crown, Unlock
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ActionButton } from '@/components/ui/action-button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { UserAvatar } from '@/components/ui/user-avatar';
import { format } from 'date-fns';
import type { ModerationItem } from '@/types/moderation';
import { sanitizeURL, sanitizePlainText } from '@/lib/sanitize';
import { getErrorMessage } from '@/lib/errorHandler';
interface QueueItemActionsProps {
item: ModerationItem;
isMobile: boolean;
actionLoading: string | null;
isLockedByMe: boolean;
isLockedByOther: boolean;
currentLockSubmissionId?: string;
notes: Record<string, string>;
isAdmin: boolean;
isSuperuser: boolean;
queueIsLoading: boolean;
isClaiming: boolean;
onNoteChange: (id: string, value: string) => void;
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
onResetToPending: (item: ModerationItem) => void;
onRetryFailed: (item: ModerationItem) => void;
onOpenReviewManager: (submissionId: string) => void;
onOpenItemEditor: (submissionId: string) => void;
onDeleteSubmission: (item: ModerationItem) => void;
onInteractionFocus: (id: string) => void;
onInteractionBlur: (id: string) => void;
onClaim: () => void;
onSuperuserReleaseLock?: (submissionId: string) => Promise<void>;
}
export const QueueItemActions = memo(({
item,
isMobile,
actionLoading,
isLockedByMe,
isLockedByOther,
currentLockSubmissionId,
notes,
isAdmin,
isSuperuser,
queueIsLoading,
isClaiming,
onNoteChange,
onApprove,
onResetToPending,
onRetryFailed,
onOpenReviewManager,
onOpenItemEditor,
onDeleteSubmission,
onInteractionFocus,
onInteractionBlur,
onClaim,
onSuperuserReleaseLock
}: QueueItemActionsProps) => {
// Error state for retry functionality
const [actionError, setActionError] = useState<{
message: string;
errorId?: string;
action: 'approve' | 'reject';
} | null>(null);
// Memoize all handlers to prevent re-renders
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onNoteChange(item.id, e.target.value);
}, [onNoteChange, item.id]);
// Debounced handlers with error tracking
const handleApprove = useDebouncedCallback(
async () => {
if (actionLoading === item.id) return;
try {
setActionError(null);
await onApprove(item, 'approved', notes[item.id]);
} catch (error: any) {
setActionError({
message: getErrorMessage(error),
errorId: error.errorId,
action: 'approve',
});
}
},
300,
{ leading: true, trailing: false }
);
const handleReject = useDebouncedCallback(
async () => {
if (actionLoading === item.id) return;
try {
setActionError(null);
await onApprove(item, 'rejected', notes[item.id]);
} catch (error: any) {
setActionError({
message: getErrorMessage(error),
errorId: error.errorId,
action: 'reject',
});
}
},
300,
{ leading: true, trailing: false }
);
const handleResetToPending = useCallback(() => {
onResetToPending(item);
}, [onResetToPending, item]);
const handleRetryFailed = useCallback(() => {
onRetryFailed(item);
}, [onRetryFailed, item]);
const handleOpenReviewManager = useCallback(() => {
onOpenReviewManager(item.id);
}, [onOpenReviewManager, item.id]);
const handleOpenItemEditor = useCallback(() => {
onOpenItemEditor(item.id);
}, [onOpenItemEditor, item.id]);
const handleDeleteSubmission = useCallback(() => {
onDeleteSubmission(item);
}, [onDeleteSubmission, item]);
const handleFocus = useCallback(() => {
onInteractionFocus(item.id);
}, [onInteractionFocus, item.id]);
const handleBlur = useCallback(() => {
onInteractionBlur(item.id);
}, [onInteractionBlur, item.id]);
const handleReverseNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onNoteChange(`reverse-${item.id}`, e.target.value);
}, [onNoteChange, item.id]);
const handleReverseApprove = useDebouncedCallback(
() => {
if (actionLoading === item.id) {
return;
}
onApprove(item, 'approved', notes[`reverse-${item.id}`]);
},
300,
{ leading: true, trailing: false }
);
const handleReverseReject = useDebouncedCallback(
() => {
if (actionLoading === item.id) {
return;
}
onApprove(item, 'rejected', notes[`reverse-${item.id}`]);
},
300,
{ leading: true, trailing: false }
);
return (
<>
{/* Error Display with Retry */}
{actionError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Action Failed: {actionError.action}</AlertTitle>
<AlertDescription>
<div className="space-y-2">
<p className="text-sm">{actionError.message}</p>
{actionError.errorId && (
<p className="text-xs font-mono bg-destructive/10 px-2 py-1 rounded">
Reference ID: {actionError.errorId.slice(0, 8)}
</p>
)}
<div className="flex gap-2 mt-3">
<Button
size="sm"
variant="outline"
onClick={() => {
setActionError(null);
if (actionError.action === 'approve') handleApprove();
else if (actionError.action === 'reject') handleReject();
}}
>
Retry {actionError.action}
</Button>
<Button size="sm" variant="ghost" onClick={() => setActionError(null)}>
Dismiss
</Button>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* Action buttons based on status */}
{(item.status === 'pending' || item.status === 'flagged') && (
<>
{/* Claim button for unclaimed submissions */}
{!isLockedByOther && currentLockSubmissionId !== item.id && (
<div className="mb-4">
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/20">
<AlertCircle className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 dark:text-blue-100">Unclaimed Submission</AlertTitle>
<AlertDescription className="text-blue-800 dark:text-blue-200">
<div className="flex items-center justify-between mt-2">
<span className="text-sm">Claim this submission to lock it for 15 minutes while you review</span>
<ActionButton
action="claim"
onClick={onClaim}
disabled={queueIsLoading || isClaiming}
isLoading={isClaiming}
size="sm"
className="ml-4"
/>
</div>
</AlertDescription>
</Alert>
</div>
)}
{/* Superuser Lock Override - Show for locked items */}
{isSuperuser && isLockedByOther && onSuperuserReleaseLock && (
<Alert className="border-purple-500/50 bg-purple-500/10">
<Crown className="h-4 w-4 text-purple-600" />
<AlertTitle className="text-purple-900 dark:text-purple-100">
Superuser Override
</AlertTitle>
<AlertDescription className="text-purple-800 dark:text-purple-200">
<div className="flex flex-col gap-2 mt-2">
<p className="text-sm">
This submission is locked by another moderator.
You can force-release this lock.
</p>
<Button
size="sm"
variant="outline"
className="border-purple-500 text-purple-700 hover:bg-purple-50 dark:hover:bg-purple-950"
onClick={() => onSuperuserReleaseLock(item.id)}
disabled={actionLoading === item.id}
>
<Unlock className="w-4 h-4 mr-2" />
Force Release Lock
</Button>
</div>
</AlertDescription>
</Alert>
)}
<div className={isMobile ? 'space-y-4 mt-4' : 'grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start mt-4'}>
{/* Submitter Context - shown before moderator can add their notes */}
{(item.submission_items?.[0]?.item_data?.source_url || item.submission_items?.[0]?.item_data?.submission_notes) && (
<div className="space-y-3 mb-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg lg:col-span-2">
<div className="flex items-center gap-2 mb-2">
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-100">
Submitter Context
</h4>
</div>
{item.submission_items?.[0]?.item_data?.source_url && (
<div className="text-sm">
<span className="font-medium text-blue-900 dark:text-blue-100">Source: </span>
<a
href={sanitizeURL(item.submission_items[0].item_data.source_url)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline dark:text-blue-400 inline-flex items-center gap-1"
>
{sanitizePlainText(item.submission_items[0].item_data.source_url)}
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
{item.submission_items?.[0]?.item_data?.submission_notes && (
<div className="text-sm">
<span className="font-medium text-blue-900 dark:text-blue-100">Submitter Notes: </span>
<p className="mt-1 whitespace-pre-wrap text-blue-800 dark:text-blue-200">
{sanitizePlainText(item.submission_items[0].item_data.submission_notes)}
</p>
</div>
)}
</div>
)}
{/* Left: Notes textarea */}
<div className="space-y-2">
<Label htmlFor={`notes-${item.id}`}>Moderation Notes (optional)</Label>
<Textarea
id={`notes-${item.id}`}
placeholder="Add notes about your moderation decision..."
value={notes[item.id] || ''}
onChange={handleNoteChange}
onFocus={handleFocus}
onBlur={handleBlur}
rows={isMobile ? 2 : 4}
className={!isMobile ? 'min-h-[120px]' : ''}
disabled={isLockedByOther || currentLockSubmissionId !== item.id}
/>
</div>
{/* Right: Action buttons */}
<div className={isMobile ? 'flex flex-col gap-2' : 'grid grid-cols-2 gap-2 min-w-[400px]'}>
{/* Show Review Items button for content submissions */}
{item.type === 'content_submission' && (
<>
<Button
onClick={handleOpenReviewManager}
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
variant="outline"
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
size={isMobile ? "default" : "default"}
>
<ListTree className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Review Items
</Button>
{isLockedByMe && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleOpenItemEditor}
disabled={actionLoading === item.id}
variant="outline"
className={isMobile ? 'h-11' : ''}
size={isMobile ? "default" : "default"}
>
<Edit className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
{!isMobile && "Edit"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit submission items (Press E)</p>
</TooltipContent>
</Tooltip>
)}
</>
)}
<ActionButton
action="approve"
onClick={handleApprove}
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
isLoading={actionLoading === item.id}
loadingText={
item.submission_items && item.submission_items.length > 5
? `Processing ${item.submission_items.length} items...`
: 'Processing...'
}
className="flex-1"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
/>
<ActionButton
action="reject"
onClick={handleReject}
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
isLoading={actionLoading === item.id}
className="flex-1"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
/>
</div>
</div>
</>
)}
{/* Reset button for rejected items */}
{item.status === 'rejected' && item.type === 'content_submission' && (
<div className="space-y-3 pt-4 border-t bg-red-50 dark:bg-red-950/20 -mx-4 px-4 py-3 rounded-b-lg">
<div className="flex items-start gap-2 text-sm text-red-800 dark:text-red-300">
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium">This submission was rejected</p>
<p className="text-xs mt-1">You can reset it to pending to re-review and approve it.</p>
</div>
</div>
<ActionButton
action="reset"
onClick={handleResetToPending}
disabled={actionLoading === item.id}
isLoading={actionLoading === item.id}
className="w-full"
/>
</div>
)}
{/* Retry/Reset buttons for partially approved items */}
{item.status === 'partially_approved' && item.type === 'content_submission' && (
<div className="space-y-3 pt-4 border-t bg-yellow-50 dark:bg-yellow-950/20 -mx-4 px-4 py-3 rounded-b-lg">
<div className="flex items-start gap-2 text-sm text-yellow-800 dark:text-yellow-300">
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium">This submission was partially approved</p>
<p className="text-xs mt-1">Some items failed. You can retry them or reset everything to pending.</p>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleOpenReviewManager}
disabled={actionLoading === item.id}
variant="outline"
className="flex-1"
>
<ListTree className="w-4 h-4 mr-2" />
Review Items
</Button>
<ActionButton
action="reset"
onClick={handleResetToPending}
disabled={actionLoading === item.id}
isLoading={actionLoading === item.id}
className="flex-1"
>
Reset All
</ActionButton>
<ActionButton
action="retry"
onClick={handleRetryFailed}
disabled={actionLoading === item.id}
isLoading={actionLoading === item.id}
className="flex-1"
/>
</div>
</div>
)}
{/* Reviewer Information for approved/rejected items */}
{(item.status === 'approved' || item.status === 'rejected') && (item.reviewed_at || item.reviewer_notes || item.submission_items?.[0]?.item_data?.source_url || item.submission_items?.[0]?.item_data?.submission_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">
<UserAvatar
key={item.reviewer_profile.avatar_url || `reviewer-${item.reviewed_by}`}
avatarUrl={item.reviewer_profile.avatar_url}
fallbackText={item.reviewer_profile.display_name || item.reviewer_profile.username || 'R'}
size="sm"
className="h-6 w-6"
/>
<span className="font-medium">
{item.reviewer_profile.display_name || item.reviewer_profile.username}
</span>
</div>
</>
)}
</div>
{/* Submitter Context (shown in collapsed state after review) */}
{(item.submission_items?.[0]?.item_data?.source_url || item.submission_items?.[0]?.item_data?.submission_notes) && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline">
<ChevronDown className="w-4 h-4" />
View Submitter Context
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 bg-muted/30 p-3 rounded-lg">
{item.submission_items?.[0]?.item_data?.source_url && (
<div className="text-sm mb-2">
<span className="font-medium">Source: </span>
<a
href={sanitizeURL(item.submission_items[0].item_data.source_url)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{sanitizePlainText(item.submission_items[0].item_data.source_url)}
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
{item.submission_items?.[0]?.item_data?.submission_notes && (
<div className="text-sm">
<span className="font-medium">Submitter Notes: </span>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">
{sanitizePlainText(item.submission_items[0].item_data.submission_notes)}
</p>
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
{item.reviewer_notes && (
<div className="bg-muted/30 p-3 rounded-lg">
<p className="text-sm font-medium mb-1">Moderator 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={handleReverseNoteChange}
onFocus={handleFocus}
onBlur={handleBlur}
rows={2}
/>
<div className={`flex gap-2 ${isMobile ? 'flex-col' : ''}`}>
{item.status === 'approved' && (
<ActionButton
action="reject"
onClick={handleReverseReject}
disabled={actionLoading === item.id}
isLoading={actionLoading === item.id}
className="flex-1"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
>
Change to Rejected
</ActionButton>
)}
{item.status === 'rejected' && (
<ActionButton
action="approve"
onClick={handleReverseApprove}
disabled={actionLoading === item.id}
isLoading={actionLoading === item.id}
className="flex-1"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
>
Change to Approved
</ActionButton>
)}
</div>
</div>
</div>
)}
{/* Delete button for rejected submissions (admin/superadmin only) */}
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin || isSuperuser) && (
<div className="pt-2">
<ActionButton
action="delete"
onClick={handleDeleteSubmission}
disabled={actionLoading === item.id}
isLoading={actionLoading === item.id}
className="w-full"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
>
Delete Submission
</ActionButton>
</div>
)}
</>
);
});
QueueItemActions.displayName = 'QueueItemActions';

View File

@@ -0,0 +1,67 @@
import { memo } from 'react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import type { ModerationItem } from '@/types/moderation';
interface QueueItemContextProps {
item: ModerationItem;
}
export const QueueItemContext = memo(({ item }: QueueItemContextProps) => {
if (!item.entity_name && !item.park_name && !item.user_profile) {
return null;
}
return (
<div className="space-y-3">
{(item.entity_name || item.park_name) && (
<div className="bg-card rounded-md border p-3 space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Context
</div>
{item.entity_name && (
<div className="text-sm">
<span className="text-xs text-muted-foreground block mb-0.5">
{item.park_name ? 'Ride' : 'Entity'}
</span>
<span className="font-medium">{item.entity_name}</span>
</div>
)}
{item.park_name && (
<div className="text-sm">
<span className="text-xs text-muted-foreground block mb-0.5">Park</span>
<span className="font-medium">{item.park_name}</span>
</div>
)}
</div>
)}
{item.user_profile && (
<div className="bg-card rounded-md border p-3 space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Submitter
</div>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={item.user_profile.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{(item.user_profile.display_name || item.user_profile.username)?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-sm">
<div className="font-medium">
{item.user_profile.display_name || item.user_profile.username}
</div>
{item.user_profile.display_name && (
<div className="text-xs text-muted-foreground">
@{item.user_profile.username}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
});
QueueItemContext.displayName = 'QueueItemContext';

View File

@@ -0,0 +1,190 @@
import { memo, useCallback } from 'react';
import { MessageSquare, Image, FileText, Calendar, Edit, Lock, AlertCircle, Code2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { UserAvatar } from '@/components/ui/user-avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ValidationSummary } from '../ValidationSummary';
import { TransactionStatusIndicator, type TransactionStatus } from '../TransactionStatusIndicator';
import { format } from 'date-fns';
import type { ModerationItem } from '@/types/moderation';
import type { ValidationResult } from '@/lib/entityValidationSchemas';
interface QueueItemHeaderProps {
item: ModerationItem;
isMobile: boolean;
hasModeratorEdits: boolean;
isLockedByOther: boolean;
currentLockSubmissionId?: string;
validationResult: ValidationResult | null;
transactionStatus?: TransactionStatus;
transactionMessage?: string;
onValidationChange: (result: ValidationResult) => void;
onViewRawData?: () => void;
}
const getStatusBadgeVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => {
switch (status) {
case 'pending': return 'default';
case 'approved': return 'secondary';
case 'rejected': return 'destructive';
case 'flagged': return 'destructive';
case 'partially_approved': return 'outline';
default: return 'outline';
}
};
export const QueueItemHeader = memo(({
item,
isMobile,
hasModeratorEdits,
isLockedByOther,
currentLockSubmissionId,
validationResult,
transactionStatus = 'idle',
transactionMessage,
onValidationChange,
onViewRawData
}: QueueItemHeaderProps) => {
const handleValidationChange = useCallback((result: ValidationResult) => {
onValidationChange(result);
}, [onValidationChange]);
return (
<>
<div className={`flex gap-3 ${isMobile ? 'flex-col' : 'items-center justify-between'}`}>
<div className="flex items-center gap-2 flex-wrap flex-1">
<Badge variant={getStatusBadgeVariant(item.status)} className={isMobile ? "text-xs" : ""}>
{item.type === 'review' ? (
<>
<MessageSquare className="w-3 h-3 mr-1" />
Review
</>
) : item.submission_type === 'photo' ? (
<>
<Image className="w-3 h-3 mr-1" />
Photo
</>
) : (
<>
<FileText className="w-3 h-3 mr-1" />
Submission
</>
)}
</Badge>
<Badge variant={getStatusBadgeVariant(item.status)} className={isMobile ? "text-xs" : ""}>
{item.status === 'partially_approved' ? 'Partially Approved' :
item.status.charAt(0).toUpperCase() + item.status.slice(1)}
</Badge>
{hasModeratorEdits && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 border border-blue-300 dark:border-blue-700"
>
<Edit className="w-3 h-3 mr-1" />
Edited
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>This submission has been modified by a moderator</p>
</TooltipContent>
</Tooltip>
)}
{item.status === 'partially_approved' && (
<Badge variant="outline" className="bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700">
<AlertCircle className="w-3 h-3 mr-1" />
Needs Retry
</Badge>
)}
{isLockedByOther && item.type === 'content_submission' && (
<Badge variant="outline" className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 border-orange-300 dark:border-orange-700">
<Lock className="w-3 h-3 mr-1" />
Locked by Another Moderator
</Badge>
)}
{currentLockSubmissionId === item.id && item.type === 'content_submission' && (
<Badge variant="outline" className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-300 dark:border-blue-700">
<Lock className="w-3 h-3 mr-1" />
Claimed by You
</Badge>
)}
<TransactionStatusIndicator
status={transactionStatus}
message={transactionMessage}
showLabel={!isMobile}
/>
{item.submission_items && item.submission_items.length > 0 && item.submission_items[0].item_data && (
<ValidationSummary
item={{
item_type: item.submission_items[0].item_type,
item_data: item.submission_items[0].item_data,
id: item.submission_items[0].id,
}}
compact={true}
onValidationChange={handleValidationChange}
/>
)}
</div>
<div className="flex items-center gap-2">
{onViewRawData && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onViewRawData}
className="h-8"
>
<Code2 className="h-4 w-4" />
{!isMobile && <span className="ml-2">Raw Data</span>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View complete JSON data</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className={`flex items-center gap-2 text-muted-foreground ${isMobile ? 'text-xs' : 'text-sm'}`}>
<Calendar className={isMobile ? "w-3 h-3" : "w-4 h-4"} />
{format(new Date(item.created_at), isMobile ? 'MMM d, HH:mm:ss' : 'MMM d, yyyy HH:mm:ss.SSS')}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Full timestamp:</p>
<p className="font-mono">{item.created_at}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
{item.user_profile && (
<div className={`flex items-center gap-3 ${isMobile ? 'text-xs' : 'text-sm'}`}>
<UserAvatar
key={item.user_profile.avatar_url || `user-${item.user_id}`}
avatarUrl={item.user_profile.avatar_url}
fallbackText={item.user_profile.display_name || item.user_profile.username || 'U'}
size={isMobile ? "sm" : "md"}
/>
<div>
<span className="font-medium">
{item.user_profile.display_name || item.user_profile.username}
</span>
{item.user_profile.display_name && (
<span className={`text-muted-foreground block ${isMobile ? 'text-xs' : 'text-xs'}`}>
@{item.user_profile.username}
</span>
)}
</div>
</div>
)}
</>
);
});
QueueItemHeader.displayName = 'QueueItemHeader';

View File

@@ -0,0 +1,71 @@
import { memo } from 'react';
import { PhotoGrid } from '@/components/common/PhotoGrid';
import { normalizePhotoData } from '@/lib/photoHelpers';
import type { PhotoItem } from '@/types/photos';
import type { PhotoForDisplay, ModerationItem } from '@/types/moderation';
interface ReviewDisplayProps {
item: ModerationItem;
isMobile: boolean;
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
}
export const ReviewDisplay = memo(({ item, isMobile, onOpenPhotos }: ReviewDisplayProps) => {
return (
<div>
{item.content.title && (
<h4 className="font-semibold mb-2">{item.content.title}</h4>
)}
{item.content.content && (
<p className="text-sm mb-2">{item.content.content}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<span>Rating: {item.content.rating}/5</span>
</div>
{/* Entity Names for Reviews */}
{(item.entity_name || item.park_name) && (
<div className="space-y-1 mb-2">
{item.entity_name && (
<div className="text-sm text-muted-foreground">
<span className="text-xs">{item.park_name ? 'Ride:' : 'Park:'} </span>
<span className="text-base font-medium text-foreground">{item.entity_name}</span>
</div>
)}
{item.park_name && (
<div className="text-sm text-muted-foreground">
<span className="text-xs">Park: </span>
<span className="text-base font-medium text-foreground">{item.park_name}</span>
</div>
)}
</div>
)}
{item.content.photos && item.content.photos.length > 0 && (() => {
const reviewPhotos: PhotoItem[] = normalizePhotoData({
type: 'review',
photos: item.content.photos
});
return (
<div className="mt-3">
<div className="text-sm font-medium mb-2">Attached Photos:</div>
<PhotoGrid
photos={reviewPhotos}
onPhotoClick={(photos, index) => onOpenPhotos(photos as any, index)}
maxDisplay={isMobile ? 3 : 4}
className="grid-cols-2 md:grid-cols-3"
/>
{item.content.photos[0]?.caption && (
<p className="text-sm text-muted-foreground mt-2">
{item.content.photos[0].caption}
</p>
)}
</div>
);
})()}
</div>
);
});
ReviewDisplay.displayName = 'ReviewDisplay';

View File

@@ -0,0 +1,30 @@
import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ShowNewItemsButtonProps {
count: number;
onShow: () => void | Promise<void>;
isLoading?: boolean;
}
export const ShowNewItemsButton = ({
count,
onShow,
isLoading = false
}: ShowNewItemsButtonProps) => {
const itemText = count === 1 ? 'item' : 'items';
return (
<Button
variant="default"
size="sm"
onClick={onShow}
loading={isLoading}
loadingText={`Loading ${count} ${itemText}...`}
trackingLabel="show-new-queue-items"
>
<RefreshCw className="w-4 h-4 mr-2" />
Show {count} New {count === 1 ? 'Item' : 'Items'}
</Button>
);
};