Reverted to commit 06ed528d76

This commit is contained in:
gpt-engineer-app[bot]
2025-10-11 15:58:56 +00:00
parent 1df9ada8ae
commit f37b99a5f9
33 changed files with 2509 additions and 140 deletions

View File

@@ -12,6 +12,7 @@ export function AuthButtons() {
const {
user,
profile,
loading,
signOut
} = useAuth();
const navigate = useNavigate();
@@ -40,10 +41,21 @@ 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={() => {

View File

@@ -10,6 +10,7 @@ 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;
@@ -34,6 +35,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
displayName: ''
});
const { requireCaptcha } = useCaptchaBypass();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
@@ -45,7 +48,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
e.preventDefault();
setLoading(true);
if (!signInCaptchaToken) {
if (requireCaptcha && !signInCaptchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
@@ -59,19 +62,26 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
setSignInCaptchaToken(null);
try {
const { error } = await supabase.auth.signInWithPassword({
const signInOptions: any = {
email: formData.email,
password: formData.password,
options: {
captchaToken: tokenToUse
}
});
};
if (tokenToUse) {
signInOptions.options = { captchaToken: tokenToUse };
}
const { error } = await supabase.auth.signInWithPassword(signInOptions);
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);
@@ -109,7 +119,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
return;
}
if (!captchaToken) {
if (requireCaptcha && !captchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
@@ -123,17 +133,22 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
setCaptchaToken(null);
try {
const { data, error } = await supabase.auth.signUp({
const signUpOptions: any = {
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;
@@ -288,21 +303,23 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
</div>
</div>
<div>
<TurnstileCaptcha
key={signInCaptchaKey}
onSuccess={setSignInCaptchaToken}
onError={() => setSignInCaptchaToken(null)}
onExpire={() => setSignInCaptchaToken(null)}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme="auto"
/>
</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>
)}
<Button
type="submit"
className="w-full"
disabled={loading || !signInCaptchaToken}
disabled={loading || (requireCaptcha && !signInCaptchaToken)}
>
{loading ? "Signing in..." : "Sign In"}
</Button>
@@ -448,21 +465,23 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
</div>
</div>
<div>
<TurnstileCaptcha
key={captchaKey}
onSuccess={setCaptchaToken}
onError={() => setCaptchaToken(null)}
onExpire={() => setCaptchaToken(null)}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme="auto"
/>
</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>
)}
<Button
type="submit"
className="w-full"
disabled={loading || !captchaToken}
disabled={loading || (requireCaptcha && !captchaToken)}
>
{loading ? "Creating account..." : "Create Account"}
</Button>

View File

@@ -0,0 +1,88 @@
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>
);
}

View File

@@ -0,0 +1,31 @@
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>
);
}

View File

