diff --git a/package-lock.json b/package-lock.json index b367cee5..f0e1af49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 59f924e8..eaa1c941 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/moderation/ConfirmationDialog.tsx b/src/components/moderation/ConfirmationDialog.tsx new file mode 100644 index 00000000..779c0c1e --- /dev/null +++ b/src/components/moderation/ConfirmationDialog.tsx @@ -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 ( + + + + {title} + {description} + + + {cancelLabel} + + {confirmLabel} + + + + + ); +}; + +ConfirmationDialog.displayName = 'ConfirmationDialog'; diff --git a/src/components/moderation/EmptyQueueState.tsx b/src/components/moderation/EmptyQueueState.tsx index efbca20f..4acda1e6 100644 --- a/src/components/moderation/EmptyQueueState.tsx +++ b/src/components/moderation/EmptyQueueState.tsx @@ -49,3 +49,5 @@ export const EmptyQueueState = ({ ); }; + +EmptyQueueState.displayName = 'EmptyQueueState'; diff --git a/src/components/moderation/EnhancedEmptyState.tsx b/src/components/moderation/EnhancedEmptyState.tsx new file mode 100644 index 00000000..20fc5ac9 --- /dev/null +++ b/src/components/moderation/EnhancedEmptyState.tsx @@ -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 ( +
+
+ +
+

{variant.title}

+

+ {variant.description} +

+ {variant.action && ( + + )} +
+ ); +}; + +EnhancedEmptyState.displayName = 'EnhancedEmptyState'; diff --git a/src/components/moderation/EnhancedLockStatusDisplay.tsx b/src/components/moderation/EnhancedLockStatusDisplay.tsx new file mode 100644 index 00000000..680a76e1 --- /dev/null +++ b/src/components/moderation/EnhancedLockStatusDisplay.tsx @@ -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(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 ( +
+
+ + No submission claimed +
+ {queueStats && ( +
+ {queueStats.pendingCount} pending +
+ )} +
+ ); + } + + return ( +
+
+
+ + Lock expires in +
+ + {formatTime(timeLeft)} + +
+ + div]:bg-destructive', + urgency === 'warning' && '[&>div]:bg-yellow-500', + urgency === 'safe' && '[&>div]:bg-primary' + )} + /> + + {urgency === 'critical' && ( +
+ + Lock expiring soon! Extend or release. +
+ )} + +
+ {showExtendButton && ( + + )} + +
+
+ ); +}; + +EnhancedLockStatusDisplay.displayName = 'EnhancedLockStatusDisplay'; diff --git a/src/components/moderation/KeyboardShortcutsHelp.tsx b/src/components/moderation/KeyboardShortcutsHelp.tsx new file mode 100644 index 00000000..f04b15bc --- /dev/null +++ b/src/components/moderation/KeyboardShortcutsHelp.tsx @@ -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 ( + + + + + + Keyboard Shortcuts + + + Speed up your moderation workflow with these keyboard shortcuts + + +
+ {shortcuts.map((shortcut, index) => ( +
+ + {shortcut.description} + + + {shortcut.key} + +
+ ))} +
+
+
+ ); +}; + +KeyboardShortcutsHelp.displayName = 'KeyboardShortcutsHelp'; diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 9116f927..8686a35f 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -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(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('[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
- queueManager.queue.extendLock(queueManager.queue.currentLock?.submissionId || '')} + onReleaseLock={() => queueManager.queue.releaseLock(queueManager.queue.currentLock?.submissionId || '', false)} + getCurrentTime={() => new Date()} />
@@ -192,9 +251,10 @@ export const ModerationQueue = forwardRef ) : queueManager.items.length === 0 ? ( - ) : ( @@ -222,7 +282,7 @@ export const ModerationQueue = forwardRef queueManager.markInteracting(id, true)} onInteractionBlur={(id) => queueManager.markInteracting(id, false)} /> @@ -274,6 +334,24 @@ export const ModerationQueue = forwardRef )} + + {/* Confirmation Dialog */} + setConfirmDialog(prev => ({ ...prev, open }))} + title={confirmDialog.title} + description={confirmDialog.description} + onConfirm={confirmDialog.onConfirm} + variant="destructive" + confirmLabel="Delete" + /> + + {/* Keyboard Shortcuts Help */} + ); }); diff --git a/src/components/moderation/QueueItem.tsx b/src/components/moderation/QueueItem.tsx index abc2a066..0bc73b07 100644 --- a/src/components/moderation/QueueItem.tsx +++ b/src/components/moderation/QueueItem.tsx @@ -201,68 +201,12 @@ export const QueueItem = memo(({ })()} ) : item.submission_type === 'photo' ? ( -
-
- Photo Submission -
- - {/* Submission Title */} - {item.content.title && ( -
-
Title:
-

{item.content.title}

-
- )} - - {/* Photos from relational table */} - {photosLoading ? ( -
Loading photos...
- ) : photoItems.length > 0 ? ( -
-
- Photos ({photoItems.length}): - {import.meta.env.DEV && photoItems[0] && ( - - URL: {photoItems[0].cloudflare_image_url?.slice(0, 30)}... - - )} -
- ({ - 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} - /> -
- ) : ( - - - No Photos Found - - This photo submission has no photos attached. This may be a data integrity issue. - - - )} - - {/* Context Information */} - {item.entity_name && ( -
- For: - {item.entity_name} - {item.park_name && ( - <> - at - {item.park_name} - - )} -
- )} -
+ ) : ( <> {/* Main content area - spans 1st column on all layouts */} diff --git a/src/components/moderation/QueuePagination.tsx b/src/components/moderation/QueuePagination.tsx index f1950808..242092ad 100644 --- a/src/components/moderation/QueuePagination.tsx +++ b/src/components/moderation/QueuePagination.tsx @@ -159,3 +159,5 @@ export const QueuePagination = ({ ); }; + +QueuePagination.displayName = 'QueuePagination'; diff --git a/src/components/moderation/QueueStats.tsx b/src/components/moderation/QueueStats.tsx index ad756dce..58485475 100644 --- a/src/components/moderation/QueueStats.tsx +++ b/src/components/moderation/QueueStats.tsx @@ -27,3 +27,5 @@ export const QueueStats = ({ stats, isMobile }: QueueStatsProps) => { ); }; + +QueueStats.displayName = 'QueueStats'; diff --git a/src/components/moderation/renderers/QueueItemActions.tsx b/src/components/moderation/renderers/QueueItemActions.tsx index 1500fa65..782aec8b 100644 --- a/src/components/moderation/renderers/QueueItemActions.tsx +++ b/src/components/moderation/renderers/QueueItemActions.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import { CheckCircle, XCircle, RefreshCw, AlertCircle, Lock, Trash2, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar @@ -60,6 +60,59 @@ export const QueueItemActions = memo(({ onInteractionBlur, onClaim }: QueueItemActionsProps) => { + // Memoize all handlers to prevent re-renders + const handleNoteChange = useCallback((e: React.ChangeEvent) => { + 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) => { + 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 */} @@ -142,9 +195,9 @@ export const QueueItemActions = memo(({ 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)} + onChange={handleNoteChange} + onFocus={handleFocus} + onBlur={handleBlur} rows={isMobile ? 2 : 4} className={!isMobile ? 'min-h-[120px]' : ''} disabled={isLockedByOther || currentLockSubmissionId !== item.id} @@ -157,7 +210,7 @@ export const QueueItemActions = memo(({ {item.type === 'content_submission' && ( <>