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

5
.env
View File

@@ -7,7 +7,7 @@ VITE_SUPABASE_URL="https://ydvtmnrszybqnbcqbdcy.supabase.co"
# For development, you can use test keys: # For development, you can use test keys:
# - Always passes: 1x00000000000000000000AA # - Always passes: 1x00000000000000000000AA
# - Always fails: 3x00000000000000000000FF # - Always fails: 3x00000000000000000000FF
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAAyqVp3RjccrC9Kz VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
# Cloudflare Images # Cloudflare Images
VITE_CLOUDFLARE_ACCOUNT_HASH=X-2-mmiWukWxvAQQ2_o-7Q VITE_CLOUDFLARE_ACCOUNT_HASH=X-2-mmiWukWxvAQQ2_o-7Q
@@ -16,3 +16,6 @@ VITE_CLOUDFLARE_ACCOUNT_HASH=X-2-mmiWukWxvAQQ2_o-7Q
VITE_NOVU_APPLICATION_IDENTIFIER="" VITE_NOVU_APPLICATION_IDENTIFIER=""
VITE_NOVU_SOCKET_URL="wss://ws.novu.co" VITE_NOVU_SOCKET_URL="wss://ws.novu.co"
VITE_NOVU_API_URL="https://api.novu.co" VITE_NOVU_API_URL="https://api.novu.co"
# CAPTCHA Bypass (Development/Preview Only)
VITE_ALLOW_CAPTCHA_BYPASS=true

View File

@@ -14,6 +14,12 @@ VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key
# Cloudflare Images Configuration # Cloudflare Images Configuration
VITE_CLOUDFLARE_ACCOUNT_HASH=your-cloudflare-account-hash VITE_CLOUDFLARE_ACCOUNT_HASH=your-cloudflare-account-hash
# CAPTCHA Bypass Control (Development/Preview Only)
# Set to 'true' to bypass CAPTCHA verification during authentication
# This is controlled ONLY via environment variable for simplicity
# MUST be 'false' or unset in production for security
VITE_ALLOW_CAPTCHA_BYPASS=false
# Novu Configuration # Novu Configuration
# For Novu Cloud, use these defaults: # For Novu Cloud, use these defaults:
# - Socket URL: wss://ws.novu.co # - Socket URL: wss://ws.novu.co

1283
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.61.1", "react-hook-form": "^7.61.1",
"react-markdown": "^9.1.0",
"react-resizable-panels": "^2.1.9", "react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",

View File

