mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 15:31:13 -05:00
Reverted to commit 96a961d95c
This commit is contained in:
@@ -1,55 +0,0 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
<Alert variant="destructive" className="max-w-lg">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Something went wrong</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-4">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<Button onClick={this.handleReset} variant="outline">
|
||||
Reload Page
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export function AuthButtons() {
|
||||
const {
|
||||
user,
|
||||
profile,
|
||||
loading,
|
||||
signOut
|
||||
} = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -41,21 +40,10 @@ export function AuthButtons() {
|
||||
setLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading skeleton during auth check
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="h-8 w-16 bg-muted animate-pulse rounded" />
|
||||
<div className="h-8 w-8 bg-muted animate-pulse rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { supabase } from '@/integrations/supabase/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { TurnstileCaptcha } from './TurnstileCaptcha';
|
||||
import { notificationService } from '@/lib/notificationService';
|
||||
import { useCaptchaBypass } from '@/hooks/useCaptchaBypass';
|
||||
|
||||
interface AuthModalProps {
|
||||
open: boolean;
|
||||
@@ -35,8 +34,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
displayName: ''
|
||||
});
|
||||
|
||||
const { requireCaptcha } = useCaptchaBypass();
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -48,7 +45,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
if (requireCaptcha && !signInCaptchaToken) {
|
||||
if (!signInCaptchaToken) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "CAPTCHA required",
|
||||
@@ -62,26 +59,19 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
setSignInCaptchaToken(null);
|
||||
|
||||
try {
|
||||
const signInOptions: any = {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
if (tokenToUse) {
|
||||
signInOptions.options = { captchaToken: tokenToUse };
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword(signInOptions);
|
||||
options: {
|
||||
captchaToken: tokenToUse
|
||||
}
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
toast({
|
||||
title: "Welcome back!",
|
||||
description: "You've been signed in successfully."
|
||||
});
|
||||
|
||||
// Wait for auth state to propagate before closing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
setSignInCaptchaKey(prev => prev + 1);
|
||||
@@ -119,7 +109,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireCaptcha && !captchaToken) {
|
||||
if (!captchaToken) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "CAPTCHA required",
|
||||
@@ -133,22 +123,17 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
setCaptchaToken(null);
|
||||
|
||||
try {
|
||||
const signUpOptions: any = {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
options: {
|
||||
captchaToken: tokenToUse,
|
||||
data: {
|
||||
username: formData.username,
|
||||
display_name: formData.displayName
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (tokenToUse) {
|
||||
signUpOptions.options.captchaToken = tokenToUse;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signUp(signUpOptions);
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -303,23 +288,21 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requireCaptcha && (
|
||||
<div>
|
||||
<TurnstileCaptcha
|
||||
key={signInCaptchaKey}
|
||||
onSuccess={setSignInCaptchaToken}
|
||||
onError={() => setSignInCaptchaToken(null)}
|
||||
onExpire={() => setSignInCaptchaToken(null)}
|
||||
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
|
||||
theme="auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TurnstileCaptcha
|
||||
key={signInCaptchaKey}
|
||||
onSuccess={setSignInCaptchaToken}
|
||||
onError={() => setSignInCaptchaToken(null)}
|
||||
onExpire={() => setSignInCaptchaToken(null)}
|
||||
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
|
||||
theme="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || (requireCaptcha && !signInCaptchaToken)}
|
||||
disabled={loading || !signInCaptchaToken}
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
@@ -465,23 +448,21 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requireCaptcha && (
|
||||
<div>
|
||||
<TurnstileCaptcha
|
||||
key={captchaKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onError={() => setCaptchaToken(null)}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
|
||||
theme="auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TurnstileCaptcha
|
||||
key={captchaKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onError={() => setCaptchaToken(null)}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
|
||||
theme="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || (requireCaptcha && !captchaToken)}
|
||||
disabled={loading || !captchaToken}
|
||||
>
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
</Button>
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Eye, Calendar } from 'lucide-react';
|
||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface BlogPostCardProps {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
featuredImageId?: string;
|
||||
author: {
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
publishedAt: string;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
export function BlogPostCard({
|
||||
slug,
|
||||
title,
|
||||
content,
|
||||
featuredImageId,
|
||||
author,
|
||||
publishedAt,
|
||||
viewCount,
|
||||
}: BlogPostCardProps) {
|
||||
const excerpt = content.substring(0, 150) + (content.length > 150 ? '...' : '');
|
||||
|
||||
return (
|
||||
<Link to={`/blog/${slug}`}>
|
||||
<Card className="overflow-hidden hover:scale-[1.02] hover:shadow-xl transition-all duration-300 group">
|
||||
<div className="aspect-[16/9] overflow-hidden bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
{featuredImageId ? (
|
||||
<img
|
||||
src={getCloudflareImageUrl(featuredImageId, 'public')}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">📝</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-3">
|
||||
<h3 className="text-xl font-bold line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{excerpt}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="w-6 h-6">
|
||||
<AvatarImage src={author.avatarUrl} />
|
||||
<AvatarFallback>
|
||||
{author.displayName?.[0] || author.username[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{author.displayName || author.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDistanceToNow(new Date(publishedAt), { addSuffix: true })}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{viewCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={cn(
|
||||
'prose dark:prose-invert max-w-none',
|
||||
'prose-headings:font-bold prose-headings:tracking-tight',
|
||||
'prose-h1:text-4xl prose-h2:text-3xl prose-h3:text-2xl',
|
||||
'prose-p:text-base prose-p:leading-relaxed',
|
||||
'prose-a:text-primary prose-a:no-underline hover:prose-a:underline',
|
||||
'prose-strong:text-foreground prose-strong:font-semibold',
|
||||
'prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm',
|
||||
'prose-pre:bg-muted prose-pre:border prose-pre:border-border',
|
||||
'prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:italic',
|
||||
'prose-img:rounded-lg prose-img:shadow-lg',
|
||||
'prose-hr:border-border',
|
||||
'prose-ul:list-disc prose-ol:list-decimal',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -50,8 +50,23 @@ export function ContentTabs() {
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
// Recent changes will be populated from other sources since entity_versions requires auth
|
||||
const changesData: any[] = [];
|
||||
// Fetch recent entity changes
|
||||
const { data: changesData } = await supabase
|
||||
.from('entity_versions')
|
||||
.select(`
|
||||
id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
version_number,
|
||||
version_data,
|
||||
changed_at,
|
||||
change_type,
|
||||
change_reason,
|
||||
changer_profile:profiles!entity_versions_changed_by_fkey(username, avatar_url)
|
||||
`)
|
||||
.eq('is_current', true)
|
||||
.order('changed_at', { ascending: false })
|
||||
.limit(24);
|
||||
|
||||
// Process changes to extract entity info from version_data
|
||||
const processedChanges = changesData?.map(change => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen } from 'lucide-react';
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText } from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
@@ -19,7 +19,6 @@ export function AdminSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const { permissions } = useUserRole();
|
||||
const isSuperuser = permissions?.role_level === 'superuser';
|
||||
const isAdmin = permissions?.role_level === 'admin' || isSuperuser;
|
||||
const collapsed = state === 'collapsed';
|
||||
|
||||
const navItems = [
|
||||
@@ -48,11 +47,6 @@ export function AdminSidebar() {
|
||||
url: '/admin/users',
|
||||
icon: Users,
|
||||
},
|
||||
...(isAdmin ? [{
|
||||
title: 'Blog',
|
||||
url: '/admin/blog',
|
||||
icon: BookOpen,
|
||||
}] : []),
|
||||
...(isSuperuser ? [{
|
||||
title: 'Settings',
|
||||
url: '/admin/settings',
|
||||
|
||||
@@ -27,12 +27,6 @@ export function Footer() {
|
||||
>
|
||||
Submission Guidelines
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,12 +62,6 @@ interface ModerationItem {
|
||||
assigned_to?: string;
|
||||
locked_until?: string;
|
||||
_removing?: boolean;
|
||||
submission_items?: Array<{
|
||||
id: string;
|
||||
item_type: string;
|
||||
item_data: any;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
||||
@@ -130,7 +124,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const isMountingRef = useRef(true);
|
||||
const initialFetchCompleteRef = useRef(false);
|
||||
const FETCH_COOLDOWN_MS = 1000;
|
||||
const isPageVisible = useRef(true);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -191,16 +184,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
localStorage.setItem('moderationQueue_sortConfig', JSON.stringify(sortConfig));
|
||||
}, [sortConfig]);
|
||||
|
||||
// Sync itemsRef with items state (after React commits)
|
||||
// Only sync itemsRef (not loadedIdsRef) to avoid breaking silent polling logic
|
||||
useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
// Sync loadedIdsRef with items state (after React commits)
|
||||
useEffect(() => {
|
||||
loadedIdsRef.current = new Set(items.map(item => item.id));
|
||||
}, [items]);
|
||||
|
||||
// Enable transitions after initial render
|
||||
useEffect(() => {
|
||||
if (loadingState === 'ready' && items.length > 0 && !hasRenderedOnce) {
|
||||
@@ -213,34 +201,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
}
|
||||
}, [loadingState, items.length, hasRenderedOnce]);
|
||||
|
||||
// Track page visibility to prevent tab-switch refreshes
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const wasHidden = !isPageVisible.current;
|
||||
isPageVisible.current = !document.hidden;
|
||||
|
||||
if (wasHidden && isPageVisible.current) {
|
||||
console.log('📄 Page became visible - NOT auto-refreshing queue');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false, tab: QueueTab = 'mainQueue') => {
|
||||
console.log('🔍 fetchItems called:', {
|
||||
hasUser: !!userRef.current,
|
||||
entityFilter,
|
||||
statusFilter,
|
||||
silent,
|
||||
tab,
|
||||
loadingState,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!userRef.current) {
|
||||
console.warn('⚠️ fetchItems: No user available yet, cannot fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,12 +212,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip fetch if triggered during tab visibility change (unless manual refresh)
|
||||
if (!silent && !isPageVisible.current) {
|
||||
console.log('👁️ Skipping fetch while page is hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cooldown check - prevent rapid-fire calls
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
@@ -299,13 +255,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
reviewer_notes,
|
||||
escalated,
|
||||
assigned_to,
|
||||
locked_until,
|
||||
submission_items (
|
||||
id,
|
||||
item_type,
|
||||
item_data,
|
||||
status
|
||||
)
|
||||
locked_until
|
||||
`)
|
||||
.order('escalated', { ascending: false })
|
||||
.order('created_at', { ascending: true });
|
||||
@@ -610,8 +560,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const existingIds = new Set(prev.map(p => p.id));
|
||||
const uniqueNew = newSubmissions.filter(item => !existingIds.has(item.id));
|
||||
|
||||
// Track count increment (loadedIdsRef will sync automatically via useEffect)
|
||||
// Track these IDs as loaded to prevent re-counting on next poll
|
||||
if (uniqueNew.length > 0) {
|
||||
const newIds = uniqueNew.map(item => item.id);
|
||||
const currentLoadedIds = loadedIdsRef.current;
|
||||
loadedIdsRef.current = new Set([...currentLoadedIds, ...newIds]);
|
||||
setNewItemsCount(prev => prev + uniqueNew.length);
|
||||
}
|
||||
|
||||
@@ -652,6 +605,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
});
|
||||
|
||||
if (mergeResult.hasChanges) {
|
||||
// Update ref BEFORE setState to prevent race conditions
|
||||
itemsRef.current = mergeResult.items;
|
||||
setItems(mergeResult.items);
|
||||
console.log('🔄 Queue updated (replace mode):', {
|
||||
added: mergeResult.changes.added.length,
|
||||
@@ -693,6 +648,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
});
|
||||
|
||||
if (mergeResult.hasChanges) {
|
||||
// Update ref BEFORE setState to prevent race conditions
|
||||
itemsRef.current = mergeResult.items;
|
||||
setItems(mergeResult.items);
|
||||
console.log('🔄 Queue updated (manual refresh):', {
|
||||
added: mergeResult.changes.added.length,
|
||||
@@ -773,28 +730,15 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
|
||||
// Initial fetch on mount and filter changes
|
||||
useEffect(() => {
|
||||
console.log('🎯 Initial fetch effect:', {
|
||||
hasUser: !!user,
|
||||
loadingState,
|
||||
hasInitialFetchRef: hasInitialFetchRef.current,
|
||||
initialFetchComplete: initialFetchCompleteRef.current,
|
||||
isMounting: isMountingRef.current
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log('⏳ Waiting for user to be available...');
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
|
||||
// Phase 1: Initial fetch (run once)
|
||||
if (!hasInitialFetchRef.current) {
|
||||
console.log('✅ Triggering initial fetch');
|
||||
hasInitialFetchRef.current = true;
|
||||
isMountingRef.current = true;
|
||||
|
||||
fetchItems(debouncedEntityFilter, debouncedStatusFilter, false)
|
||||
.then(() => {
|
||||
console.log('✅ Initial fetch complete');
|
||||
initialFetchCompleteRef.current = true;
|
||||
// Wait for DOM to paint before allowing subsequent fetches
|
||||
requestAnimationFrame(() => {
|
||||
@@ -806,7 +750,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
|
||||
// Phase 2: Filter changes (only after initial fetch completes)
|
||||
if (!isMountingRef.current && initialFetchCompleteRef.current) {
|
||||
console.log('🔄 Filter changed, fetching with debounce');
|
||||
debouncedFetchItems(debouncedEntityFilter, debouncedStatusFilter, true, activeTab);
|
||||
}
|
||||
}, [debouncedEntityFilter, debouncedStatusFilter, user, activeTab, fetchItems, debouncedFetchItems]);
|
||||
@@ -881,13 +824,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
.from('content_submissions')
|
||||
.select(`
|
||||
id, submission_type, status, content, created_at, user_id,
|
||||
reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until,
|
||||
submission_items (
|
||||
id,
|
||||
item_type,
|
||||
item_data,
|
||||
status
|
||||
)
|
||||
reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until
|
||||
`)
|
||||
.eq('id', newSubmission.id)
|
||||
.single();
|
||||
@@ -995,7 +932,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const newTimeout = setTimeout(() => {
|
||||
updateFn();
|
||||
realtimeUpdateDebounceRef.current.delete(submissionId);
|
||||
}, 1000); // Wait 1000ms after last event
|
||||
}, 500); // Wait 500ms after last event
|
||||
|
||||
realtimeUpdateDebounceRef.current.set(submissionId, newTimeout);
|
||||
}, []);
|
||||
@@ -1067,13 +1004,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
.from('content_submissions')
|
||||
.select(`
|
||||
id, submission_type, status, content, created_at, user_id,
|
||||
reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until,
|
||||
submission_items (
|
||||
id,
|
||||
item_type,
|
||||
item_data,
|
||||
status
|
||||
)
|
||||
reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until
|
||||
`)
|
||||
.eq('id', updatedSubmission.id)
|
||||
.single();
|
||||
@@ -1106,7 +1037,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
escalated: submission.escalated,
|
||||
assigned_to: submission.assigned_to || undefined,
|
||||
locked_until: submission.locked_until || undefined,
|
||||
submission_items: submission.submission_items || undefined,
|
||||
};
|
||||
|
||||
// Update or add to queue
|
||||
@@ -1122,61 +1052,26 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const hasChanged =
|
||||
currentItem.status !== fullItem.status ||
|
||||
currentItem.reviewed_at !== fullItem.reviewed_at ||
|
||||
currentItem.reviewed_by !== fullItem.reviewed_by ||
|
||||
currentItem.reviewer_notes !== fullItem.reviewer_notes ||
|
||||
currentItem.assigned_to !== fullItem.assigned_to ||
|
||||
currentItem.locked_until !== fullItem.locked_until ||
|
||||
currentItem.escalated !== fullItem.escalated;
|
||||
JSON.stringify(currentItem.content) !== JSON.stringify(fullItem.content);
|
||||
|
||||
// Only check content if critical fields match (performance optimization)
|
||||
let contentChanged = false;
|
||||
if (!hasChanged && currentItem.content && fullItem.content) {
|
||||
// Compare content reference first
|
||||
if (currentItem.content !== fullItem.content) {
|
||||
// Check each key for actual value changes (one level deep)
|
||||
const currentKeys = Object.keys(currentItem.content).sort();
|
||||
const fullKeys = Object.keys(fullItem.content).sort();
|
||||
|
||||
if (currentKeys.length !== fullKeys.length ||
|
||||
!currentKeys.every((key, i) => key === fullKeys[i])) {
|
||||
contentChanged = true;
|
||||
} else {
|
||||
for (const key of currentKeys) {
|
||||
if (currentItem.content[key] !== fullItem.content[key]) {
|
||||
contentChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanged && !contentChanged) {
|
||||
if (!hasChanged) {
|
||||
console.log('✅ Realtime UPDATE: No changes detected for', fullItem.id);
|
||||
return prev; // Keep existing array reference - PREVENTS RE-RENDER
|
||||
return prev; // Keep existing array reference
|
||||
}
|
||||
|
||||
console.log('🔄 Realtime UPDATE: Changes detected for', fullItem.id);
|
||||
// Update ONLY changed fields to preserve object stability
|
||||
return prev.map(i => {
|
||||
if (i.id !== fullItem.id) return i;
|
||||
|
||||
// Create minimal update object with only changed fields
|
||||
const updates: Partial<ModerationItem> = {};
|
||||
if (i.status !== fullItem.status) updates.status = fullItem.status;
|
||||
if (i.reviewed_at !== fullItem.reviewed_at) updates.reviewed_at = fullItem.reviewed_at;
|
||||
if (i.reviewer_notes !== fullItem.reviewer_notes) updates.reviewer_notes = fullItem.reviewer_notes;
|
||||
if (i.assigned_to !== fullItem.assigned_to) updates.assigned_to = fullItem.assigned_to;
|
||||
if (i.locked_until !== fullItem.locked_until) updates.locked_until = fullItem.locked_until;
|
||||
if (i.escalated !== fullItem.escalated) updates.escalated = fullItem.escalated;
|
||||
if (contentChanged) updates.content = fullItem.content;
|
||||
if (fullItem.submission_items) updates.submission_items = fullItem.submission_items;
|
||||
|
||||
// Only create new object if there are actual updates
|
||||
return Object.keys(updates).length > 0 ? { ...i, ...updates } : i;
|
||||
});
|
||||
const newItems = prev.map(i => i.id === fullItem.id ? fullItem : i);
|
||||
itemsRef.current = newItems; // Update ref immediately
|
||||
return newItems;
|
||||
} else {
|
||||
console.log('🆕 Realtime UPDATE: Adding new item', fullItem.id);
|
||||
return [fullItem, ...prev];
|
||||
const newItems = [fullItem, ...prev];
|
||||
itemsRef.current = newItems; // Update ref immediately
|
||||
return newItems;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2310,13 +2205,23 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Instant merge without loading state
|
||||
// Smooth merge with loading state
|
||||
if (pendingNewItems.length > 0) {
|
||||
setItems(prev => [...pendingNewItems, ...prev]);
|
||||
setPendingNewItems([]);
|
||||
setNewItemsCount(0);
|
||||
console.log('✅ New items merged into queue:', pendingNewItems.length);
|
||||
setLoadingState('loading');
|
||||
|
||||
// After 150ms, merge items
|
||||
setTimeout(() => {
|
||||
setItems(prev => [...pendingNewItems, ...prev]);
|
||||
setPendingNewItems([]);
|
||||
setNewItemsCount(0);
|
||||
|
||||
// Show content again after brief pause
|
||||
setTimeout(() => {
|
||||
setLoadingState('ready');
|
||||
}, 100);
|
||||
}, 150);
|
||||
}
|
||||
console.log('✅ New items merged into queue');
|
||||
}}
|
||||
className="ml-4"
|
||||
>
|
||||
|
||||
@@ -39,12 +39,6 @@ interface ModerationItem {
|
||||
assigned_to?: string;
|
||||
locked_until?: string;
|
||||
_removing?: boolean;
|
||||
submission_items?: Array<{
|
||||
id: string;
|
||||
item_type: string;
|
||||
item_data: any;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
import { ValidationSummary } from './ValidationSummary';
|
||||
@@ -174,12 +168,12 @@ export const QueueItem = memo(({
|
||||
Claimed by You
|
||||
</Badge>
|
||||
)}
|
||||
{item.submission_items && item.submission_items.length > 0 && (
|
||||
{item.submission_type && (
|
||||
<ValidationSummary
|
||||
item={{
|
||||
item_type: item.submission_items[0].item_type,
|
||||
item_data: item.submission_items[0].item_data,
|
||||
id: item.submission_items[0].id,
|
||||
item_type: item.submission_type,
|
||||
item_data: item.content,
|
||||
id: item.id,
|
||||
}}
|
||||
compact={true}
|
||||
onValidationChange={handleValidationChange}
|
||||
@@ -668,16 +662,16 @@ export const QueueItem = memo(({
|
||||
const nextLocked = nextProps.lockedSubmissions.has(nextProps.item.id);
|
||||
if (prevLocked !== nextLocked) return false;
|
||||
|
||||
// Deep comparison of critical fields (use strict equality for reference stability)
|
||||
if (prevProps.item.status !== nextProps.item.status) return false;
|
||||
// Deep comparison of content and other fields that affect rendering
|
||||
if (prevProps.item.reviewed_at !== nextProps.item.reviewed_at) return false;
|
||||
if (prevProps.item.reviewed_by !== nextProps.item.reviewed_by) return false;
|
||||
if (prevProps.item.reviewer_notes !== nextProps.item.reviewer_notes) return false;
|
||||
if (prevProps.item.assigned_to !== nextProps.item.assigned_to) return false;
|
||||
if (prevProps.item.locked_until !== nextProps.item.locked_until) return false;
|
||||
if (prevProps.item.escalated !== nextProps.item.escalated) return false;
|
||||
|
||||
// Only check content reference, not deep equality (performance)
|
||||
if (prevProps.item.content !== nextProps.item.content) return false;
|
||||
// Content comparison (most expensive, do last)
|
||||
if (JSON.stringify(prevProps.item.content) !== JSON.stringify(nextProps.item.content)) return false;
|
||||
|
||||
// All checks passed - items are identical
|
||||
return true;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocationAutoDetect } from '@/hooks/useLocationAutoDetect';
|
||||
|
||||
interface LocationAutoDetectProviderProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function LocationAutoDetectProvider({ children }: LocationAutoDetectProviderProps) {
|
||||
export function LocationAutoDetectProvider() {
|
||||
useLocationAutoDetect();
|
||||
return <>{children}</>;
|
||||
return null; // This component doesn't render anything, just runs the hook
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function VersionComparisonDialog({
|
||||
};
|
||||
|
||||
loadDiff();
|
||||
}, [open, fromVersionId, toVersionId, compareVersions]);
|
||||
}, [open, fromVersionId, toVersionId]);
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
|
||||
Reference in New Issue
Block a user