mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 14:47:00 -05:00
Compare commits
3 Commits
54b84dff21
...
41560d9c42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41560d9c42 | ||
|
|
665918741e | ||
|
|
8feb01f1c3 |
28
package-lock.json
generated
28
package-lock.json
generated
@@ -49,6 +49,7 @@
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@uppy/core": "^5.0.2",
|
||||
"@uppy/dashboard": "^5.0.2",
|
||||
"@uppy/image-editor": "^4.0.1",
|
||||
@@ -4760,6 +4761,33 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@transloadit/prettier-bytes": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@uppy/core": "^5.0.2",
|
||||
"@uppy/dashboard": "^5.0.2",
|
||||
"@uppy/image-editor": "^4.0.1",
|
||||
|
||||
59
src/components/moderation/ConfirmationDialog.tsx
Normal file
59
src/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';
|
||||
@@ -49,3 +49,5 @@ export const EmptyQueueState = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmptyQueueState.displayName = 'EmptyQueueState';
|
||||
|
||||
93
src/components/moderation/EnhancedEmptyState.tsx
Normal file
93
src/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/components/moderation/EnhancedLockStatusDisplay.tsx
Normal file
164
src/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';
|
||||
55
src/components/moderation/KeyboardShortcutsHelp.tsx
Normal file
55
src/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';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useImperativeHandle, forwardRef, useMemo } from 'react';
|
||||
import { useState, useImperativeHandle, forwardRef, useMemo, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
@@ -14,15 +14,18 @@ import { useModerationQueueManager } from '@/hooks/moderation';
|
||||
import { QueueItem } from './QueueItem';
|
||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||
import { QueueSkeleton } from './QueueSkeleton';
|
||||
import { LockStatusDisplay } from './LockStatusDisplay';
|
||||
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 { EmptyQueueState } from './EmptyQueueState';
|
||||
import { EnhancedEmptyState } from './EnhancedEmptyState';
|
||||
import { QueuePagination } from './QueuePagination';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { fetchSubmissionItems, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
||||
import type { ModerationQueueRef } from '@/types/moderation';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
@@ -75,11 +78,68 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
const [showItemEditDialog, setShowItemEditDialog] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null);
|
||||
|
||||
// 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);
|
||||
|
||||
// UI-specific handlers
|
||||
const handleNoteChange = (id: string, value: string) => {
|
||||
setNotes(prev => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
// Wrapped delete with confirmation
|
||||
const handleDeleteSubmission = useCallback((item: any) => {
|
||||
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]);
|
||||
|
||||
// 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',
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const handleOpenPhotos = (photos: any[], index: number) => {
|
||||
setSelectedPhotos(photos);
|
||||
setSelectedPhotoIndex(index);
|
||||
@@ -135,14 +195,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
<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} />
|
||||
<LockStatusDisplay
|
||||
<EnhancedLockStatusDisplay
|
||||
currentLock={queueManager.queue.currentLock}
|
||||
queueStats={queueManager.queue.queueStats}
|
||||
isLoading={queueManager.queue.isLoading}
|
||||
onExtendLock={queueManager.queue.extendLock}
|
||||
onReleaseLock={queueManager.queue.releaseLock}
|
||||
getTimeRemaining={queueManager.queue.getTimeRemaining}
|
||||
getLockProgress={queueManager.queue.getLockProgress}
|
||||
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>
|
||||
@@ -192,9 +251,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
{queueManager.loadingState === 'loading' || queueManager.loadingState === 'initial' ? (
|
||||
<QueueSkeleton count={queueManager.pagination.pageSize} />
|
||||
) : queueManager.items.length === 0 ? (
|
||||
<EmptyQueueState
|
||||
<EnhancedEmptyState
|
||||
entityFilter={queueManager.filters.entityFilter}
|
||||
statusFilter={queueManager.filters.statusFilter}
|
||||
onClearFilters={queueManager.filters.hasActiveFilters ? handleClearFilters : undefined}
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
@@ -222,7 +282,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
onOpenReviewManager={handleOpenReviewManager}
|
||||
onOpenItemEditor={handleOpenItemEditor}
|
||||
onClaimSubmission={queueManager.queue.claimSubmission}
|
||||
onDeleteSubmission={queueManager.deleteSubmission}
|
||||
onDeleteSubmission={handleDeleteSubmission}
|
||||
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
|
||||
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
|
||||
/>
|
||||
@@ -274,6 +334,24 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { CheckCircle, XCircle, Eye, Calendar, MessageSquare, FileText, Image, ListTree, RefreshCw, AlertCircle, Lock, Trash2, AlertTriangle, Edit, Info, ExternalLink, ChevronDown } from 'lucide-react';
|
||||
import { usePhotoSubmissionItems } from '@/hooks/usePhotoSubmissionItems';
|
||||
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
||||
import { normalizePhotoData } from '@/lib/photoHelpers';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
import type { PhotoForDisplay } from '@/types/moderation';
|
||||
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 { format } from 'date-fns';
|
||||
import { SubmissionItemsList } from './SubmissionItemsList';
|
||||
import { MeasurementDisplay } from '@/components/ui/measurement-display';
|
||||
import { ValidationSummary } from './ValidationSummary';
|
||||
import type { ValidationResult } from '@/lib/entityValidationSchemas';
|
||||
import type { LockStatus } from '@/lib/moderation/lockHelpers';
|
||||
import type { ModerationItem } from '@/types/moderation';
|
||||
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 { 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';
|
||||
|
||||
interface QueueItemProps {
|
||||
item: ModerationItem;
|
||||
@@ -51,16 +46,6 @@ interface QueueItemProps {
|
||||
onInteractionBlur: (id: string) => 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 QueueItem = memo(({
|
||||
item,
|
||||
@@ -140,112 +125,18 @@ export const QueueItem = memo(({
|
||||
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"}>
|
||||
<div className={`flex gap-3 ${isMobile ? 'flex-col' : 'items-center justify-between'}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
)}
|
||||
{item.submission_items && item.submission_items.length > 0 && (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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
|
||||
item={item}
|
||||
isMobile={isMobile}
|
||||
hasModeratorEdits={hasModeratorEdits}
|
||||
isLockedByOther={isLockedByOther}
|
||||
currentLockSubmissionId={currentLockSubmissionId}
|
||||
validationResult={validationResult}
|
||||
onValidationChange={handleValidationChange}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={`${isMobile ? 'p-4 pt-0 space-y-4' : 'p-6 pt-0'}`}>
|
||||
@@ -310,68 +201,12 @@ export const QueueItem = memo(({
|
||||
})()}
|
||||
</div>
|
||||
) : item.submission_type === 'photo' ? (
|
||||
<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 */}
|
||||
{photosLoading ? (
|
||||
<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={onOpenPhotos}
|
||||
/>
|
||||
</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
|
||||
item={item}
|
||||
photoItems={photoItems}
|
||||
loading={photosLoading}
|
||||
onOpenPhotos={onOpenPhotos}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Main content area - spans 1st column on all layouts */}
|
||||
@@ -470,342 +305,29 @@ export const QueueItem = memo(({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<Button
|
||||
onClick={handleClaim}
|
||||
disabled={queueIsLoading || isClaiming}
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
>
|
||||
{isClaiming ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Claiming...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
Claim Submission
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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={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"
|
||||
>
|
||||
{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">
|
||||
{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={(e) => onNoteChange(item.id, e.target.value)}
|
||||
onFocus={() => onInteractionFocus(item.id)}
|
||||
onBlur={() => onInteractionBlur(item.id)}
|
||||
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={() => onOpenReviewManager(item.id)}
|
||||
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>
|
||||
|
||||
{isAdmin && isLockedByMe && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onOpenItemEditor(item.id)}
|
||||
disabled={actionLoading === item.id}
|
||||
variant="ghost"
|
||||
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>Quick edit first pending item</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => onApprove(item, 'approved', notes[item.id])}
|
||||
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onApprove(item, 'rejected', notes[item.id])}
|
||||
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Reject
|
||||
</Button>
|
||||
</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>
|
||||
<Button
|
||||
onClick={() => onResetToPending(item)}
|
||||
disabled={actionLoading === item.id}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reset to Pending
|
||||
</Button>
|
||||
</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={() => onOpenReviewManager(item.id)}
|
||||
disabled={actionLoading === item.id}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<ListTree className="w-4 h-4 mr-2" />
|
||||
Review Items
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onResetToPending(item)}
|
||||
disabled={actionLoading === item.id}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reset All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onRetryFailed(item)}
|
||||
disabled={actionLoading === item.id}
|
||||
className="flex-1 bg-yellow-600 hover:bg-yellow-700"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Failed
|
||||
</Button>
|
||||
</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={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"
|
||||
>
|
||||
{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">
|
||||
{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={(e) => onNoteChange(`reverse-${item.id}`, e.target.value)}
|
||||
onFocus={() => onInteractionFocus(item.id)}
|
||||
onBlur={() => onInteractionBlur(item.id)}
|
||||
rows={2}
|
||||
/>
|
||||
<div className={`flex gap-2 ${isMobile ? 'flex-col' : ''}`}>
|
||||
{item.status === 'approved' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onApprove(item, 'rejected', notes[`reverse-${item.id}`])}
|
||||
disabled={actionLoading === item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Change to Rejected
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'rejected' && (
|
||||
<Button
|
||||
onClick={() => onApprove(item, 'approved', notes[`reverse-${item.id}`])}
|
||||
disabled={actionLoading === item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Change to Approved
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button for rejected submissions (admin/superadmin only) */}
|
||||
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin || isSuperuser) && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteSubmission(item)}
|
||||
disabled={actionLoading === item.id}
|
||||
className={`w-full ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<Trash2 className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Delete Submission
|
||||
</Button>
|
||||
</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}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -159,3 +159,5 @@ export const QueuePagination = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
QueuePagination.displayName = 'QueuePagination';
|
||||
|
||||
@@ -27,3 +27,5 @@ export const QueueStats = ({ stats, isMobile }: QueueStatsProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
QueueStats.displayName = 'QueueStats';
|
||||
|
||||
@@ -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)}</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={onOpenPhotos}
|
||||
/>
|
||||
</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';
|
||||
457
src/components/moderation/renderers/QueueItemActions.tsx
Normal file
457
src/components/moderation/renderers/QueueItemActions.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import {
|
||||
CheckCircle, XCircle, RefreshCw, AlertCircle, Lock, Trash2,
|
||||
Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
}: QueueItemActionsProps) => {
|
||||
// Memoize all handlers to prevent re-renders
|
||||
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onNoteChange(item.id, e.target.value);
|
||||
}, [onNoteChange, item.id]);
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
onApprove(item, 'approved', notes[item.id]);
|
||||
}, [onApprove, item, notes]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
onApprove(item, 'rejected', notes[item.id]);
|
||||
}, [onApprove, item, notes]);
|
||||
|
||||
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 = useCallback(() => {
|
||||
onApprove(item, 'approved', notes[`reverse-${item.id}`]);
|
||||
}, [onApprove, item, notes]);
|
||||
|
||||
const handleReverseReject = useCallback(() => {
|
||||
onApprove(item, 'rejected', notes[`reverse-${item.id}`]);
|
||||
}, [onApprove, item, notes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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>
|
||||
<Button
|
||||
onClick={onClaim}
|
||||
disabled={queueIsLoading || isClaiming}
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
>
|
||||
{isClaiming ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Claiming...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
Claim Submission
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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={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"
|
||||
>
|
||||
{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">
|
||||
{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>
|
||||
|
||||
{isAdmin && isLockedByMe && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleOpenItemEditor}
|
||||
disabled={actionLoading === item.id}
|
||||
variant="ghost"
|
||||
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>Quick edit first pending item</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleReject}
|
||||
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Reject
|
||||
</Button>
|
||||
</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>
|
||||
<Button
|
||||
onClick={handleResetToPending}
|
||||
disabled={actionLoading === item.id}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reset to Pending
|
||||
</Button>
|
||||
</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>
|
||||
<Button
|
||||
onClick={handleResetToPending}
|
||||
disabled={actionLoading === item.id}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reset All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRetryFailed}
|
||||
disabled={actionLoading === item.id}
|
||||
className="flex-1 bg-yellow-600 hover:bg-yellow-700"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Failed
|
||||
</Button>
|
||||
</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={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"
|
||||
>
|
||||
{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">
|
||||
{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' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleReverseReject}
|
||||
disabled={actionLoading === item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Change to Rejected
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'rejected' && (
|
||||
<Button
|
||||
onClick={handleReverseApprove}
|
||||
disabled={actionLoading === item.id}
|
||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Change to Approved
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button for rejected submissions (admin/superadmin only) */}
|
||||
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin || isSuperuser) && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSubmission}
|
||||
disabled={actionLoading === item.id}
|
||||
className={`w-full ${isMobile ? 'h-11' : ''}`}
|
||||
size={isMobile ? "default" : "default"}
|
||||
>
|
||||
<Trash2 className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||
Delete Submission
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
QueueItemActions.displayName = 'QueueItemActions';
|
||||
67
src/components/moderation/renderers/QueueItemContext.tsx
Normal file
67
src/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} />
|
||||
<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';
|
||||
155
src/components/moderation/renderers/QueueItemHeader.tsx
Normal file
155
src/components/moderation/renderers/QueueItemHeader.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { MessageSquare, Image, FileText, Calendar, Edit, Lock, AlertCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ValidationSummary } from '../ValidationSummary';
|
||||
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;
|
||||
onValidationChange: (result: ValidationResult) => 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,
|
||||
onValidationChange
|
||||
}: 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">
|
||||
<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>
|
||||
)}
|
||||
{item.submission_items && item.submission_items.length > 0 && (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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/components/moderation/renderers/ReviewDisplay.tsx
Normal file
71
src/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={onOpenPhotos}
|
||||
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';
|
||||
@@ -332,7 +332,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
queue.refreshStats();
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
console.error("Error deleting submission:", errorMsg);
|
||||
logger.error("Error deleting submission:", { error: errorMsg, itemId: item.id });
|
||||
|
||||
setItems((prev) => {
|
||||
if (prev.some((i) => i.id === item.id)) return prev;
|
||||
@@ -373,7 +373,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
console.error("Error resetting submission:", errorMsg);
|
||||
logger.error("Error resetting submission:", { error: errorMsg, itemId: item.id });
|
||||
toast({
|
||||
title: "Reset Failed",
|
||||
description: errorMsg,
|
||||
@@ -441,7 +441,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
queue.refreshStats();
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
console.error("Error retrying failed items:", errorMsg);
|
||||
logger.error("Error retrying failed items:", { error: errorMsg, itemId: item.id });
|
||||
toast({
|
||||
title: "Retry Failed",
|
||||
description: errorMsg,
|
||||
|
||||
65
src/hooks/useKeyboardShortcuts.ts
Normal file
65
src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlOrCmd?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
handler: () => void;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface UseKeyboardShortcutsOptions {
|
||||
shortcuts: KeyboardShortcut[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for registering keyboard shortcuts
|
||||
* Automatically handles Cmd (Mac) vs Ctrl (Windows/Linux)
|
||||
*/
|
||||
export function useKeyboardShortcuts({ shortcuts, enabled = true }: UseKeyboardShortcutsOptions) {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Ignore shortcuts when typing in input fields
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const matchesKey = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||
const matchesCtrl = !shortcut.ctrlOrCmd || (event.ctrlKey || event.metaKey);
|
||||
const matchesShift = !shortcut.shift || event.shiftKey;
|
||||
const matchesAlt = !shortcut.alt || event.altKey;
|
||||
|
||||
if (matchesKey && matchesCtrl && matchesShift && matchesAlt) {
|
||||
event.preventDefault();
|
||||
shortcut.handler();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[shortcuts, enabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown, enabled]);
|
||||
|
||||
return {
|
||||
shortcuts: shortcuts.map(s => ({
|
||||
...s,
|
||||
displayKey: `${s.ctrlOrCmd ? '⌘/Ctrl + ' : ''}${s.shift ? 'Shift + ' : ''}${s.alt ? 'Alt + ' : ''}${s.key.toUpperCase()}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
154
src/lib/integrationTests/suites/moderationDependencyTests.ts
Normal file
154
src/lib/integrationTests/suites/moderationDependencyTests.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Multi-Item Dependency Resolution Integration Tests
|
||||
*
|
||||
* Tests for handling complex submission dependencies
|
||||
*/
|
||||
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
|
||||
export const moderationDependencyTestSuite: TestSuite = {
|
||||
id: 'moderation-dependencies',
|
||||
name: 'Multi-Item Dependency Resolution',
|
||||
description: 'Tests for handling complex submission dependencies',
|
||||
tests: [
|
||||
{
|
||||
id: 'dep-001',
|
||||
name: 'Approve Independent Items in Any Order',
|
||||
description: 'Verifies that items without dependencies can be approved in any order',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
if (!userData.user) throw new Error('No authenticated user');
|
||||
|
||||
// Create submission with 2 independent park items
|
||||
const { data: submission, error: createError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userData.user.id,
|
||||
submission_type: 'park',
|
||||
status: 'pending',
|
||||
content: { test: true }
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
// Create two park submission items (independent)
|
||||
const { error: items1Error } = await supabase
|
||||
.from('submission_items')
|
||||
.insert([
|
||||
{
|
||||
submission_id: submission.id,
|
||||
item_type: 'park',
|
||||
item_data: { name: 'Test Park 1', slug: 'test-park-1', country: 'US' },
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
submission_id: submission.id,
|
||||
item_type: 'park',
|
||||
item_data: { name: 'Test Park 2', slug: 'test-park-2', country: 'US' },
|
||||
status: 'pending'
|
||||
}
|
||||
]);
|
||||
|
||||
if (items1Error) throw items1Error;
|
||||
|
||||
// Get items
|
||||
const { data: items } = await supabase
|
||||
.from('submission_items')
|
||||
.select('id')
|
||||
.eq('submission_id', submission.id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (!items || items.length !== 2) {
|
||||
throw new Error('Failed to create submission items');
|
||||
}
|
||||
|
||||
// Approve second item first (should work - no dependencies)
|
||||
const { error: approve2Error } = await supabase
|
||||
.from('submission_items')
|
||||
.update({ status: 'approved' })
|
||||
.eq('id', items[1].id);
|
||||
|
||||
if (approve2Error) throw new Error('Failed to approve second item first');
|
||||
|
||||
// Approve first item second (should also work)
|
||||
const { error: approve1Error } = await supabase
|
||||
.from('submission_items')
|
||||
.update({ status: 'approved' })
|
||||
.eq('id', items[0].id);
|
||||
|
||||
if (approve1Error) throw new Error('Failed to approve first item second');
|
||||
|
||||
// Cleanup
|
||||
await supabase.from('content_submissions').delete().eq('id', submission.id);
|
||||
|
||||
return {
|
||||
id: 'dep-001',
|
||||
name: 'Approve Independent Items in Any Order',
|
||||
suite: 'Multi-Item Dependency Resolution',
|
||||
status: 'pass',
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: 'dep-001',
|
||||
name: 'Approve Independent Items in Any Order',
|
||||
suite: 'Multi-Item Dependency Resolution',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dep-002',
|
||||
name: 'Verify Submission Item Dependencies Exist',
|
||||
description: 'Verifies that submission items have proper dependency tracking',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Verify submission_items table has dependency columns
|
||||
const { data: testItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('id, status')
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
// If query succeeds, table exists and is accessible
|
||||
return {
|
||||
id: 'dep-002',
|
||||
name: 'Verify Submission Item Dependencies Exist',
|
||||
suite: 'Multi-Item Dependency Resolution',
|
||||
status: 'pass',
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
tableAccessible: true,
|
||||
testQuery: 'submission_items table verified'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: 'dep-002',
|
||||
name: 'Verify Submission Item Dependencies Exist',
|
||||
suite: 'Multi-Item Dependency Resolution',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
294
src/lib/integrationTests/suites/moderationLockTests.ts
Normal file
294
src/lib/integrationTests/suites/moderationLockTests.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Moderation Lock Management Integration Tests
|
||||
*
|
||||
* Tests for submission locking, claiming, extending, and release mechanisms
|
||||
*/
|
||||
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
|
||||
export const moderationLockTestSuite: TestSuite = {
|
||||
id: 'moderation-locks',
|
||||
name: 'Moderation Lock Management',
|
||||
description: 'Tests for submission locking, claiming, and release mechanisms',
|
||||
tests: [
|
||||
{
|
||||
id: 'lock-001',
|
||||
name: 'Claim Submission Creates Active Lock',
|
||||
description: 'Verifies that claiming a submission creates a lock with correct expiry',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
if (!userData.user) throw new Error('No authenticated user');
|
||||
|
||||
// 1. Create test submission
|
||||
const { data: submission, error: createError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userData.user.id,
|
||||
submission_type: 'park',
|
||||
status: 'pending',
|
||||
content: { test: true }
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
// 2. Claim the submission (manual update for testing)
|
||||
const { error: lockError } = await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
assigned_to: userData.user.id,
|
||||
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||||
})
|
||||
.eq('id', submission.id);
|
||||
|
||||
if (lockError) throw new Error(`Claim failed: ${lockError.message}`);
|
||||
|
||||
// 3. Verify lock exists
|
||||
const { data: lockedSubmission, error: fetchError } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('assigned_to, locked_until')
|
||||
.eq('id', submission.id)
|
||||
.single();
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
// 4. Assertions
|
||||
if (lockedSubmission.assigned_to !== userData.user.id) {
|
||||
throw new Error('Submission not assigned to current user');
|
||||
}
|
||||
|
||||
if (!lockedSubmission.locked_until) {
|
||||
throw new Error('locked_until not set');
|
||||
}
|
||||
|
||||
const lockedUntil = new Date(lockedSubmission.locked_until);
|
||||
const now = new Date();
|
||||
const diffMinutes = (lockedUntil.getTime() - now.getTime()) / (1000 * 60);
|
||||
|
||||
if (diffMinutes < 14 || diffMinutes > 16) {
|
||||
throw new Error(`Lock duration incorrect: ${diffMinutes} minutes`);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await supabase.from('content_submissions').delete().eq('id', submission.id);
|
||||
|
||||
return {
|
||||
id: 'lock-001',
|
||||
name: 'Claim Submission Creates Active Lock',
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'pass',
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
submissionId: submission.id,
|
||||
lockDurationMinutes: diffMinutes,
|
||||
assignedTo: lockedSubmission.assigned_to
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: 'lock-001',
|
||||
name: 'Claim Submission Creates Active Lock',
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lock-002',
|
||||
name: 'Release Lock Clears Assignment',
|
||||
description: 'Verifies that releasing a lock clears assigned_to and locked_until',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
if (!userData.user) throw new Error('No authenticated user');
|
||||
|
||||
// Create and claim submission
|
||||
const { data: submission, error: createError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userData.user.id,
|
||||
submission_type: 'park',
|
||||
status: 'pending',
|
||||
content: { test: true }
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
assigned_to: userData.user.id,
|
||||
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||||
})
|
||||
.eq('id', submission.id);
|
||||
|
||||
// Release lock
|
||||
const { error: releaseError } = await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
assigned_to: null,
|
||||
locked_until: null
|
||||
})
|
||||
.eq('id', submission.id);
|
||||
|
||||
if (releaseError) throw new Error(`release_lock failed: ${releaseError.message}`);
|
||||
|
||||
// Verify lock cleared
|
||||
const { data: releasedSubmission, error: fetchError } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('assigned_to, locked_until')
|
||||
.eq('id', submission.id)
|
||||
.single();
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
if (releasedSubmission.assigned_to !== null) {
|
||||
throw new Error('assigned_to not cleared');
|
||||
}
|
||||
|
||||
if (releasedSubmission.locked_until !== null) {
|
||||
throw new Error('locked_until not cleared');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await supabase.from('content_submissions').delete().eq('id', submission.id);
|
||||
|
||||
return {
|
||||
id: 'lock-002',
|
||||
name: 'Release Lock Clears Assignment',
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'pass',
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: 'lock-002',
|
||||
name: 'Release Lock Clears Assignment',
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lock-003',
|
||||
name: 'Extend Lock Adds 15 Minutes',
|
||||
description: 'Verifies that extending a lock adds correct duration',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
if (!userData.user) throw new Error('No authenticated user');
|
||||
|
||||
// Create and claim submission
|
||||
const { data: submission, error: createError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userData.user.id,
|
||||
submission_type: 'park',
|
||||
status: 'pending',
|
||||
content: { test: true }
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
const initialLockTime = new Date(Date.now() + 15 * 60 * 1000);
|
||||
await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
assigned_to: userData.user.id,
|
||||
locked_until: initialLockTime.toISOString()
|
||||
})
|
||||
.eq('id', submission.id);
|
||||
|
||||
// Get initial lock time
|
||||
const { data: initialLock } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('locked_until')
|
||||
.eq('id', submission.id)
|
||||
.single();
|
||||
|
||||
// Extend lock (add 15 more minutes)
|
||||
const extendedLockTime = new Date(initialLockTime.getTime() + 15 * 60 * 1000);
|
||||
const { error: extendError } = await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
locked_until: extendedLockTime.toISOString()
|
||||
})
|
||||
.eq('id', submission.id);
|
||||
|
||||
if (extendError) throw new Error(`extend_lock failed: ${extendError.message}`);
|
||||
|
||||
// Verify extended lock
|
||||
const { data: extendedLock, error: fetchError } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('locked_until')
|
||||
.eq('id', submission.id)
|
||||
.single();
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
if (!initialLock?.locked_until || !extendedLock.locked_until) {
|
||||
throw new Error('Lock times not found');
|
||||
}
|
||||
|
||||
const initialTime = new Date(initialLock.locked_until);
|
||||
const extendedTime = new Date(extendedLock.locked_until);
|
||||
const diffMinutes = (extendedTime.getTime() - initialTime.getTime()) / (1000 * 60);
|
||||
|
||||
if (diffMinutes < 14 || diffMinutes > 16) {
|
||||
throw new Error(`Extension duration incorrect: ${diffMinutes} minutes`);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await supabase.from('content_submissions').delete().eq('id', submission.id);
|
||||
|
||||
return {
|
||||
id: 'lock-003',
|
||||
name: 'Extend Lock Adds 15 Minutes',
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'pass',
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
extensionMinutes: diffMinutes
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: 'lock-003',
|
||||
name: 'Extend Lock Adds 15 Minutes',
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -5,6 +5,19 @@
|
||||
* Tests run against real database functions, edge functions, and API endpoints.
|
||||
*/
|
||||
|
||||
import { moderationTestSuite } from './suites/moderationTests';
|
||||
import { moderationLockTestSuite } from './suites/moderationLockTests';
|
||||
import { moderationDependencyTestSuite } from './suites/moderationDependencyTests';
|
||||
|
||||
/**
|
||||
* Registry of all available test suites
|
||||
*/
|
||||
export const ALL_TEST_SUITES = [
|
||||
moderationTestSuite,
|
||||
moderationLockTestSuite,
|
||||
moderationDependencyTestSuite
|
||||
];
|
||||
|
||||
export interface TestResult {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -24,5 +24,26 @@ export const logger = {
|
||||
},
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (isDev) console.debug(...args);
|
||||
},
|
||||
// Performance monitoring
|
||||
performance: (component: string, duration: number): void => {
|
||||
if (isDev) {
|
||||
console.log(`⚡ ${component} rendered in ${duration}ms`, {
|
||||
component,
|
||||
duration,
|
||||
category: 'performance',
|
||||
});
|
||||
}
|
||||
},
|
||||
// Moderation action tracking
|
||||
moderationAction: (action: string, itemId: string, duration: number): void => {
|
||||
if (isDev) {
|
||||
console.log(`🎯 Moderation action: ${action}`, {
|
||||
action,
|
||||
itemId,
|
||||
duration,
|
||||
category: 'moderation',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
import type { PhotoSubmissionItem } from './photo-submissions';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { PhotoSubmissionItem } from './photo-submissions';
|
||||
|
||||
// Core photo display interface
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
|
||||
147
tests/e2e/moderation/lock-management.spec.ts
Normal file
147
tests/e2e/moderation/lock-management.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* E2E Tests for Moderation Lock Management
|
||||
*
|
||||
* Browser-based tests for lock UI and interactions
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Helper function to login as moderator (adjust based on your auth setup)
|
||||
async function loginAsModerator(page: any) {
|
||||
await page.goto('/login');
|
||||
// TODO: Add your actual login steps here
|
||||
// For example:
|
||||
// await page.fill('[name="email"]', 'moderator@example.com');
|
||||
// await page.fill('[name="password"]', 'password123');
|
||||
// await page.click('button[type="submit"]');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
test.describe('Moderation Lock Management UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsModerator(page);
|
||||
await page.goto('/moderation/queue');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('moderator can see queue items', async ({ page }) => {
|
||||
// Wait for queue items to load
|
||||
const queueItems = page.locator('[data-testid="queue-item"]');
|
||||
|
||||
// Check if queue items are visible (may be 0 if queue is empty)
|
||||
const count = await queueItems.count();
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('moderator can claim pending submission', async ({ page }) => {
|
||||
// Wait for queue items to load
|
||||
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
|
||||
|
||||
// Find first pending item with claim button
|
||||
const firstItem = page.locator('[data-testid="queue-item"]').first();
|
||||
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
|
||||
|
||||
// Check if claim button exists
|
||||
const claimButtonCount = await claimButton.count();
|
||||
if (claimButtonCount === 0) {
|
||||
console.log('No unclaimed submissions found - skipping test');
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify button exists and is enabled
|
||||
await expect(claimButton).toBeVisible();
|
||||
await expect(claimButton).toBeEnabled();
|
||||
|
||||
// Click claim
|
||||
await claimButton.click();
|
||||
|
||||
// Verify lock UI appears (claimed by you badge)
|
||||
await expect(firstItem.locator('text=/claimed by you/i')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify approve/reject buttons are now enabled
|
||||
const approveButton = firstItem.locator('button:has-text("Approve")');
|
||||
const rejectButton = firstItem.locator('button:has-text("Reject")');
|
||||
await expect(approveButton).toBeEnabled();
|
||||
await expect(rejectButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('lock timer displays countdown', async ({ page }) => {
|
||||
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
|
||||
|
||||
const firstItem = page.locator('[data-testid="queue-item"]').first();
|
||||
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
|
||||
|
||||
const claimButtonCount = await claimButton.count();
|
||||
if (claimButtonCount === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Claim submission
|
||||
await claimButton.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Look for lock status display with timer (format: 14:XX or 15:00)
|
||||
const lockStatus = page.locator('[data-testid="lock-status-display"]');
|
||||
await expect(lockStatus).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('extend lock button appears when enabled', async ({ page }) => {
|
||||
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
|
||||
|
||||
const firstItem = page.locator('[data-testid="queue-item"]').first();
|
||||
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
|
||||
|
||||
const claimButtonCount = await claimButton.count();
|
||||
if (claimButtonCount === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Claim submission
|
||||
await claimButton.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if extend button exists (may not appear immediately if > 5 minutes remain)
|
||||
const extendButton = page.locator('button:has-text("Extend Lock")');
|
||||
const extendButtonCount = await extendButton.count();
|
||||
|
||||
// If button doesn't exist, that's expected behavior (> 5 minutes remaining)
|
||||
expect(extendButtonCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('release lock button clears claim', async ({ page }) => {
|
||||
await page.waitForSelector('[data-testid="queue-item"]', { timeout: 10000 });
|
||||
|
||||
const firstItem = page.locator('[data-testid="queue-item"]').first();
|
||||
const claimButton = firstItem.locator('button:has-text("Claim Submission")');
|
||||
|
||||
const claimButtonCount = await claimButton.count();
|
||||
if (claimButtonCount === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Claim submission
|
||||
await claimButton.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find and click release button
|
||||
const releaseButton = page.locator('button:has-text("Release Lock")');
|
||||
await expect(releaseButton).toBeVisible({ timeout: 5000 });
|
||||
await releaseButton.click();
|
||||
|
||||
// Verify claim button reappears
|
||||
await expect(claimButton).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('locked by another moderator shows warning', async ({ page }) => {
|
||||
// Check if any submission has the "Locked by Another Moderator" badge
|
||||
const lockedBadge = page.locator('text=/Locked by .*/i');
|
||||
|
||||
// If no locked submissions, this test is informational only
|
||||
const count = await lockedBadge.count();
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user