@@ -50,23 +50,8 @@ export function ContentTabs() {
.order('created_at', { ascending: false })
.limit(12);
// 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);
// Recent changes will be populated from other sources since entity_versions requires auth
const changesData: any[] = [];
// Process changes to extract entity info from version_data
const processedChanges = changesData?.map(change => {

View File

@@ -1,4 +1,4 @@
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText } from 'lucide-react';
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { useUserRole } from '@/hooks/useUserRole';
import { useSidebar } from '@/hooks/useSidebar';
@@ -19,6 +19,7 @@ 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 = [
@@ -47,6 +48,11 @@ export function AdminSidebar() {
url: '/admin/users',
icon: Users,
},
...(isAdmin ? [{
title: 'Blog',
url: '/admin/blog',
icon: BookOpen,
}] : []),
...(isSuperuser ? [{
title: 'Settings',
url: '/admin/settings',

View File

@@ -27,6 +27,12 @@ export function Footer() {
>
Submission Guidelines
</Link>
<Link
to="/blog"
className="hover:text-foreground transition-colors"
>
Blog
</Link>
</div>
</div>
</div>

View File

@@ -62,6 +62,12 @@ 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';
@@ -184,11 +190,16 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
localStorage.setItem('moderationQueue_sortConfig', JSON.stringify(sortConfig));
}, [sortConfig]);
// Only sync itemsRef (not loadedIdsRef) to avoid breaking silent polling logic
// Sync itemsRef with items state (after React commits)
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) {
@@ -255,7 +266,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
reviewer_notes,
escalated,
assigned_to,
locked_until
locked_until,
submission_items (
id,
item_type,
item_data,
status
)
`)
.order('escalated', { ascending: false })
.order('created_at', { ascending: true });
@@ -560,11 +577,8 @@ 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 these IDs as loaded to prevent re-counting on next poll
// Track count increment (loadedIdsRef will sync automatically via useEffect)
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);
}
@@ -605,8 +619,6 @@ 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,
@@ -648,8 +660,6 @@ 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,
@@ -824,7 +834,13 @@ 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
reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until,
submission_items (
id,
item_type,
item_data,
status
)
`)
.eq('id', newSubmission.id)
.single();
@@ -932,7 +948,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const newTimeout = setTimeout(() => {
updateFn();
realtimeUpdateDebounceRef.current.delete(submissionId);
}, 500); // Wait 500ms after last event
}, 1000); // Wait 1000ms after last event
realtimeUpdateDebounceRef.current.set(submissionId, newTimeout);
}, []);
@@ -1004,7 +1020,13 @@ 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
reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until,
submission_items (
id,
item_type,
item_data,
status
)
`)
.eq('id', updatedSubmission.id)
.single();
@@ -1037,6 +1059,7 @@ 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
@@ -1052,26 +1075,61 @@ 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 ||
JSON.stringify(currentItem.content) !== JSON.stringify(fullItem.content);
currentItem.escalated !== fullItem.escalated;
if (!hasChanged) {
// 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) {
console.log('✅ Realtime UPDATE: No changes detected for', fullItem.id);
return prev; // Keep existing array reference
return prev; // Keep existing array reference - PREVENTS RE-RENDER
}
console.log('🔄 Realtime UPDATE: Changes detected for', fullItem.id);
const newItems = prev.map(i => i.id === fullItem.id ? fullItem : i);
itemsRef.current = newItems; // Update ref immediately
return newItems;
// 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;
});
} else {
console.log('🆕 Realtime UPDATE: Adding new item', fullItem.id);
const newItems = [fullItem, ...prev];
itemsRef.current = newItems; // Update ref immediately
return newItems;
return [fullItem, ...prev];
}
});
} catch (error) {
@@ -2205,23 +2263,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
variant="default"
size="sm"
onClick={() => {
// Smooth merge with loading state
// Instant merge without loading state
if (pendingNewItems.length > 0) {
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);
setItems(prev => [...pendingNewItems, ...prev]);
setPendingNewItems([]);
setNewItemsCount(0);
console.log('✅ New items merged into queue:', pendingNewItems.length);
}
console.log('✅ New items merged into queue');
}}
className="ml-4"
>

View File

@@ -39,6 +39,12 @@ 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';
@@ -168,12 +174,12 @@ export const QueueItem = memo(({
Claimed by You
</Badge>
)}
{item.submission_type && (
{item.submission_items && item.submission_items.length > 0 && (
<ValidationSummary
item={{
item_type: item.submission_type,
item_data: item.content,
id: item.id,
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}
@@ -662,16 +668,16 @@ export const QueueItem = memo(({
const nextLocked = nextProps.lockedSubmissions.has(nextProps.item.id);
if (prevLocked !== nextLocked) return false;
// Deep comparison of content and other fields that affect rendering
// Deep comparison of critical fields (use strict equality for reference stability)
if (prevProps.item.status !== nextProps.item.status) return false;
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;
// Content comparison (most expensive, do last)
if (JSON.stringify(prevProps.item.content) !== JSON.stringify(nextProps.item.content)) return false;
// Only check content reference, not deep equality (performance)
if (prevProps.item.content !== nextProps.item.content) return false;
// All checks passed - items are identical
return true;

View File

@@ -41,7 +41,7 @@ export function VersionComparisonDialog({
};
loadDiff();
}, [open, fromVersionId, toVersionId]);
}, [open, fromVersionId, toVersionId, compareVersions]);
const formatValue = (value: any): string => {
if (value === null || value === undefined) return 'null';