mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 14:51:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
51
src-old/components/moderation/ActiveFiltersDisplay.tsx
Normal file
51
src-old/components/moderation/ActiveFiltersDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
src-old/components/moderation/ActivityCard.tsx
Normal file
115
src-old/components/moderation/ActivityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
src-old/components/moderation/ArrayFieldDiff.tsx
Normal file
218
src-old/components/moderation/ArrayFieldDiff.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Minus, Edit, Check } from 'lucide-react';
|
||||
import { formatFieldValue } from '@/lib/submissionChangeDetection';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ArrayFieldDiffProps {
|
||||
fieldName: string;
|
||||
oldArray: 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;
|
||||
}
|
||||
173
src-old/components/moderation/AuditTrailViewer.tsx
Normal file
173
src-old/components/moderation/AuditTrailViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src-old/components/moderation/AutoRefreshIndicator.tsx
Normal file
24
src-old/components/moderation/AutoRefreshIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
src-old/components/moderation/ConfirmationDialog.tsx
Normal file
59
src-old/components/moderation/ConfirmationDialog.tsx
Normal 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';
|
||||
136
src-old/components/moderation/ConflictResolutionDialog.tsx
Normal file
136
src-old/components/moderation/ConflictResolutionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src-old/components/moderation/ConflictResolutionModal.tsx
Normal file
157
src-old/components/moderation/ConflictResolutionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src-old/components/moderation/DependencyTreeView.tsx
Normal file
83
src-old/components/moderation/DependencyTreeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src-old/components/moderation/DependencyVisualizer.tsx
Normal file
130
src-old/components/moderation/DependencyVisualizer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src-old/components/moderation/EditHistoryAccordion.tsx
Normal file
166
src-old/components/moderation/EditHistoryAccordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src-old/components/moderation/EditHistoryEntry.tsx
Normal file
131
src-old/components/moderation/EditHistoryEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src-old/components/moderation/EmptyQueueState.tsx
Normal file
53
src-old/components/moderation/EmptyQueueState.tsx
Normal 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';
|
||||
93
src-old/components/moderation/EnhancedEmptyState.tsx
Normal file
93
src-old/components/moderation/EnhancedEmptyState.tsx
Normal 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';
|
||||
164
src-old/components/moderation/EnhancedLockStatusDisplay.tsx
Normal file
164
src-old/components/moderation/EnhancedLockStatusDisplay.tsx
Normal 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';
|
||||
400
src-old/components/moderation/EntityEditPreview.tsx
Normal file
400
src-old/components/moderation/EntityEditPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
147
src-old/components/moderation/EscalationDialog.tsx
Normal file
147
src-old/components/moderation/EscalationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
331
src-old/components/moderation/FieldComparison.tsx
Normal file
331
src-old/components/moderation/FieldComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
src-old/components/moderation/ItemEditDialog.tsx
Normal file
385
src-old/components/moderation/ItemEditDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src-old/components/moderation/ItemReviewCard.tsx
Normal file
160
src-old/components/moderation/ItemReviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src-old/components/moderation/ItemSelectorDialog.tsx
Normal file
115
src-old/components/moderation/ItemSelectorDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src-old/components/moderation/KeyboardShortcutsHelp.tsx
Normal file
55
src-old/components/moderation/KeyboardShortcutsHelp.tsx
Normal 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';
|
||||
107
src-old/components/moderation/LockStatusDisplay.tsx
Normal file
107
src-old/components/moderation/LockStatusDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
733
src-old/components/moderation/ModerationQueue.tsx
Normal file
733
src-old/components/moderation/ModerationQueue.tsx
Normal 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';
|
||||
29
src-old/components/moderation/NewItemsAlert.tsx
Normal file
29
src-old/components/moderation/NewItemsAlert.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
192
src-old/components/moderation/PhotoComparison.tsx
Normal file
192
src-old/components/moderation/PhotoComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src-old/components/moderation/PhotoModal.tsx
Normal file
180
src-old/components/moderation/PhotoModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src-old/components/moderation/PhotoSubmissionDisplay.tsx
Normal file
96
src-old/components/moderation/PhotoSubmissionDisplay.tsx
Normal 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} />;
|
||||
}
|
||||
484
src-old/components/moderation/ProfileManager.tsx
Normal file
484
src-old/components/moderation/ProfileManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src-old/components/moderation/QueueFilters.tsx
Normal file
223
src-old/components/moderation/QueueFilters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
407
src-old/components/moderation/QueueItem.tsx
Normal file
407
src-old/components/moderation/QueueItem.tsx
Normal 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';
|
||||
49
src-old/components/moderation/QueueItemSkeleton.tsx
Normal file
49
src-old/components/moderation/QueueItemSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src-old/components/moderation/QueuePagination.tsx
Normal file
163
src-old/components/moderation/QueuePagination.tsx
Normal 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';
|
||||
15
src-old/components/moderation/QueueSkeleton.tsx
Normal file
15
src-old/components/moderation/QueueSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src-old/components/moderation/QueueSortControls.tsx
Normal file
97
src-old/components/moderation/QueueSortControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src-old/components/moderation/QueueStats.tsx
Normal file
31
src-old/components/moderation/QueueStats.tsx
Normal 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';
|
||||
75
src-old/components/moderation/QueueStatsDashboard.tsx
Normal file
75
src-old/components/moderation/QueueStatsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
src-old/components/moderation/RawDataViewer.tsx
Normal file
224
src-old/components/moderation/RawDataViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src-old/components/moderation/ReassignDialog.tsx
Normal file
178
src-old/components/moderation/ReassignDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
src-old/components/moderation/RecentActivity.tsx
Normal file
271
src-old/components/moderation/RecentActivity.tsx
Normal 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';
|
||||
160
src-old/components/moderation/RejectionDialog.tsx
Normal file
160
src-old/components/moderation/RejectionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src-old/components/moderation/ReportButton.tsx
Normal file
154
src-old/components/moderation/ReportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
763
src-old/components/moderation/ReportsQueue.tsx
Normal file
763
src-old/components/moderation/ReportsQueue.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
337
src-old/components/moderation/SpecialFieldDisplay.tsx
Normal file
337
src-old/components/moderation/SpecialFieldDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
416
src-old/components/moderation/SubmissionChangesDisplay.tsx
Normal file
416
src-old/components/moderation/SubmissionChangesDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
338
src-old/components/moderation/SubmissionItemsList.tsx
Normal file
338
src-old/components/moderation/SubmissionItemsList.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
214
src-old/components/moderation/SubmissionMetadataPanel.tsx
Normal file
214
src-old/components/moderation/SubmissionMetadataPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1076
src-old/components/moderation/SubmissionReviewManager.tsx
Normal file
1076
src-old/components/moderation/SubmissionReviewManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
84
src-old/components/moderation/SuperuserQueueControls.tsx
Normal file
84
src-old/components/moderation/SuperuserQueueControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
129
src-old/components/moderation/TimelineEventPreview.tsx
Normal file
129
src-old/components/moderation/TimelineEventPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src-old/components/moderation/TransactionStatusIndicator.tsx
Normal file
109
src-old/components/moderation/TransactionStatusIndicator.tsx
Normal 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';
|
||||
346
src-old/components/moderation/UserRoleManager.tsx
Normal file
346
src-old/components/moderation/UserRoleManager.tsx
Normal 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>;
|
||||
}
|
||||
97
src-old/components/moderation/ValidationBlockerDialog.tsx
Normal file
97
src-old/components/moderation/ValidationBlockerDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src-old/components/moderation/ValidationSummary.tsx
Normal file
307
src-old/components/moderation/ValidationSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src-old/components/moderation/WarningConfirmDialog.tsx
Normal file
73
src-old/components/moderation/WarningConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src-old/components/moderation/displays/RichCompanyDisplay.tsx
Normal file
197
src-old/components/moderation/displays/RichCompanyDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
src-old/components/moderation/displays/RichParkDisplay.tsx
Normal file
305
src-old/components/moderation/displays/RichParkDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
715
src-old/components/moderation/displays/RichRideDisplay.tsx
Normal file
715
src-old/components/moderation/displays/RichRideDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src-old/components/moderation/displays/RichRideModelDisplay.tsx
Normal file
156
src-old/components/moderation/displays/RichRideModelDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
569
src-old/components/moderation/renderers/QueueItemActions.tsx
Normal file
569
src-old/components/moderation/renderers/QueueItemActions.tsx
Normal 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';
|
||||
67
src-old/components/moderation/renderers/QueueItemContext.tsx
Normal file
67
src-old/components/moderation/renderers/QueueItemContext.tsx
Normal 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';
|
||||
190
src-old/components/moderation/renderers/QueueItemHeader.tsx
Normal file
190
src-old/components/moderation/renderers/QueueItemHeader.tsx
Normal 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';
|
||||
71
src-old/components/moderation/renderers/ReviewDisplay.tsx
Normal file
71
src-old/components/moderation/renderers/ReviewDisplay.tsx
Normal 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';
|
||||
30
src-old/components/moderation/show-new-items-button.tsx
Normal file
30
src-old/components/moderation/show-new-items-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user