Implement cache management

This commit is contained in:
gpt-engineer-app[bot]
2025-10-31 00:46:42 +00:00
parent e2b064fa0b
commit 875d189881
16 changed files with 553 additions and 51 deletions

View File

@@ -3,8 +3,9 @@ import { lazy, Suspense } from "react";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, QueryCache } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { CacheMonitor } from "@/components/dev/CacheMonitor";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import { AuthModalProvider } from "@/contexts/AuthModalContext";
@@ -77,8 +78,41 @@ const queryClient = new QueryClient({
gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache for 5 mins
},
},
// Add cache size management
queryCache: new QueryCache({
onSuccess: () => {
// Monitor cache size in development
if (import.meta.env.DEV) {
const cacheSize = queryClient.getQueryCache().getAll().length;
if (cacheSize > 100) {
console.warn(`⚠️ Query cache size: ${cacheSize} queries`);
}
}
},
}),
});
// Add cache size monitoring and automatic cleanup (dev mode)
if (import.meta.env.DEV) {
setInterval(() => {
const cache = queryClient.getQueryCache();
const queries = cache.getAll();
// Remove oldest queries if cache exceeds 150 items
if (queries.length > 150) {
const sortedByLastUpdated = queries
.sort((a, b) => (a.state.dataUpdatedAt || 0) - (b.state.dataUpdatedAt || 0));
const toRemove = sortedByLastUpdated.slice(0, queries.length - 100);
toRemove.forEach(query => {
queryClient.removeQueries({ queryKey: query.queryKey });
});
console.log(`🧹 Removed ${toRemove.length} stale queries from cache`);
}
}, 60000); // Check every minute
}
function AppContent(): React.JSX.Element {
return (
<TooltipProvider>
@@ -161,7 +195,12 @@ const App = (): React.JSX.Element => (
<AppContent />
</AuthModalProvider>
</AuthProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
{import.meta.env.DEV && (
<>
<ReactQueryDevtools initialIsOpen={false} position="bottom" />
<CacheMonitor />
</>
)}
<Analytics />
</QueryClientProvider>
);

View File

@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
/**
* CacheMonitor Component (Dev Only)
*
* Real-time cache performance monitoring for development.
* Displays total queries, stale queries, fetching queries, and cache size.
* Only renders in development mode.
*/
export function CacheMonitor() {
const queryClient = useQueryClient();
const [stats, setStats] = useState({
totalQueries: 0,
staleQueries: 0,
fetchingQueries: 0,
cacheSize: 0,
});
useEffect(() => {
const interval = setInterval(() => {
const cache = queryClient.getQueryCache();
const queries = cache.getAll();
setStats({
totalQueries: queries.length,
staleQueries: queries.filter(q => q.isStale()).length,
fetchingQueries: queries.filter(q => q.state.fetchStatus === 'fetching').length,
cacheSize: JSON.stringify(queries).length,
});
}, 1000);
return () => clearInterval(interval);
}, [queryClient]);
if (!import.meta.env.DEV) return null;
return (
<div className="fixed bottom-4 right-4 bg-black/80 text-white p-4 rounded-lg text-xs font-mono z-50 shadow-xl">
<h3 className="font-bold mb-2 text-primary">Cache Monitor</h3>
<div className="space-y-1">
<div>Total Queries: <span className="text-green-400">{stats.totalQueries}</span></div>
<div>Stale: <span className="text-yellow-400">{stats.staleQueries}</span></div>
<div>Fetching: <span className="text-blue-400">{stats.fetchingQueries}</span></div>
<div>Size: <span className="text-purple-400">{(stats.cacheSize / 1024).toFixed(1)} KB</span></div>
</div>
</div>
);
}

View File

@@ -1,19 +1,55 @@
import { MapPin, Star, Users, Clock, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Park } from '@/types/database';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { queryKeys } from '@/lib/queryKeys';
import { supabase } from '@/integrations/supabase/client';
interface ParkCardProps {
park: Park;
}
export function ParkCard({ park }: ParkCardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleClick = () => {
navigate(`/parks/${park.slug}`);
};
// Prefetch park detail data on hover
const handleMouseEnter = () => {
// Prefetch park detail page data
queryClient.prefetchQuery({
queryKey: queryKeys.parks.detail(park.slug),
queryFn: async () => {
const { data } = await supabase
.from('parks')
.select('*')
.eq('slug', park.slug)
.single();
return data;
},
staleTime: 5 * 60 * 1000,
});
// Prefetch park photos (first 10)
queryClient.prefetchQuery({
queryKey: queryKeys.photos.entity('park', park.id),
queryFn: async () => {
const { data } = await supabase
.from('photos')
.select('*')
.eq('entity_type', 'park')
.eq('entity_id', park.id)
.limit(10);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'operating':
@@ -56,7 +92,7 @@ export function ParkCard({ park }: ParkCardProps) {
const formatParkType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
return <Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300" onClick={handleClick}>
return <Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300" onClick={handleClick} onMouseEnter={handleMouseEnter}>
<div className="relative overflow-hidden">
{/* Image Placeholder with Gradient */}
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 flex items-center justify-center relative">

View File

@@ -1,10 +1,13 @@
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Star, MapPin, Clock, Zap, FerrisWheel, Waves, Theater, Train, ArrowUp, CheckCircle, Calendar, Hammer, XCircle } from 'lucide-react';
import { MeasurementDisplay } from '@/components/ui/measurement-display';
import { Ride } from '@/types/database';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { queryKeys } from '@/lib/queryKeys';
import { supabase } from '@/integrations/supabase/client';
interface RideCardProps {
ride: Ride;
@@ -15,12 +18,48 @@ interface RideCardProps {
export function RideCard({ ride, showParkName = true, className, parkSlug }: RideCardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleRideClick = () => {
const slug = parkSlug || ride.park?.slug;
navigate(`/parks/${slug}/rides/${ride.slug}`);
};
// Prefetch ride detail data on hover
const handleMouseEnter = () => {
const slug = parkSlug || ride.park?.slug;
if (!slug) return;
// Prefetch ride detail page data
queryClient.prefetchQuery({
queryKey: queryKeys.rides.detail(slug, ride.slug),
queryFn: async () => {
const { data } = await supabase
.from('rides')
.select('*')
.eq('slug', ride.slug)
.single();
return data;
},
staleTime: 5 * 60 * 1000,
});
// Prefetch ride photos (first 10)
queryClient.prefetchQuery({
queryKey: queryKeys.photos.entity('ride', ride.id),
queryFn: async () => {
const { data } = await supabase
.from('photos')
.select('*')
.eq('entity_type', 'ride')
.eq('entity_id', ride.id)
.limit(10);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
const getRideIcon = (category: string) => {
switch (category) {
case 'roller_coaster': return <FerrisWheel className="w-6 h-6" />;
@@ -61,6 +100,7 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid
<Card
className={`group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300 ${className}`}
onClick={handleRideClick}
onMouseEnter={handleMouseEnter}
>
<div className="relative overflow-hidden">
{/* Image/Icon Section */}

View File

@@ -3,7 +3,10 @@ import { Badge } from '@/components/ui/badge';
import { FerrisWheel } from 'lucide-react';
import { RideModel } from '@/types/database';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { queryKeys } from '@/lib/queryKeys';
import { supabase } from '@/integrations/supabase/client';
interface RideModelCardProps {
model: RideModel;
@@ -12,6 +15,23 @@ interface RideModelCardProps {
export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
// Prefetch ride model detail data on hover
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: queryKeys.rideModels.detail(manufacturerSlug, model.slug),
queryFn: async () => {
const { data } = await supabase
.from('ride_models')
.select('*')
.eq('slug', model.slug)
.single();
return data;
},
staleTime: 5 * 60 * 1000,
});
};
const formatCategory = (category: string | null | undefined) => {
if (!category) return 'Unknown';
@@ -42,6 +62,7 @@ export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
<Card
className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300"
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models/${model.slug}`)}
onMouseEnter={handleMouseEnter}
>
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 relative overflow-hidden">
{(cardImageUrl || cardImageId) ? (

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useEntityName } from '@/hooks/entities/useEntityName';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -61,6 +62,9 @@ export function PhotoManagementDialog({
const [deleteReason, setDeleteReason] = useState('');
const { toast } = useToast();
// Fetch entity name once using cached hook (replaces 4 sequential direct queries)
const { data: entityName = 'Unknown' } = useEntityName(entityType, entityId);
useEffect(() => {
if (open) {
fetchPhotos();
@@ -106,27 +110,6 @@ export function PhotoManagementDialog({
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Fetch entity name from database based on entity type
let entityName = 'Unknown';
try {
if (entityType === 'park') {
const { data } = await supabase.from('parks').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (entityType === 'ride') {
const { data } = await supabase.from('rides').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (entityType === 'ride_model') {
const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
const { data } = await supabase.from('companies').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
}
} catch (err) {
logger.error('Failed to fetch entity name', { error: getErrorMessage(err), entityType, entityId });
}
// Create content submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')

View File

@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* useBlogPost Hook
*
* Fetches a published blog post by slug with author profile information.
* Extracted from BlogPost.tsx for reusability and better caching.
*
* Features:
* - Caches blog posts for 5 minutes
* - Includes author profile data (username, display_name, avatar)
* - Only returns published posts
*
* @param slug - URL slug of the blog post
* @returns TanStack Query result with blog post data
*
* @example
* ```tsx
* const { data: post, isLoading } = useBlogPost(slug);
* ```
*/
export function useBlogPost(slug: string | undefined) {
return useQuery({
queryKey: queryKeys.blog.post(slug || ''),
queryFn: async () => {
if (!slug) return null;
const { data, error } = await supabase
.from('blog_posts')
.select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)')
.eq('slug', slug)
.eq('status', 'published')
.single();
if (error) throw error;
return data;
},
enabled: !!slug,
staleTime: 5 * 60 * 1000, // 5 minutes (blog content)
gcTime: 15 * 60 * 1000, // 15 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* useEntityName Hook
*
* Fetches the name of an entity for display purposes.
* Replaces multiple sequential direct queries with a single cached hook.
*
* Features:
* - Caches entity names for 10 minutes (rarely change)
* - Supports parks, rides, ride_models, and all company types
* - Returns 'Unknown' for invalid types or missing data
*
* @param entityType - Type of entity ('park', 'ride', 'ride_model', 'manufacturer', etc.)
* @param entityId - UUID of the entity
* @returns TanStack Query result with entity name string
*
* @example
* ```tsx
* const { data: entityName = 'Unknown' } = useEntityName('park', parkId);
* ```
*/
export function useEntityName(entityType: string, entityId: string) {
return useQuery({
queryKey: queryKeys.entities.name(entityType, entityId),
queryFn: async () => {
// Type-safe approach: separate queries for each table type
try {
if (entityType === 'park') {
const { data, error } = await supabase
.from('parks')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
if (entityType === 'ride') {
const { data, error } = await supabase
.from('rides')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
if (entityType === 'ride_model') {
const { data, error } = await supabase
.from('ride_models')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
const { data, error } = await supabase
.from('companies')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
return 'Unknown';
} catch (error) {
console.error('Failed to fetch entity name:', error);
return 'Unknown';
}
},
enabled: !!entityType && !!entityId,
staleTime: 10 * 60 * 1000, // 10 minutes (entity names rarely change)
gcTime: 30 * 60 * 1000, // 30 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
/**
* usePrefetchNextPage Hook
*
* Prefetches the next page of paginated data for instant navigation.
*
* Features:
* - Automatically prefetches when user is on a page with more data
* - Short staleTime (2 minutes) prevents over-caching
* - Generic implementation works with any paginated data
*
* @param baseQueryKey - Base query key array for the paginated query
* @param currentPage - Current page number
* @param hasNextPage - Boolean indicating if there is a next page
* @param queryFn - Function to fetch the next page
*
* @example
* ```tsx
* usePrefetchNextPage(
* queryKeys.parks.all(),
* currentPage,
* hasNextPage,
* (page) => fetchParks(page)
* );
* ```
*/
export function usePrefetchNextPage<T>(
baseQueryKey: readonly unknown[],
currentPage: number,
hasNextPage: boolean,
queryFn: (page: number) => Promise<T>
) {
const queryClient = useQueryClient();
useEffect(() => {
if (!hasNextPage) return;
// Prefetch next page
queryClient.prefetchQuery({
queryKey: [...baseQueryKey, currentPage + 1],
queryFn: () => queryFn(currentPage + 1),
staleTime: 2 * 60 * 1000, // 2 minutes
});
}, [currentPage, hasNextPage, baseQueryKey, queryFn, queryClient]);
}

View File

@@ -26,7 +26,8 @@
* ```
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { useQuery, UseQueryResult, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
@@ -42,9 +43,12 @@ interface EntityPhoto {
export function useEntityPhotos(
entityType: string,
entityId: string,
sortBy: 'newest' | 'oldest' = 'newest'
sortBy: 'newest' | 'oldest' = 'newest',
enableRealtime = false // New parameter for opt-in real-time updates
): UseQueryResult<EntityPhoto[]> {
return useQuery({
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.photos.entity(entityType, entityId, sortBy),
queryFn: async () => {
const startTime = performance.now();
@@ -82,4 +86,34 @@ export function useEntityPhotos(
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
// Real-time subscription for photo uploads (opt-in)
useEffect(() => {
if (!enableRealtime || !entityType || !entityId) return;
const channel = supabase
.channel(`photos-${entityType}-${entityId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'photos',
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`,
},
(payload) => {
console.log('📸 New photo uploaded:', payload.new);
queryClient.invalidateQueries({
queryKey: queryKeys.photos.entity(entityType, entityId)
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [enableRealtime, entityType, entityId, queryClient, sortBy]);
return query;
}

View File

@@ -1,12 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch reviews for a specific entity (park or ride)
*/
export function useEntityReviews(entityType: 'park' | 'ride', entityId: string | undefined, enabled = true) {
return useQuery({
export function useEntityReviews(
entityType: 'park' | 'ride',
entityId: string | undefined,
enabled = true,
enableRealtime = false // New parameter for opt-in real-time updates
) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.reviews.entity(entityType, entityId || ''),
queryFn: async () => {
if (!entityId) return [];
@@ -35,4 +43,34 @@ export function useEntityReviews(entityType: 'park' | 'ride', entityId: string |
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
// Real-time subscription for new reviews (opt-in)
useEffect(() => {
if (!enableRealtime || !entityId || !enabled) return;
const channel = supabase
.channel(`reviews-${entityType}-${entityId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'reviews',
filter: `${entityType}_id=eq.${entityId},moderation_status=eq.approved`,
},
(payload) => {
console.log('⭐ New review posted:', payload.new);
queryClient.invalidateQueries({
queryKey: queryKeys.reviews.entity(entityType, entityId)
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [enableRealtime, entityType, entityId, enabled, queryClient]);
return query;
}

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export interface CoasterStat {
id: string;
@@ -15,7 +16,7 @@ export interface CoasterStat {
export function useCoasterStats(rideId: string | undefined) {
return useQuery({
queryKey: ['coaster-stats', rideId],
queryKey: queryKeys.stats.coaster(rideId || ''),
queryFn: async () => {
if (!rideId) return [];

View File

@@ -1,12 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch public Novu settings accessible to all authenticated users
*/
export function usePublicNovuSettings() {
const { data: settings, isLoading, error } = useQuery({
queryKey: ['public-novu-settings'],
queryKey: queryKeys.settings.publicNovu(),
queryFn: async () => {
const { data, error } = await supabase
.from('admin_settings')

View File

@@ -247,8 +247,82 @@ export function useQueryInvalidation() {
* Invalidate model rides cache
* Call this after ride changes
*/
invalidateModelRides: (modelId: string) => {
queryClient.invalidateQueries({ queryKey: ['ride-models', 'rides', modelId] });
invalidateModelRides: (modelId: string, limit?: number) => {
queryClient.invalidateQueries({
queryKey: queryKeys.rideModels.rides(modelId, limit),
});
},
/**
* Invalidate entity name cache
* Call this after updating an entity's name
*/
invalidateEntityName: (entityType: string, entityId: string) => {
queryClient.invalidateQueries({
queryKey: queryKeys.entities.name(entityType, entityId)
});
},
/**
* Invalidate blog post cache
* Call this after updating a blog post
*/
invalidateBlogPost: (slug: string) => {
queryClient.invalidateQueries({
queryKey: queryKeys.blog.post(slug)
});
},
/**
* Invalidate coaster stats cache
* Call this after updating ride statistics
*/
invalidateCoasterStats: (rideId: string) => {
queryClient.invalidateQueries({
queryKey: queryKeys.stats.coaster(rideId)
});
},
/**
* Invalidate security queries
* Call this after security-related changes (email, sessions)
*/
invalidateSecurityQueries: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.security.emailChangeStatus()
});
queryClient.invalidateQueries({
queryKey: queryKeys.security.sessions()
});
},
/**
* Smart invalidation for related entities
* Invalidates entity detail, photos, reviews, and name cache
* Call this after any entity update
*/
invalidateRelatedEntities: (entityType: string, entityId: string) => {
// Invalidate the entity itself
if (entityType === 'park') {
queryClient.invalidateQueries({
queryKey: queryKeys.parks.detail(entityId)
});
} else if (entityType === 'ride') {
queryClient.invalidateQueries({
queryKey: queryKeys.rides.detail('', entityId)
});
}
// Invalidate photos, reviews, and entity name
queryClient.invalidateQueries({
queryKey: queryKeys.photos.entity(entityType, entityId)
});
queryClient.invalidateQueries({
queryKey: queryKeys.reviews.entity(entityType as 'park' | 'ride', entityId)
});
queryClient.invalidateQueries({
queryKey: queryKeys.entities.name(entityType, entityId)
});
},
};
}

View File

@@ -121,4 +121,31 @@ export const queryKeys = {
['ride-models', 'rides', modelId, limit] as const,
statistics: (modelId: string) => ['ride-models', 'statistics', modelId] as const,
},
// Settings queries
settings: {
publicNovu: () => ['public-novu-settings'] as const,
},
// Stats queries
stats: {
coaster: (rideId: string) => ['coaster-stats', rideId] as const,
},
// Blog queries
blog: {
post: (slug: string) => ['blog-post', slug] as const,
viewIncrement: (slug: string) => ['blog-view-increment', slug] as const,
},
// Entity name queries (for PhotoManagementDialog)
entities: {
name: (entityType: string, entityId: string) => ['entity-name', entityType, entityId] as const,
},
// Security queries
security: {
emailChangeStatus: () => ['email-change-status'] as const,
sessions: () => ['my-sessions'] as const,
},
} as const;

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useBlogPost } from '@/hooks/blog/useBlogPost';
import { MarkdownRenderer } from '@/components/blog/MarkdownRenderer';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -17,22 +17,7 @@ import { useOpenGraph } from '@/hooks/useOpenGraph';
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,
});
const { data: post, isLoading } = useBlogPost(slug);
// Update document title when post changes
useDocumentTitle(post?.title || 'Blog Post');