mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
Reverted to commit 06ed528d76
This commit is contained in:
5
.env
5
.env
@@ -7,7 +7,7 @@ VITE_SUPABASE_URL="https://ydvtmnrszybqnbcqbdcy.supabase.co"
|
||||
# For development, you can use test keys:
|
||||
# - Always passes: 1x00000000000000000000AA
|
||||
# - Always fails: 3x00000000000000000000FF
|
||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAAyqVp3RjccrC9Kz
|
||||
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
|
||||
# Cloudflare Images
|
||||
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_SOCKET_URL="wss://ws.novu.co"
|
||||
VITE_NOVU_API_URL="https://api.novu.co"
|
||||
|
||||
# CAPTCHA Bypass (Development/Preview Only)
|
||||
VITE_ALLOW_CAPTCHA_BYPASS=true
|
||||
|
||||
@@ -14,6 +14,12 @@ VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key
|
||||
# Cloudflare Images Configuration
|
||||
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
|
||||
# For Novu Cloud, use these defaults:
|
||||
# - Socket URL: wss://ws.novu.co
|
||||
|
||||
1283
package-lock.json
generated
1283
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
|
||||
@@ -40,6 +40,9 @@ import AdminReports from "./pages/AdminReports";
|
||||
import AdminSystemLog from "./pages/AdminSystemLog";
|
||||
import AdminUsers from "./pages/AdminUsers";
|
||||
import AdminSettings from "./pages/AdminSettings";
|
||||
import BlogIndex from "./pages/BlogIndex";
|
||||
import BlogPost from "./pages/BlogPost";
|
||||
import AdminBlog from "./pages/AdminBlog";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -64,6 +67,8 @@ function AppContent() {
|
||||
<Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} />
|
||||
<Route path="/rides" element={<Rides />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/blog" element={<BlogIndex />} />
|
||||
<Route path="/blog/:slug" element={<BlogPost />} />
|
||||
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||
<Route path="/manufacturers/:slug" element={<ManufacturerDetail />} />
|
||||
<Route path="/manufacturers/:manufacturerSlug/rides" element={<ManufacturerRides />} />
|
||||
@@ -86,6 +91,7 @@ function AppContent() {
|
||||
<Route path="/admin/reports" element={<AdminReports />} />
|
||||
<Route path="/admin/system-log" element={<AdminSystemLog />} />
|
||||
<Route path="/admin/users" element={<AdminUsers />} />
|
||||
<Route path="/admin/blog" element={<AdminBlog />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
|
||||
@@ -12,6 +12,7 @@ export function AuthButtons() {
|
||||
const {
|
||||
user,
|
||||
profile,
|
||||
loading,
|
||||
signOut
|
||||
} = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -40,6 +41,17 @@ 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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
88
src/components/blog/BlogPostCard.tsx
Normal file
88
src/components/blog/BlogPostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/blog/MarkdownRenderer.tsx
Normal file
31
src/components/blog/MarkdownRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -27,6 +27,12 @@ export function Footer() {
|
||||
>
|
||||
Submission Guidelines
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -78,6 +78,7 @@ export function useAdminSettings() {
|
||||
return settings?.filter(s => s.category === category) || [];
|
||||
};
|
||||
|
||||
|
||||
const updateSetting = async (key: string, value: any) => {
|
||||
return updateSettingMutation.mutateAsync({ key, value });
|
||||
};
|
||||
|
||||
@@ -93,13 +93,23 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log('[Auth] State change:', event, 'User:', session?.user?.email || 'none');
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const currentEmail = session?.user?.email;
|
||||
const newEmailPending = session?.user?.new_email;
|
||||
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
// 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);
|
||||
setUser(session?.user ?? null);
|
||||
}
|
||||
|
||||
// Track pending email changes
|
||||
setPendingEmail(newEmailPending ?? null);
|
||||
|
||||
23
src/hooks/useCaptchaBypass.ts
Normal file
23
src/hooks/useCaptchaBypass.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -294,7 +294,7 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
||||
channelRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [entityType, entityId, fetchVersions]);
|
||||
}, [entityType, entityId]);
|
||||
|
||||
// Set mounted ref on mount and cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// This file is automatically generated. Do not edit it directly.
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './types';
|
||||
import { authStorage } from '@/lib/authStorage';
|
||||
|
||||
const SUPABASE_URL = "https://ydvtmnrszybqnbcqbdcy.supabase.co";
|
||||
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, {
|
||||
auth: {
|
||||
storage: localStorage,
|
||||
storage: authStorage,
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
}
|
||||
|
||||
@@ -74,6 +74,66 @@ export type Database = {
|
||||
}
|
||||
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: {
|
||||
Row: {
|
||||
average_rating: number | null
|
||||
@@ -2899,6 +2959,10 @@ export type Database = {
|
||||
Args: { ip_text: string }
|
||||
Returns: string
|
||||
}
|
||||
increment_blog_view_count: {
|
||||
Args: { post_slug: string }
|
||||
Returns: undefined
|
||||
}
|
||||
is_moderator: {
|
||||
Args: { _user_id: string }
|
||||
Returns: boolean
|
||||
|
||||
59
src/lib/authStorage.ts
Normal file
59
src/lib/authStorage.ts
Normal 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();
|
||||
@@ -98,7 +98,7 @@ export const rideValidationSchema = 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'),
|
||||
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(),
|
||||
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(),
|
||||
|
||||
376
src/pages/AdminBlog.tsx
Normal file
376
src/pages/AdminBlog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
|
||||
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() {
|
||||
const { user } = useAuth();
|
||||
@@ -435,7 +435,7 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Moderation</span>
|
||||
|
||||
130
src/pages/BlogIndex.tsx
Normal file
130
src/pages/BlogIndex.tsx
Normal 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
133
src/pages/BlogPost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||
@@ -41,19 +41,25 @@ export default function ParkDetail() {
|
||||
const [photoCount, setPhotoCount] = useState<number>(0);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const { isModerator } = useUserRole();
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
fetchParkData();
|
||||
}
|
||||
}, [slug]);
|
||||
const fetchPhotoCount = useCallback(async (parkId: string) => {
|
||||
try {
|
||||
const { count, error } = await supabase
|
||||
.from('photos')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('entity_type', 'park')
|
||||
.eq('entity_id', parkId);
|
||||
|
||||
// Track page view when park is loaded
|
||||
useEffect(() => {
|
||||
if (park?.id) {
|
||||
trackPageView('park', park.id);
|
||||
if (error) throw error;
|
||||
setPhotoCount(count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error fetching photo count:', error);
|
||||
setPhotoCount(0);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, [park?.id]);
|
||||
const fetchParkData = async () => {
|
||||
}, []);
|
||||
|
||||
const fetchParkData = useCallback(async () => {
|
||||
try {
|
||||
// Fetch park details
|
||||
const {
|
||||
@@ -79,25 +85,20 @@ export default function ParkDetail() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [slug, fetchPhotoCount]);
|
||||
|
||||
const fetchPhotoCount = async (parkId: string) => {
|
||||
try {
|
||||
const { count, error } = await supabase
|
||||
.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);
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
fetchParkData();
|
||||
}
|
||||
};
|
||||
}, [slug, fetchParkData]);
|
||||
|
||||
// Track page view when park is loaded
|
||||
useEffect(() => {
|
||||
if (park?.id) {
|
||||
trackPageView('park', park.id);
|
||||
}
|
||||
}, [park?.id]);
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'operating':
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user