mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 11:51:14 -05:00
Fix Phase 4 API optimization
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react';
|
import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -14,11 +14,9 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
||||||
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
|
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
|
||||||
import { PhotoModal } from '@/components/moderation/PhotoModal';
|
import { PhotoModal } from '@/components/moderation/PhotoModal';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos';
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,47 +36,18 @@ export function EntityPhotoGallery({
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isModerator } = useUserRole();
|
const { isModerator } = useUserRole();
|
||||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [showManagement, setShowManagement] = useState(false);
|
const [showManagement, setShowManagement] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest');
|
const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest');
|
||||||
|
|
||||||
useEffect(() => {
|
// Use optimized photos hook with caching
|
||||||
fetchPhotos();
|
const { data: photos = [], isLoading: loading, refetch } = useEntityPhotos(
|
||||||
}, [entityId, entityType, sortBy]);
|
entityType,
|
||||||
|
entityId,
|
||||||
const fetchPhotos = async () => {
|
sortBy
|
||||||
try {
|
);
|
||||||
// Fetch photos directly from the photos table
|
|
||||||
const { data: photoData, error } = await supabase
|
|
||||||
.from('photos')
|
|
||||||
.select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index')
|
|
||||||
.eq('entity_type', entityType)
|
|
||||||
.eq('entity_id', entityId)
|
|
||||||
.order('created_at', { ascending: sortBy === 'oldest' });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// Map to Photo interface
|
|
||||||
const mappedPhotos: Photo[] = photoData?.map((photo) => ({
|
|
||||||
id: photo.id,
|
|
||||||
url: photo.cloudflare_image_url,
|
|
||||||
caption: photo.caption || undefined,
|
|
||||||
title: photo.title || undefined,
|
|
||||||
user_id: photo.submitted_by,
|
|
||||||
created_at: photo.created_at,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
setPhotos(mappedPhotos);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error('Failed to fetch photos', { error: getErrorMessage(error), entityId, entityType });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
const handleUploadClick = () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -90,7 +59,7 @@ export function EntityPhotoGallery({
|
|||||||
|
|
||||||
const handleSubmissionComplete = () => {
|
const handleSubmissionComplete = () => {
|
||||||
setShowUpload(false);
|
setShowUpload(false);
|
||||||
fetchPhotos(); // Refresh photos after submission
|
refetch(); // Refresh photos after submission
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePhotoClick = (index: number) => {
|
const handlePhotoClick = (index: number) => {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { PersonalLocationDisplay } from '@/components/profile/PersonalLocationDi
|
|||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
import { useOpenGraph } from '@/hooks/useOpenGraph';
|
import { useOpenGraph } from '@/hooks/useOpenGraph';
|
||||||
|
import { useProfileActivity } from '@/hooks/profile/useProfileActivity';
|
||||||
|
import { useProfileStats } from '@/hooks/profile/useProfileStats';
|
||||||
|
|
||||||
// Activity type definitions
|
// Activity type definitions
|
||||||
interface SubmissionActivity {
|
interface SubmissionActivity {
|
||||||
@@ -147,13 +149,6 @@ export default function Profile() {
|
|||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
||||||
const [avatarImageId, setAvatarImageId] = useState<string>('');
|
const [avatarImageId, setAvatarImageId] = useState<string>('');
|
||||||
const [calculatedStats, setCalculatedStats] = useState({
|
|
||||||
rideCount: 0,
|
|
||||||
coasterCount: 0,
|
|
||||||
parkCount: 0
|
|
||||||
});
|
|
||||||
const [recentActivity, setRecentActivity] = useState<ActivityEntry[]>([]);
|
|
||||||
const [activityLoading, setActivityLoading] = useState(false);
|
|
||||||
|
|
||||||
// User role checking
|
// User role checking
|
||||||
const { isModerator, loading: rolesLoading } = useUserRole();
|
const { isModerator, loading: rolesLoading } = useUserRole();
|
||||||
@@ -172,6 +167,18 @@ export default function Profile() {
|
|||||||
|
|
||||||
// Username validation
|
// Username validation
|
||||||
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
|
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
|
||||||
|
|
||||||
|
// Optimized activity and stats hooks
|
||||||
|
const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id;
|
||||||
|
const { data: calculatedStats = { rideCount: 0, coasterCount: 0, parkCount: 0 } } = useProfileStats(profile?.user_id);
|
||||||
|
const { data: recentActivity = [], isLoading: activityLoading } = useProfileActivity(
|
||||||
|
profile?.user_id,
|
||||||
|
isOwnProfile || false,
|
||||||
|
isModerator()
|
||||||
|
);
|
||||||
|
// Cast activity to local types for type safety
|
||||||
|
const typedActivity = recentActivity as ActivityEntry[];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCurrentUser();
|
getCurrentUser();
|
||||||
if (username) {
|
if (username) {
|
||||||
@@ -181,214 +188,6 @@ export default function Profile() {
|
|||||||
}
|
}
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
const fetchCalculatedStats = async (userId: string) => {
|
|
||||||
try {
|
|
||||||
// Fetch ride credits stats
|
|
||||||
const { data: ridesData, error: ridesError } = await supabase
|
|
||||||
.from('user_ride_credits')
|
|
||||||
.select(`
|
|
||||||
ride_count,
|
|
||||||
rides!inner(category, park_id)
|
|
||||||
`)
|
|
||||||
.eq('user_id', userId);
|
|
||||||
|
|
||||||
if (ridesError) throw ridesError;
|
|
||||||
|
|
||||||
// Calculate total rides count (sum of all ride_count values)
|
|
||||||
const totalRides = ridesData?.reduce((sum, credit) => sum + (credit.ride_count || 0), 0) || 0;
|
|
||||||
|
|
||||||
// Calculate coasters count (distinct rides where category is roller_coaster)
|
|
||||||
const coasterRides = ridesData?.filter(credit =>
|
|
||||||
credit.rides?.category === 'roller_coaster'
|
|
||||||
) || [];
|
|
||||||
const uniqueCoasters = new Set(coasterRides.map(credit => credit.rides));
|
|
||||||
const coasterCount = uniqueCoasters.size;
|
|
||||||
|
|
||||||
// Calculate parks count (distinct parks where user has ridden at least one ride)
|
|
||||||
const parkRides = ridesData?.map(credit => credit.rides?.park_id).filter(Boolean) || [];
|
|
||||||
const uniqueParks = new Set(parkRides);
|
|
||||||
const parkCount = uniqueParks.size;
|
|
||||||
|
|
||||||
setCalculatedStats({
|
|
||||||
rideCount: totalRides,
|
|
||||||
coasterCount: coasterCount,
|
|
||||||
parkCount: parkCount
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching calculated stats:', error);
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
description: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
// Set defaults on error
|
|
||||||
setCalculatedStats({
|
|
||||||
rideCount: 0,
|
|
||||||
coasterCount: 0,
|
|
||||||
parkCount: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchRecentActivity = async (userId: string) => {
|
|
||||||
setActivityLoading(true);
|
|
||||||
try {
|
|
||||||
const isOwnProfile = currentUser && currentUser.id === userId;
|
|
||||||
|
|
||||||
// Wait for role loading to complete
|
|
||||||
if (rolesLoading) {
|
|
||||||
setActivityLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check user privacy settings for activity visibility
|
|
||||||
const { data: preferences } = await supabase
|
|
||||||
.from('user_preferences')
|
|
||||||
.select('privacy_settings')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const privacySettings = preferences?.privacy_settings as { activity_visibility?: string } | null;
|
|
||||||
const activityVisibility = privacySettings?.activity_visibility || 'public';
|
|
||||||
|
|
||||||
// If activity is not public and viewer is not owner or moderator, show empty
|
|
||||||
if (activityVisibility !== 'public' && !isOwnProfile && !isModerator()) {
|
|
||||||
setRecentActivity([]);
|
|
||||||
setActivityLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch last 10 reviews
|
|
||||||
let reviewsQuery = supabase
|
|
||||||
.from('reviews')
|
|
||||||
.select('id, rating, title, created_at, moderation_status, park_id, ride_id, parks(name, slug), rides(name, slug, parks(name, slug))')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
// Regular users viewing others: show only approved reviews
|
|
||||||
if (!isOwnProfile && !isModerator()) {
|
|
||||||
reviewsQuery = reviewsQuery.eq('moderation_status', 'approved');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: reviews, error: reviewsError } = await reviewsQuery;
|
|
||||||
if (reviewsError) throw reviewsError;
|
|
||||||
|
|
||||||
// Fetch last 10 ride credits
|
|
||||||
const { data: credits, error: creditsError } = await supabase
|
|
||||||
.from('user_ride_credits')
|
|
||||||
.select('id, ride_count, first_ride_date, created_at, rides(name, slug, parks(name, slug))')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
if (creditsError) throw creditsError;
|
|
||||||
|
|
||||||
// Fetch last 10 submissions with enriched data
|
|
||||||
let submissionsQuery = supabase
|
|
||||||
.from('content_submissions')
|
|
||||||
.select('id, submission_type, content, status, created_at')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
// Regular users viewing others: show only approved submissions
|
|
||||||
// Moderators/Admins/Superusers see all submissions (they bypass the submission process)
|
|
||||||
if (!isOwnProfile && !isModerator()) {
|
|
||||||
submissionsQuery = submissionsQuery.eq('status', 'approved');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: submissions, error: submissionsError } = await submissionsQuery;
|
|
||||||
if (submissionsError) throw submissionsError;
|
|
||||||
|
|
||||||
// Enrich submissions with entity data and photos
|
|
||||||
const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => {
|
|
||||||
const enriched: any = { ...sub };
|
|
||||||
|
|
||||||
// For photo submissions, get photo count and preview
|
|
||||||
if (sub.submission_type === 'photo') {
|
|
||||||
const { data: photoSubs } = await supabase
|
|
||||||
.from('photo_submissions')
|
|
||||||
.select('id, entity_type, entity_id')
|
|
||||||
.eq('submission_id', sub.id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (photoSubs) {
|
|
||||||
const { data: photoItems, count } = await supabase
|
|
||||||
.from('photo_submission_items')
|
|
||||||
.select('cloudflare_image_url', { count: 'exact' })
|
|
||||||
.eq('photo_submission_id', photoSubs.id)
|
|
||||||
.order('order_index', { ascending: true })
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
enriched.photo_count = count || 0;
|
|
||||||
enriched.photo_preview = photoItems?.[0]?.cloudflare_image_url;
|
|
||||||
enriched.entity_type = photoSubs.entity_type;
|
|
||||||
enriched.entity_id = photoSubs.entity_id;
|
|
||||||
|
|
||||||
// Get entity name/slug for linking
|
|
||||||
if (photoSubs.entity_type === 'park') {
|
|
||||||
const { data: park } = await supabase
|
|
||||||
.from('parks')
|
|
||||||
.select('name, slug')
|
|
||||||
.eq('id', photoSubs.entity_id)
|
|
||||||
.single();
|
|
||||||
enriched.content = { ...enriched.content, entity_name: park?.name, entity_slug: park?.slug };
|
|
||||||
} else if (photoSubs.entity_type === 'ride') {
|
|
||||||
const { data: ride } = await supabase
|
|
||||||
.from('rides')
|
|
||||||
.select('name, slug, parks!inner(name, slug)')
|
|
||||||
.eq('id', photoSubs.entity_id)
|
|
||||||
.single();
|
|
||||||
enriched.content = {
|
|
||||||
...enriched.content,
|
|
||||||
entity_name: ride?.name,
|
|
||||||
entity_slug: ride?.slug,
|
|
||||||
park_name: ride?.parks?.name,
|
|
||||||
park_slug: ride?.parks?.slug
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return enriched;
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Fetch last 10 rankings (public top lists)
|
|
||||||
let rankingsQuery = supabase
|
|
||||||
.from('user_top_lists')
|
|
||||||
.select('id, title, description, list_type, created_at')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
if (!isOwnProfile) {
|
|
||||||
rankingsQuery = rankingsQuery.eq('is_public', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: rankings, error: rankingsError } = await rankingsQuery;
|
|
||||||
if (rankingsError) throw rankingsError;
|
|
||||||
|
|
||||||
// Combine and sort by date
|
|
||||||
const combined = [
|
|
||||||
...(reviews?.map(r => ({ ...r, type: 'review' as const })) || []),
|
|
||||||
...(credits?.map(c => ({ ...c, type: 'credit' as const })) || []),
|
|
||||||
...(enrichedSubmissions?.map(s => ({ ...s, type: 'submission' as const })) || []),
|
|
||||||
...(rankings?.map(r => ({ ...r, type: 'ranking' as const })) || [])
|
|
||||||
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
||||||
.slice(0, 15) as ActivityEntry[];
|
|
||||||
|
|
||||||
setRecentActivity(combined);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching recent activity:', error);
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
description: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
setRecentActivity([]);
|
|
||||||
} finally {
|
|
||||||
setActivityLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getCurrentUser = async () => {
|
const getCurrentUser = async () => {
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
@@ -434,10 +233,6 @@ export default function Profile() {
|
|||||||
});
|
});
|
||||||
setAvatarUrl(data.avatar_url || '');
|
setAvatarUrl(data.avatar_url || '');
|
||||||
setAvatarImageId(data.avatar_image_id || '');
|
setAvatarImageId(data.avatar_image_id || '');
|
||||||
|
|
||||||
// Fetch calculated stats and recent activity for this user
|
|
||||||
await fetchCalculatedStats(data.user_id);
|
|
||||||
await fetchRecentActivity(data.user_id);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching profile:', error);
|
console.error('Error fetching profile:', error);
|
||||||
@@ -475,10 +270,6 @@ export default function Profile() {
|
|||||||
});
|
});
|
||||||
setAvatarUrl(data.avatar_url || '');
|
setAvatarUrl(data.avatar_url || '');
|
||||||
setAvatarImageId(data.avatar_image_id || '');
|
setAvatarImageId(data.avatar_image_id || '');
|
||||||
|
|
||||||
// Fetch calculated stats and recent activity for the current user
|
|
||||||
await fetchCalculatedStats(user.id);
|
|
||||||
await fetchRecentActivity(user.id);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching profile:', error);
|
console.error('Error fetching profile:', error);
|
||||||
@@ -609,7 +400,7 @@ export default function Profile() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id;
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="min-h-screen bg-background">
|
return <div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -832,7 +623,7 @@ export default function Profile() {
|
|||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : recentActivity.length === 0 ? (
|
) : typedActivity.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Trophy className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
<Trophy className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-semibold mb-2">No recent activity yet</h3>
|
<h3 className="text-xl font-semibold mb-2">No recent activity yet</h3>
|
||||||
@@ -842,7 +633,7 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{recentActivity.map(activity => (
|
{typedActivity.map(activity => (
|
||||||
<div key={`${activity.type}-${activity.id}`} className="flex gap-4 p-4 rounded-lg border bg-card hover:bg-accent/5 transition-colors">
|
<div key={`${activity.type}-${activity.id}`} className="flex gap-4 p-4 rounded-lg border bg-card hover:bg-accent/5 transition-colors">
|
||||||
<div className="flex-shrink-0 mt-1">
|
<div className="flex-shrink-0 mt-1">
|
||||||
{activity.type === 'review' ? (
|
{activity.type === 'review' ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user