@@ -40,6 +40,9 @@ import AdminReports from "./pages/AdminReports";
import AdminSystemLog from "./pages/AdminSystemLog"; import AdminSystemLog from "./pages/AdminSystemLog";
import AdminUsers from "./pages/AdminUsers"; import AdminUsers from "./pages/AdminUsers";
import AdminSettings from "./pages/AdminSettings"; import AdminSettings from "./pages/AdminSettings";
import BlogIndex from "./pages/BlogIndex";
import BlogPost from "./pages/BlogPost";
import AdminBlog from "./pages/AdminBlog";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -64,6 +67,8 @@ function AppContent() {
<Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} /> <Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} />
<Route path="/rides" element={<Rides />} /> <Route path="/rides" element={<Rides />} />
<Route path="/search" element={<Search />} /> <Route path="/search" element={<Search />} />
<Route path="/blog" element={<BlogIndex />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route path="/manufacturers" element={<Manufacturers />} /> <Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/manufacturers/:slug" element={<ManufacturerDetail />} /> <Route path="/manufacturers/:slug" element={<ManufacturerDetail />} />
<Route path="/manufacturers/:manufacturerSlug/rides" element={<ManufacturerRides />} /> <Route path="/manufacturers/:manufacturerSlug/rides" element={<ManufacturerRides />} />
@@ -86,6 +91,7 @@ function AppContent() {
<Route path="/admin/reports" element={<AdminReports />} /> <Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/system-log" element={<AdminSystemLog />} /> <Route path="/admin/system-log" element={<AdminSystemLog />} />
<Route path="/admin/users" element={<AdminUsers />} /> <Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/blog" element={<AdminBlog />} />
<Route path="/admin/settings" element={<AdminSettings />} /> <Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/terms" element={<Terms />} /> <Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} /> <Route path="/privacy" element={<Privacy />} />

View File

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

View File

@@ -10,6 +10,7 @@ import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { TurnstileCaptcha } from './TurnstileCaptcha'; import { TurnstileCaptcha } from './TurnstileCaptcha';
import { notificationService } from '@/lib/notificationService'; import { notificationService } from '@/lib/notificationService';
import { useCaptchaBypass } from '@/hooks/useCaptchaBypass';
interface AuthModalProps { interface AuthModalProps {
open: boolean; open: boolean;
@@ -34,6 +35,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
displayName: '' displayName: ''
}); });
const { requireCaptcha } = useCaptchaBypass();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@@ -45,7 +48,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
if (!signInCaptchaToken) { if (requireCaptcha && !signInCaptchaToken) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "CAPTCHA required", title: "CAPTCHA required",
@@ -59,19 +62,26 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
setSignInCaptchaToken(null); setSignInCaptchaToken(null);
try { try {
const { error } = await supabase.auth.signInWithPassword({ const signInOptions: any = {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
options: { };
captchaToken: tokenToUse
if (tokenToUse) {
signInOptions.options = { captchaToken: tokenToUse };
} }
});
const { error } = await supabase.auth.signInWithPassword(signInOptions);
if (error) throw error; if (error) throw error;
toast({ toast({
title: "Welcome back!", title: "Welcome back!",
description: "You've been signed in successfully." description: "You've been signed in successfully."
}); });
// Wait for auth state to propagate before closing
await new Promise(resolve => setTimeout(resolve, 100));
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: any) {
setSignInCaptchaKey(prev => prev + 1); setSignInCaptchaKey(prev => prev + 1);
@@ -109,7 +119,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
return; return;
} }
if (!captchaToken) { if (requireCaptcha && !captchaToken) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "CAPTCHA required", title: "CAPTCHA required",
@@ -123,17 +133,22 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
setCaptchaToken(null); setCaptchaToken(null);
try { try {
const { data, error } = await supabase.auth.signUp({ const signUpOptions: any = {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
options: { options: {
captchaToken: tokenToUse,
data: { data: {
username: formData.username, username: formData.username,
display_name: formData.displayName display_name: formData.displayName
} }
} }
}); };
if (tokenToUse) {
signUpOptions.options.captchaToken = tokenToUse;
}
const { data, error } = await supabase.auth.signUp(signUpOptions);
if (error) throw error; if (error) throw error;
@@ -288,6 +303,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
</div> </div>
</div> </div>
{requireCaptcha && (
<div> <div>
<TurnstileCaptcha <TurnstileCaptcha
key={signInCaptchaKey} key={signInCaptchaKey}
@@ -298,11 +314,12 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
theme="auto" theme="auto"
/> />
</div> </div>
)}
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={loading || !signInCaptchaToken} disabled={loading || (requireCaptcha && !signInCaptchaToken)}
> >
{loading ? "Signing in..." : "Sign In"} {loading ? "Signing in..." : "Sign In"}
</Button> </Button>
@@ -448,6 +465,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
</div> </div>
</div> </div>
{requireCaptcha && (
<div> <div>
<TurnstileCaptcha <TurnstileCaptcha
key={captchaKey} key={captchaKey}
@@ -458,11 +476,12 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
theme="auto" theme="auto"
/> />
</div> </div>
)}
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={loading || !captchaToken} disabled={loading || (requireCaptcha && !captchaToken)}
> >
{loading ? "Creating account..." : "Create Account"} {loading ? "Creating account..." : "Create Account"}
</Button> </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 }) .order('created_at', { ascending: false })
.limit(12); .limit(12);
// Fetch recent entity changes // Recent changes will be populated from other sources since entity_versions requires auth
const { data: changesData } = await supabase const changesData: any[] = [];
.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 // Process changes to extract entity info from version_data
const processedChanges = changesData?.map(change => { 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 { NavLink } from 'react-router-dom';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { useSidebar } from '@/hooks/useSidebar'; import { useSidebar } from '@/hooks/useSidebar';
@@ -19,6 +19,7 @@ export function AdminSidebar() {
const { state } = useSidebar(); const { state } = useSidebar();
const { permissions } = useUserRole(); const { permissions } = useUserRole();
const isSuperuser = permissions?.role_level === 'superuser'; const isSuperuser = permissions?.role_level === 'superuser';
const isAdmin = permissions?.role_level === 'admin' || isSuperuser;
const collapsed = state === 'collapsed'; const collapsed = state === 'collapsed';
const navItems = [ const navItems = [
@@ -47,6 +48,11 @@ export function AdminSidebar() {
url: '/admin/users', url: '/admin/users',
icon: Users, icon: Users,
}, },
...(isAdmin ? [{
title: 'Blog',
url: '/admin/blog',
icon: BookOpen,
}] : []),
...(isSuperuser ? [{ ...(isSuperuser ? [{
title: 'Settings', title: 'Settings',
url: '/admin/settings', url: '/admin/settings',

View File

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

View File

@@ -62,6 +62,12 @@ interface ModerationItem {
assigned_to?: string; assigned_to?: string;
locked_until?: string; locked_until?: string;
_removing?: boolean; _removing?: boolean;
submission_items?: Array<{
id: string;
item_type: string;
item_data: any;
status: string;
}>;
} }
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
@@ -184,11 +190,16 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
localStorage.setItem('moderationQueue_sortConfig', JSON.stringify(sortConfig)); localStorage.setItem('moderationQueue_sortConfig', JSON.stringify(sortConfig));
}, [sortConfig]); }, [sortConfig]);
// Only sync itemsRef (not loadedIdsRef) to avoid breaking silent polling logic // Sync itemsRef with items state (after React commits)
useEffect(() => { useEffect(() => {
itemsRef.current = items; itemsRef.current = items;
}, [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 // Enable transitions after initial render
useEffect(() => { useEffect(() => {
if (loadingState === 'ready' && items.length > 0 && !hasRenderedOnce) { if (loadingState === 'ready' && items.length > 0 && !hasRenderedOnce) {
@@ -255,7 +266,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
reviewer_notes, reviewer_notes,
escalated, escalated,
assigned_to, assigned_to,
locked_until locked_until,
submission_items (
id,
item_type,
item_data,
status
)
`) `)
.order('escalated', { ascending: false }) .order('escalated', { ascending: false })
.order('created_at', { ascending: true }); .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 existingIds = new Set(prev.map(p => p.id));
const uniqueNew = newSubmissions.filter(item => !existingIds.has(item.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) { 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); setNewItemsCount(prev => prev + uniqueNew.length);
} }
@@ -605,8 +619,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}); });
if (mergeResult.hasChanges) { if (mergeResult.hasChanges) {
// Update ref BEFORE setState to prevent race conditions
itemsRef.current = mergeResult.items;
setItems(mergeResult.items); setItems(mergeResult.items);
console.log('🔄 Queue updated (replace mode):', { console.log('🔄 Queue updated (replace mode):', {
added: mergeResult.changes.added.length, added: mergeResult.changes.added.length,
@@ -648,8 +660,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}); });
if (mergeResult.hasChanges) { if (mergeResult.hasChanges) {
// Update ref BEFORE setState to prevent race conditions
itemsRef.current = mergeResult.items;
setItems(mergeResult.items); setItems(mergeResult.items);
console.log('🔄 Queue updated (manual refresh):', { console.log('🔄 Queue updated (manual refresh):', {
added: mergeResult.changes.added.length, added: mergeResult.changes.added.length,
@@ -824,7 +834,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.from('content_submissions') .from('content_submissions')
.select(` .select(`
id, submission_type, status, content, created_at, user_id, 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) .eq('id', newSubmission.id)
.single(); .single();
@@ -932,7 +948,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const newTimeout = setTimeout(() => { const newTimeout = setTimeout(() => {
updateFn(); updateFn();
realtimeUpdateDebounceRef.current.delete(submissionId); realtimeUpdateDebounceRef.current.delete(submissionId);
}, 500); // Wait 500ms after last event }, 1000); // Wait 1000ms after last event
realtimeUpdateDebounceRef.current.set(submissionId, newTimeout); realtimeUpdateDebounceRef.current.set(submissionId, newTimeout);
}, []); }, []);
@@ -1004,7 +1020,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.from('content_submissions') .from('content_submissions')
.select(` .select(`
id, submission_type, status, content, created_at, user_id, 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) .eq('id', updatedSubmission.id)
.single(); .single();
@@ -1037,6 +1059,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
escalated: submission.escalated, escalated: submission.escalated,
assigned_to: submission.assigned_to || undefined, assigned_to: submission.assigned_to || undefined,
locked_until: submission.locked_until || undefined, locked_until: submission.locked_until || undefined,
submission_items: submission.submission_items || undefined,
}; };
// Update or add to queue // Update or add to queue
@@ -1052,26 +1075,61 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const hasChanged = const hasChanged =
currentItem.status !== fullItem.status || currentItem.status !== fullItem.status ||
currentItem.reviewed_at !== fullItem.reviewed_at || currentItem.reviewed_at !== fullItem.reviewed_at ||
currentItem.reviewed_by !== fullItem.reviewed_by ||
currentItem.reviewer_notes !== fullItem.reviewer_notes || currentItem.reviewer_notes !== fullItem.reviewer_notes ||
currentItem.assigned_to !== fullItem.assigned_to || currentItem.assigned_to !== fullItem.assigned_to ||
currentItem.locked_until !== fullItem.locked_until || 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); 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); console.log('🔄 Realtime UPDATE: Changes detected for', fullItem.id);
const newItems = prev.map(i => i.id === fullItem.id ? fullItem : i); // Update ONLY changed fields to preserve object stability
itemsRef.current = newItems; // Update ref immediately return prev.map(i => {
return newItems; 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 { } else {
console.log('🆕 Realtime UPDATE: Adding new item', fullItem.id); console.log('🆕 Realtime UPDATE: Adding new item', fullItem.id);
const newItems = [fullItem, ...prev]; return [fullItem, ...prev];
itemsRef.current = newItems; // Update ref immediately
return newItems;
} }
}); });
} catch (error) { } catch (error) {
@@ -2205,23 +2263,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
variant="default" variant="default"
size="sm" size="sm"
onClick={() => { onClick={() => {
// Smooth merge with loading state // Instant merge without loading state
if (pendingNewItems.length > 0) { if (pendingNewItems.length > 0) {
setLoadingState('loading');
// After 150ms, merge items
setTimeout(() => {
setItems(prev => [...pendingNewItems, ...prev]); setItems(prev => [...pendingNewItems, ...prev]);
setPendingNewItems([]); setPendingNewItems([]);
setNewItemsCount(0); setNewItemsCount(0);
console.log('✅ New items merged into queue:', pendingNewItems.length);
// Show content again after brief pause
setTimeout(() => {
setLoadingState('ready');
}, 100);
}, 150);
} }
console.log('✅ New items merged into queue');
}} }}
className="ml-4" className="ml-4"
> >

View File

@@ -39,6 +39,12 @@ interface ModerationItem {
assigned_to?: string; assigned_to?: string;
locked_until?: string; locked_until?: string;
_removing?: boolean; _removing?: boolean;
submission_items?: Array<{
id: string;
item_type: string;
item_data: any;
status: string;
}>;
} }
import { ValidationSummary } from './ValidationSummary'; import { ValidationSummary } from './ValidationSummary';
@@ -168,12 +174,12 @@ export const QueueItem = memo(({
Claimed by You Claimed by You
</Badge> </Badge>
)} )}
{item.submission_type && ( {item.submission_items && item.submission_items.length > 0 && (
<ValidationSummary <ValidationSummary
item={{ item={{
item_type: item.submission_type, item_type: item.submission_items[0].item_type,
item_data: item.content, item_data: item.submission_items[0].item_data,
id: item.id, id: item.submission_items[0].id,
}} }}
compact={true} compact={true}
onValidationChange={handleValidationChange} onValidationChange={handleValidationChange}
@@ -662,16 +668,16 @@ export const QueueItem = memo(({
const nextLocked = nextProps.lockedSubmissions.has(nextProps.item.id); const nextLocked = nextProps.lockedSubmissions.has(nextProps.item.id);
if (prevLocked !== nextLocked) return false; 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_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.reviewer_notes !== nextProps.item.reviewer_notes) return false;
if (prevProps.item.assigned_to !== nextProps.item.assigned_to) 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.locked_until !== nextProps.item.locked_until) return false;
if (prevProps.item.escalated !== nextProps.item.escalated) return false; if (prevProps.item.escalated !== nextProps.item.escalated) return false;
// Content comparison (most expensive, do last) // Only check content reference, not deep equality (performance)
if (JSON.stringify(prevProps.item.content) !== JSON.stringify(nextProps.item.content)) return false; if (prevProps.item.content !== nextProps.item.content) return false;
// All checks passed - items are identical // All checks passed - items are identical
return true; return true;

View File

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

View File

@@ -78,6 +78,7 @@ export function useAdminSettings() {
return settings?.filter(s => s.category === category) || []; return settings?.filter(s => s.category === category) || [];
}; };
const updateSetting = async (key: string, value: any) => { const updateSetting = async (key: string, value: any) => {
return updateSettingMutation.mutateAsync({ key, value }); return updateSettingMutation.mutateAsync({ key, value });
}; };

View File

@@ -93,13 +93,23 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
const { const {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => { } = supabase.auth.onAuthStateChange((event, session) => {
console.log('[Auth] State change:', event, 'User:', session?.user?.email || 'none');
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
const currentEmail = session?.user?.email; const currentEmail = session?.user?.email;
const newEmailPending = session?.user?.new_email; const newEmailPending = session?.user?.new_email;
// Explicitly handle SIGNED_IN event for iframe compatibility
if (event === 'SIGNED_IN' && session) {
console.log('[Auth] SIGNED_IN detected, setting session and user');
setSession(session);
setUser(session.user);
setLoading(false);
} else {
setSession(session); setSession(session);
setUser(session?.user ?? null); setUser(session?.user ?? null);
}
// Track pending email changes // Track pending email changes
setPendingEmail(newEmailPending ?? null); setPendingEmail(newEmailPending ?? null);

View File

@@ -0,0 +1,23 @@
import { useEffect } from 'react';
export function useCaptchaBypass() {
// Single layer: Check if environment allows bypass
const bypassEnabled = import.meta.env.VITE_ALLOW_CAPTCHA_BYPASS === 'true';
// Log warning if bypass is active
useEffect(() => {
if (bypassEnabled && typeof window !== 'undefined') {
console.warn(
'⚠️ CAPTCHA BYPASS IS ACTIVE\n' +
'CAPTCHA verification is disabled via VITE_ALLOW_CAPTCHA_BYPASS=true\n' +
'This should ONLY be enabled in development/preview environments.\n' +
'Ensure VITE_ALLOW_CAPTCHA_BYPASS=false in production!'
);
}
}, [bypassEnabled]);
return {
bypassEnabled,
requireCaptcha: !bypassEnabled,
};
}

View File

@@ -294,7 +294,7 @@ export function useEntityVersions(entityType: string, entityId: string) {
channelRef.current = null; channelRef.current = null;
} }
}; };
}, [entityType, entityId, fetchVersions]); }, [entityType, entityId]);
// Set mounted ref on mount and cleanup on unmount // Set mounted ref on mount and cleanup on unmount
useEffect(() => { useEffect(() => {

View File

@@ -1,6 +1,7 @@
// This file is automatically generated. Do not edit it directly. // This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import type { Database } from './types'; import type { Database } from './types';
import { authStorage } from '@/lib/authStorage';
const SUPABASE_URL = "https://ydvtmnrszybqnbcqbdcy.supabase.co"; const SUPABASE_URL = "https://ydvtmnrszybqnbcqbdcy.supabase.co";
const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4"; const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4";
@@ -10,7 +11,7 @@ const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiO
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: { auth: {
storage: localStorage, storage: authStorage,
persistSession: true, persistSession: true,
autoRefreshToken: true, autoRefreshToken: true,
} }

View File

@@ -74,6 +74,66 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
blog_posts: {
Row: {
author_id: string
content: string
created_at: string | null
featured_image_id: string | null
featured_image_url: string | null
id: string
published_at: string | null
slug: string
status: string
title: string
updated_at: string | null
view_count: number | null
}
Insert: {
author_id: string
content: string
created_at?: string | null
featured_image_id?: string | null
featured_image_url?: string | null
id?: string
published_at?: string | null
slug: string
status?: string
title: string
updated_at?: string | null
view_count?: number | null
}
Update: {
author_id?: string
content?: string
created_at?: string | null
featured_image_id?: string | null
featured_image_url?: string | null
id?: string
published_at?: string | null
slug?: string
status?: string
title?: string
updated_at?: string | null
view_count?: number | null
}
Relationships: [
{
foreignKeyName: "blog_posts_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "filtered_profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "blog_posts_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
]
}
companies: { companies: {
Row: { Row: {
average_rating: number | null average_rating: number | null
@@ -2899,6 +2959,10 @@ export type Database = {
Args: { ip_text: string } Args: { ip_text: string }
Returns: string Returns: string
} }
increment_blog_view_count: {
Args: { post_slug: string }
Returns: undefined
}
is_moderator: { is_moderator: {
Args: { _user_id: string } Args: { _user_id: string }
Returns: boolean Returns: boolean

59
src/lib/authStorage.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Custom storage adapter for Supabase authentication that handles iframe localStorage restrictions.
* Falls back to sessionStorage or in-memory storage if localStorage is blocked.
*/
class AuthStorage {
private storage: Storage | null = null;
private memoryStorage: Map<string, string> = new Map();
private storageType: 'localStorage' | 'sessionStorage' | 'memory' = 'memory';
constructor() {
// Try localStorage first
try {
localStorage.setItem('__supabase_test__', 'test');
localStorage.removeItem('__supabase_test__');
this.storage = localStorage;
this.storageType = 'localStorage';
console.log('[AuthStorage] Using localStorage ✓');
} catch {
// Try sessionStorage as fallback
try {
sessionStorage.setItem('__supabase_test__', 'test');
sessionStorage.removeItem('__supabase_test__');
this.storage = sessionStorage;
this.storageType = 'sessionStorage';
console.warn('[AuthStorage] localStorage blocked, using sessionStorage ⚠️');
} catch {
// Use in-memory storage as last resort
this.storageType = 'memory';
console.error('[AuthStorage] Both localStorage and sessionStorage blocked, using in-memory storage ⛔');
console.error('[AuthStorage] Sessions will NOT persist across page reloads!');
}
}
}
getItem(key: string): string | null {
if (this.storage) {
return this.storage.getItem(key);
}
return this.memoryStorage.get(key) || null;
}
setItem(key: string, value: string): void {
if (this.storage) {
this.storage.setItem(key, value);
} else {
this.memoryStorage.set(key, value);
}
}
removeItem(key: string): void {
if (this.storage) {
this.storage.removeItem(key);
} else {
this.memoryStorage.delete(key);
}
}
}
export const authStorage = new AuthStorage();

View File

@@ -98,7 +98,7 @@ export const rideValidationSchema = z.object({
export const companyValidationSchema = z.object({ export const companyValidationSchema = z.object({
name: z.string().trim().min(1, 'Company name is required').max(200, 'Name must be less than 200 characters'), name: z.string().trim().min(1, 'Company name is required').max(200, 'Name must be less than 200 characters'),
slug: z.string().trim().min(1, 'Slug is required').regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'), slug: z.string().trim().min(1, 'Slug is required').regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens'),
company_type: z.enum(['manufacturer', 'designer', 'operator', 'property_owner']).optional(), company_type: z.enum(['manufacturer', 'designer', 'operator', 'property_owner']),
description: z.string().max(2000, 'Description must be less than 2000 characters').optional(), description: z.string().max(2000, 'Description must be less than 2000 characters').optional(),
person_type: z.enum(['company', 'individual', 'firm', 'organization']), person_type: z.enum(['company', 'individual', 'firm', 'organization']),
founded_year: z.number().min(1800, 'Founded year must be after 1800').max(currentYear, `Founded year cannot be in the future`).optional(), founded_year: z.number().min(1800, 'Founded year must be after 1800').max(currentYear, `Founded year cannot be in the future`).optional(),

376
src/pages/AdminBlog.tsx Normal file
View File

@@ -0,0 +1,376 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
import { generateSlugFromName } from '@/lib/slugUtils';
import { extractCloudflareImageId } from '@/lib/cloudflareImageUtils';
import { Edit, Trash2, Eye, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns';
interface BlogPost {
id: string;
slug: string;
title: string;
content: string;
featured_image_id?: string;
featured_image_url?: string;
status: 'draft' | 'published';
published_at?: string;
view_count: number;
created_at: string;
}
export default function AdminBlog() {
const { user } = useAuth();
const { isAdmin, loading } = useUserRole();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState('posts');
const [editingPost, setEditingPost] = useState<BlogPost | null>(null);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [content, setContent] = useState('');
const [featuredImageId, setFeaturedImageId] = useState('');
const [featuredImageUrl, setFeaturedImageUrl] = useState('');
// All mutations must be called before conditional returns
const saveMutation = useMutation({
mutationFn: async ({ isDraft }: { isDraft: boolean }) => {
const postData = {
title,
slug,
content,
featured_image_id: featuredImageId || null,
featured_image_url: featuredImageUrl || null,
author_id: user!.id,
status: isDraft ? 'draft' : 'published',
published_at: isDraft ? null : new Date().toISOString(),
};
if (editingPost) {
const { error } = await supabase
.from('blog_posts')
.update(postData)
.eq('id', editingPost.id);
if (error) throw error;
} else {
const { error } = await supabase
.from('blog_posts')
.insert(postData);
if (error) throw error;
}
},
onSuccess: (_, { isDraft }) => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
toast.success(isDraft ? 'Draft saved' : 'Post published');
resetForm();
setActiveTab('posts');
},
onError: (error) => {
toast.error('Failed to save post: ' + error.message);
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase
.from('blog_posts')
.delete()
.eq('id', id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
toast.success('Post deleted');
},
onError: (error) => {
toast.error('Failed to delete post: ' + error.message);
},
});
const { data: posts, isLoading } = useQuery({
queryKey: ['admin-blog-posts'],
queryFn: async () => {
const { data, error } = await supabase
.from('blog_posts')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data as BlogPost[];
},
});
// useEffect must be called before conditional returns
useEffect(() => {
if (!loading && !isAdmin()) {
navigate('/');
}
}, [loading, isAdmin, navigate]);
// Show loading state while checking permissions
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
</AdminLayout>
);
}
// Don't render if not admin
if (!loading && !isAdmin()) {
return null;
}
const resetForm = () => {
setTitle('');
setSlug('');
setContent('');
setFeaturedImageId('');
setFeaturedImageUrl('');
setEditingPost(null);
};
const loadPostForEditing = (post: BlogPost) => {
setEditingPost(post);
setTitle(post.title);
setSlug(post.slug);
setContent(post.content);
setFeaturedImageId(post.featured_image_id || '');
setFeaturedImageUrl(post.featured_image_url || '');
setActiveTab('editor');
};
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle);
if (!editingPost) {
setSlug(generateSlugFromName(newTitle));
}
};
const handleImageUpload = (imageId: string, imageUrl: string) => {
setFeaturedImageId(imageId);
setFeaturedImageUrl(imageUrl);
};
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Blog Management</h1>
<p className="text-muted-foreground">Create and manage blog posts</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="posts">Posts</TabsTrigger>
<TabsTrigger value="editor">
{editingPost ? 'Edit Post' : 'New Post'}
</TabsTrigger>
</TabsList>
<TabsContent value="posts" className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
{posts?.length || 0} total posts
</p>
<Button onClick={() => {
resetForm();
setActiveTab('editor');
}}>
<Plus className="w-4 h-4 mr-2" />
New Post
</Button>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Published</TableHead>
<TableHead>Views</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center">
Loading...
</TableCell>
</TableRow>
) : posts?.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center">
No posts yet. Create your first post!
</TableCell>
</TableRow>
) : (
posts?.map((post) => (
<TableRow key={post.id}>
<TableCell className="font-medium">
{post.title}
</TableCell>
<TableCell>
<Badge variant={post.status === 'published' ? 'default' : 'secondary'}>
{post.status}
</Badge>
</TableCell>
<TableCell>
{post.published_at
? formatDistanceToNow(new Date(post.published_at), { addSuffix: true })
: '-'
}
</TableCell>
<TableCell>{post.view_count}</TableCell>
<TableCell className="text-right space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => window.open(`/blog/${post.slug}`, '_blank')}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => loadPostForEditing(post)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Delete this post?')) {
deleteMutation.mutate(post.id);
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</TabsContent>
<TabsContent value="editor" className="space-y-6">
<Card className="p-6 space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Enter post title..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="post-url-slug"
/>
<p className="text-xs text-muted-foreground">
URL: /blog/{slug || 'post-url-slug'}
</p>
</div>
<div className="space-y-2">
<Label>Featured Image</Label>
<UppyPhotoUpload
onUploadComplete={(urls) => {
if (urls.length > 0) {
const url = urls[0];
const imageId = extractCloudflareImageId(url);
if (imageId) {
handleImageUpload(imageId, url);
toast.success('Image uploaded');
}
}
}}
maxFiles={1}
/>
{featuredImageUrl && (
<img
src={featuredImageUrl}
alt="Preview"
className="w-full max-w-md rounded-lg"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="content">Content (Markdown) *</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your post in markdown..."
rows={20}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Supports markdown formatting: **bold**, *italic*, # Headings, [links](url), etc.
</p>
</div>
<div className="flex gap-3">
<Button
onClick={() => saveMutation.mutate({ isDraft: true })}
disabled={!title || !slug || !content || saveMutation.isPending}
variant="outline"
>
Save Draft
</Button>
<Button
onClick={() => saveMutation.mutate({ isDraft: false })}
disabled={!title || !slug || !content || saveMutation.isPending}
>
Publish
</Button>
<Button
variant="ghost"
onClick={() => {
resetForm();
setActiveTab('posts');
}}
>
Cancel
</Button>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
</AdminLayout>
);
}

View File

@@ -13,7 +13,7 @@ import { useUserRole } from '@/hooks/useUserRole';
import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useAdminSettings } from '@/hooks/useAdminSettings';
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility'; import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
import { TestDataGenerator } from '@/components/admin/TestDataGenerator'; import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug } from 'lucide-react'; import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock } from 'lucide-react';
export default function AdminSettings() { export default function AdminSettings() {
const { user } = useAuth(); const { user } = useAuth();
@@ -435,7 +435,7 @@ export default function AdminSettings() {
</div> </div>
<Tabs defaultValue="moderation" className="space-y-6"> <Tabs defaultValue="moderation" className="space-y-6">
<TabsList className="grid w-full grid-cols-6"> <TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="moderation" className="flex items-center gap-2"> <TabsTrigger value="moderation" className="flex items-center gap-2">
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
<span className="hidden sm:inline">Moderation</span> <span className="hidden sm:inline">Moderation</span>

130
src/pages/BlogIndex.tsx Normal file
View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { BlogPostCard } from '@/components/blog/BlogPostCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
const POSTS_PER_PAGE = 9;
export default function BlogIndex() {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['blog-posts', page, searchQuery],
queryFn: async () => {
let query = supabase
.from('blog_posts')
.select('*, profiles!inner(username, display_name, avatar_url)', { count: 'exact' })
.eq('status', 'published')
.order('published_at', { ascending: false })
.range((page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE - 1);
if (searchQuery) {
query = query.or(`title.ilike.%${searchQuery}%,content.ilike.%${searchQuery}%`);
}
const { data: posts, count, error } = await query;
if (error) throw error;
return { posts, totalCount: count || 0 };
},
});
const totalPages = Math.ceil((data?.totalCount || 0) / POSTS_PER_PAGE);
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-7xl">
<div className="text-center mb-12">
<h1 className="text-5xl font-bold mb-4">ThrillWiki Blog</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Latest news, updates, and stories from the world of theme parks and roller coasters
</p>
</div>
<div className="max-w-md mx-auto mb-12">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search blog posts..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="pl-10"
/>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-[400px] rounded-lg" />
))}
</div>
) : data?.posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No blog posts found.</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.posts.map((post) => (
<BlogPostCard
key={post.id}
slug={post.slug}
title={post.title}
content={post.content}
featuredImageId={post.featured_image_id}
author={{
username: post.profiles.username,
displayName: post.profiles.display_name,
avatarUrl: post.profiles.avatar_url,
}}
publishedAt={post.published_at!}
viewCount={post.view_count}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-12">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</>
)}
</div>
<Footer />
</div>
);
}

133
src/pages/BlogPost.tsx Normal file
View File

@@ -0,0 +1,133 @@
import { useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { MarkdownRenderer } from '@/components/blog/MarkdownRenderer';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Calendar, Eye } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { Skeleton } from '@/components/ui/skeleton';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
export default function BlogPost() {
const { slug } = useParams<{ slug: string }>();
const { data: post, isLoading } = useQuery({
queryKey: ['blog-post', slug],
queryFn: async () => {
const query = supabase
.from('blog_posts')
.select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)')
.eq('slug', slug)
.eq('status', 'published')
.single();
const { data, error } = await query;
if (error) throw error;
return data;
},
enabled: !!slug,
});
useEffect(() => {
if (slug) {
supabase.rpc('increment_blog_view_count', { post_slug: slug });
}
}, [slug]);
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-4xl">
<Skeleton className="h-12 w-32 mb-8" />
<Skeleton className="h-16 w-full mb-4" />
<Skeleton className="h-8 w-2/3 mb-8" />
<Skeleton className="h-[400px] w-full mb-8" />
<Skeleton className="h-96 w-full" />
</div>
<Footer />
</div>
);
}
if (!post) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-4xl text-center">
<h1 className="text-2xl font-bold mb-4">Post Not Found</h1>
<Link to="/blog">
<Button variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Button>
</Link>
</div>
<Footer />
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<article className="container mx-auto px-4 py-12 max-w-4xl">
<Link to="/blog" className="inline-block mb-8">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Button>
</Link>
<h1 className="text-5xl font-bold mb-6 leading-tight">
{post.title}
</h1>
<div className="flex items-center justify-between mb-8 pb-6 border-b">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12">
<AvatarImage src={post.profiles.avatar_url} />
<AvatarFallback>
{post.profiles.display_name?.[0] || post.profiles.username[0]}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{post.profiles.display_name || post.profiles.username}
</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDistanceToNow(new Date(post.published_at!), { addSuffix: true })}
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{post.view_count} views
</div>
</div>
</div>
</div>
</div>
{post.featured_image_id && (
<div className="mb-12 rounded-lg overflow-hidden shadow-2xl">
<img
src={getCloudflareImageUrl(post.featured_image_id, 'public')}
alt={post.title}
className="w-full"
/>
</div>
)}
<MarkdownRenderer content={post.content} />
</article>
<Footer />
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils'; import { getBannerUrls } from '@/lib/cloudflareImageUtils';
@@ -41,19 +41,25 @@ export default function ParkDetail() {
const [photoCount, setPhotoCount] = useState<number>(0); const [photoCount, setPhotoCount] = useState<number>(0);
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
useEffect(() => { const fetchPhotoCount = useCallback(async (parkId: string) => {
if (slug) { try {
fetchParkData(); const { count, error } = await supabase
} .from('photos')
}, [slug]); .select('id', { count: 'exact', head: true })
.eq('entity_type', 'park')
.eq('entity_id', parkId);
// Track page view when park is loaded if (error) throw error;
useEffect(() => { setPhotoCount(count || 0);
if (park?.id) { } catch (error) {
trackPageView('park', park.id); console.error('Error fetching photo count:', error);
setPhotoCount(0);
} finally {
setStatsLoading(false);
} }
}, [park?.id]); }, []);
const fetchParkData = async () => {
const fetchParkData = useCallback(async () => {
try { try {
// Fetch park details // Fetch park details
const { const {
@@ -79,25 +85,20 @@ export default function ParkDetail() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [slug, fetchPhotoCount]);
const fetchPhotoCount = async (parkId: string) => { useEffect(() => {
try { if (slug) {
const { count, error } = await supabase fetchParkData();
.from('photos')
.select('id', { count: 'exact', head: true })
.eq('entity_type', 'park')
.eq('entity_id', parkId);
if (error) throw error;
setPhotoCount(count || 0);
} catch (error) {
console.error('Error fetching photo count:', error);
setPhotoCount(0);
} finally {
setStatsLoading(false);
} }
}; }, [slug, fetchParkData]);
// Track page view when park is loaded
useEffect(() => {
if (park?.id) {
trackPageView('park', park.id);
}
}, [park?.id]);
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'operating': case 'operating':

View File

@@ -0,0 +1,52 @@
-- Create blog_posts table
create table public.blog_posts (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
title text not null,
content text not null,
featured_image_id text,
featured_image_url text,
author_id uuid references auth.users(id) not null,
status text not null default 'draft' check (status in ('draft', 'published')),
published_at timestamptz,
view_count integer default 0,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Indexes for performance
create index idx_blog_posts_slug on blog_posts(slug);
create index idx_blog_posts_status on blog_posts(status);
create index idx_blog_posts_published_at on blog_posts(published_at desc nulls last);
create index idx_blog_posts_author on blog_posts(author_id);
-- Enable RLS
alter table blog_posts enable row level security;
-- RLS Policies
create policy "Public can read published posts"
on blog_posts for select
using (status = 'published');
create policy "Admins can do everything"
on blog_posts for all
using (is_moderator(auth.uid()));
-- Auto-update updated_at timestamp
create trigger update_blog_posts_updated_at
before update on blog_posts
for each row
execute function update_updated_at_column();
-- Function to increment view count
create or replace function increment_blog_view_count(post_slug text)
returns void
language plpgsql
security definer
as $$
begin
update blog_posts
set view_count = view_count + 1
where slug = post_slug;
end;
$$;

View File

@@ -0,0 +1,4 @@
-- Fix blog_posts foreign key to reference profiles
ALTER TABLE blog_posts DROP CONSTRAINT IF EXISTS blog_posts_author_id_fkey;
ALTER TABLE blog_posts ADD CONSTRAINT blog_posts_author_id_fkey
FOREIGN KEY (author_id) REFERENCES profiles(user_id) ON DELETE CASCADE;

View File

@@ -0,0 +1,10 @@
-- Drop existing policy that allows moderators
DROP POLICY IF EXISTS "Admins can do everything" ON public.blog_posts;
-- Create new policy for admins and superusers only
CREATE POLICY "Admins and superusers can manage blog posts"
ON public.blog_posts FOR ALL
USING (
has_role(auth.uid(), 'admin'::app_role) OR
has_role(auth.uid(), 'superuser'::app_role)
);

View File

@@ -0,0 +1,9 @@
-- Add CAPTCHA bypass setting to admin_settings
INSERT INTO public.admin_settings (setting_key, setting_value, category, description)
VALUES (
'auth.captcha_bypass_enabled',
'false',
'auth',
'Allow CAPTCHA bypass for authentication (development only - requires VITE_ALLOW_CAPTCHA_BYPASS=true in environment)'
)
ON CONFLICT (setting_key) DO NOTHING;

View File

@@ -0,0 +1,4 @@
-- Remove the CAPTCHA bypass setting from admin_settings
-- This setting is now controlled exclusively via VITE_ALLOW_CAPTCHA_BYPASS environment variable
DELETE FROM public.admin_settings
WHERE setting_key = 'auth.captcha_bypass_enabled';