mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:31:11 -05:00
1125 lines
47 KiB
TypeScript
1125 lines
47 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { User as SupabaseUser } from '@supabase/supabase-js';
|
|
import { Header } from '@/components/layout/Header';
|
|
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, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { useProfile } from '@/hooks/useProfile';
|
|
import { UserReviewsList } from '@/components/profile/UserReviewsList';
|
|
import { UserListManager } from '@/components/lists/UserListManager';
|
|
import { RideCreditsManager } from '@/components/profile/RideCreditsManager';
|
|
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
|
import { User, MapPin, Calendar, Star, Trophy, Settings, Camera, Edit3, Save, X, ArrowLeft, Check, AlertCircle, Loader2, UserX, FileText, Image } from 'lucide-react';
|
|
import { Profile as ProfileType } from '@/types/database';
|
|
import { supabase } from '@/lib/supabaseClient';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
|
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
|
import { profileEditSchema } from '@/lib/validation';
|
|
import { LocationDisplay } from '@/components/profile/LocationDisplay';
|
|
import { UserBlockButton } from '@/components/profile/UserBlockButton';
|
|
import { PersonalLocationDisplay } from '@/components/profile/PersonalLocationDisplay';
|
|
import { useUserRole } from '@/hooks/useUserRole';
|
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
|
import { useOpenGraph } from '@/hooks/useOpenGraph';
|
|
|
|
// Activity type definitions
|
|
interface SubmissionActivity {
|
|
id: string;
|
|
type: 'submission';
|
|
submission_type: 'park' | 'ride' | 'photo' | 'company' | 'ride_model';
|
|
status: string;
|
|
created_at: string;
|
|
content?: {
|
|
action?: 'edit' | 'create';
|
|
name?: string;
|
|
slug?: string;
|
|
entity_slug?: string;
|
|
entity_name?: string;
|
|
park_slug?: string;
|
|
park_name?: string;
|
|
company_type?: string;
|
|
manufacturer_slug?: string;
|
|
description?: string;
|
|
};
|
|
photo_preview?: string;
|
|
photo_count?: number;
|
|
entity_type?: 'park' | 'ride' | 'company';
|
|
entity_id?: string;
|
|
}
|
|
|
|
interface RankingActivity {
|
|
id: string;
|
|
type: 'ranking';
|
|
title: string;
|
|
description?: string;
|
|
list_type: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ReviewActivity {
|
|
id: string;
|
|
type: 'review';
|
|
rating: number;
|
|
title?: string;
|
|
content?: string;
|
|
created_at: string;
|
|
moderation_status?: string;
|
|
park_id?: string;
|
|
ride_id?: string;
|
|
parks?: {
|
|
name: string;
|
|
slug: string;
|
|
} | null;
|
|
rides?: {
|
|
name: string;
|
|
slug: string;
|
|
parks?: {
|
|
name: string;
|
|
slug: string;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
interface CreditActivity {
|
|
id: string;
|
|
type: 'credit';
|
|
ride_count: number;
|
|
first_ride_date?: string;
|
|
created_at: string;
|
|
rides?: {
|
|
name: string;
|
|
slug: string;
|
|
parks?: {
|
|
name: string;
|
|
slug: string;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
type ActivityEntry = SubmissionActivity | RankingActivity | ReviewActivity | CreditActivity;
|
|
|
|
// Type guards
|
|
const isSubmissionActivity = (act: ActivityEntry): act is SubmissionActivity =>
|
|
act.type === 'submission';
|
|
|
|
const isRankingActivity = (act: ActivityEntry): act is RankingActivity =>
|
|
act.type === 'ranking';
|
|
|
|
const isReviewActivity = (act: ActivityEntry): act is ReviewActivity =>
|
|
act.type === 'review';
|
|
|
|
const isCreditActivity = (act: ActivityEntry): act is CreditActivity =>
|
|
act.type === 'credit';
|
|
|
|
export default function Profile() {
|
|
const {
|
|
username
|
|
} = useParams<{
|
|
username?: string;
|
|
}>();
|
|
const navigate = useNavigate();
|
|
const {
|
|
toast
|
|
} = useToast();
|
|
const { user: authUser } = useAuth();
|
|
const { refreshProfile } = useProfile(authUser?.id);
|
|
const [profile, setProfile] = useState<ProfileType | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [editing, setEditing] = useState(false);
|
|
const [currentUser, setCurrentUser] = useState<SupabaseUser | null>(null);
|
|
const [editForm, setEditForm] = useState({
|
|
username: '',
|
|
display_name: '',
|
|
bio: ''
|
|
});
|
|
const [showUsernameDialog, setShowUsernameDialog] = useState(false);
|
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
|
const [avatarUrl, setAvatarUrl] = 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
|
|
const { isModerator, loading: rolesLoading } = useUserRole();
|
|
|
|
// Update document title when profile changes
|
|
useDocumentTitle(profile?.username ? `${profile.username}'s Profile` : 'Profile');
|
|
|
|
useOpenGraph({
|
|
title: profile ? `${profile.display_name || profile.username} - ThrillWiki` : 'User Profile',
|
|
description: profile?.bio || (profile ? `${profile.display_name || profile.username}'s profile on ThrillWiki` : undefined),
|
|
imageUrl: profile?.avatar_url,
|
|
imageId: profile?.avatar_image_id,
|
|
type: 'profile',
|
|
enabled: !!profile
|
|
});
|
|
|
|
// Username validation
|
|
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
|
|
useEffect(() => {
|
|
getCurrentUser();
|
|
if (username) {
|
|
fetchProfile(username);
|
|
} else {
|
|
fetchCurrentUserProfile();
|
|
}
|
|
}, [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) {
|
|
handleNonCriticalError(error, { action: 'Fetch Calculated Stats', metadata: { userId } });
|
|
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,
|
|
status,
|
|
created_at,
|
|
submission_metadata(name)
|
|
`)
|
|
.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 };
|
|
|
|
// Get name from submission_metadata
|
|
const metadata = sub.submission_metadata as any;
|
|
enriched.name = Array.isArray(metadata) && metadata.length > 0
|
|
? metadata[0]?.name
|
|
: undefined;
|
|
|
|
// 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) {
|
|
handleNonCriticalError(error, { action: 'Fetch Recent Activity', metadata: { userId } });
|
|
toast({
|
|
variant: 'destructive',
|
|
description: getErrorMessage(error),
|
|
});
|
|
setRecentActivity([]);
|
|
} finally {
|
|
setActivityLoading(false);
|
|
}
|
|
};
|
|
const getCurrentUser = async () => {
|
|
const {
|
|
data: {
|
|
user
|
|
}
|
|
} = await supabase.auth.getUser();
|
|
setCurrentUser(user);
|
|
};
|
|
const fetchProfile = async (profileUsername: string) => {
|
|
try {
|
|
// Use filtered_profiles view for privacy-respecting queries
|
|
// This view enforces field-level privacy based on user settings
|
|
const { data, error } = await supabase
|
|
.from('filtered_profiles')
|
|
.select(`*`)
|
|
.eq('username', profileUsername)
|
|
.maybeSingle();
|
|
|
|
if (error) throw error;
|
|
|
|
if (data) {
|
|
// Fetch location separately if location_id is visible
|
|
let locationData: any = null;
|
|
if (data.location_id) {
|
|
const { data: location } = await supabase
|
|
.from('locations')
|
|
.select('*')
|
|
.eq('id', data.location_id)
|
|
.single();
|
|
locationData = location;
|
|
}
|
|
|
|
const profileWithLocation = {
|
|
...data,
|
|
location: locationData
|
|
};
|
|
|
|
setProfile(profileWithLocation as unknown as ProfileType);
|
|
setEditForm({
|
|
username: data.username || '',
|
|
display_name: data.display_name || '',
|
|
bio: data.bio || ''
|
|
});
|
|
setAvatarUrl(data.avatar_url || '');
|
|
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) {
|
|
handleNonCriticalError(error, { action: 'Fetch Profile', metadata: { username } });
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error loading profile",
|
|
description: getErrorMessage(error)
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
const fetchCurrentUserProfile = async () => {
|
|
try {
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) {
|
|
navigate('/auth');
|
|
return;
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('profiles')
|
|
.select(`*, location:locations(*)`)
|
|
.eq('user_id', user.id)
|
|
.maybeSingle();
|
|
|
|
if (error) throw error;
|
|
|
|
if (data) {
|
|
setProfile(data as ProfileType);
|
|
setEditForm({
|
|
username: data.username || '',
|
|
display_name: data.display_name || '',
|
|
bio: data.bio || ''
|
|
});
|
|
setAvatarUrl(data.avatar_url || '');
|
|
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) {
|
|
handleNonCriticalError(error, { action: 'Fetch Current User Profile' });
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error loading profile",
|
|
description: getErrorMessage(error)
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
const validateForm = () => {
|
|
const result = profileEditSchema.safeParse(editForm);
|
|
if (!result.success) {
|
|
const errors: Record<string, string> = {};
|
|
result.error.issues.forEach(issue => {
|
|
if (issue.path[0]) {
|
|
errors[issue.path[0] as string] = issue.message;
|
|
}
|
|
});
|
|
setFormErrors(errors);
|
|
return false;
|
|
}
|
|
if (!usernameValidation.isValid && editForm.username !== profile?.username) {
|
|
setFormErrors({
|
|
username: usernameValidation.error || 'Invalid username'
|
|
});
|
|
return false;
|
|
}
|
|
setFormErrors({});
|
|
return true;
|
|
};
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const handleSaveProfile = async () => {
|
|
if (!profile || !currentUser) return;
|
|
if (!validateForm()) return;
|
|
const usernameChanged = editForm.username !== profile.username;
|
|
if (usernameChanged && !showUsernameDialog) {
|
|
setShowUsernameDialog(true);
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
const updateData: any = {
|
|
display_name: editForm.display_name,
|
|
bio: editForm.bio,
|
|
avatar_url: avatarUrl,
|
|
avatar_image_id: avatarImageId
|
|
};
|
|
if (usernameChanged) {
|
|
updateData.username = editForm.username;
|
|
}
|
|
const {
|
|
error
|
|
} = await supabase.from('profiles').update(updateData).eq('user_id', currentUser.id);
|
|
if (error) throw error;
|
|
setProfile(prev => prev ? {
|
|
...prev,
|
|
...updateData
|
|
} : null);
|
|
setEditing(false);
|
|
setShowUsernameDialog(false);
|
|
if (usernameChanged) {
|
|
toast({
|
|
title: "Profile updated",
|
|
description: "Your username and profile URL have been updated successfully."
|
|
});
|
|
// Navigate to new username URL
|
|
navigate(`/profile/${editForm.username}`);
|
|
} else {
|
|
toast({
|
|
title: "Profile updated",
|
|
description: "Your profile has been updated successfully."
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error updating profile",
|
|
description: getErrorMessage(error)
|
|
});
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
const confirmUsernameChange = () => {
|
|
setShowUsernameDialog(false);
|
|
handleSaveProfile();
|
|
};
|
|
const handleAvatarUpload = async (urls: string[], imageId?: string) => {
|
|
if (!currentUser || !urls[0]) return;
|
|
const newAvatarUrl = urls[0];
|
|
const newImageId = imageId || '';
|
|
|
|
// Update local state immediately
|
|
setAvatarUrl(newAvatarUrl);
|
|
setAvatarImageId(newImageId);
|
|
try {
|
|
// Update database immediately
|
|
const {
|
|
error
|
|
} = await supabase.from('profiles').update({
|
|
avatar_url: newAvatarUrl,
|
|
avatar_image_id: newImageId
|
|
}).eq('user_id', currentUser.id);
|
|
if (error) throw error;
|
|
|
|
// Update local profile state
|
|
setProfile(prev => prev ? {
|
|
...prev,
|
|
avatar_url: newAvatarUrl,
|
|
avatar_image_id: newImageId
|
|
} : null);
|
|
|
|
// Refresh the auth context to update header avatar
|
|
if (refreshProfile) {
|
|
await refreshProfile();
|
|
}
|
|
toast({
|
|
title: "Avatar updated",
|
|
description: "Your profile picture has been updated successfully."
|
|
});
|
|
} catch (error) {
|
|
// Revert local state on error
|
|
setAvatarUrl(profile?.avatar_url || '');
|
|
setAvatarImageId(profile?.avatar_image_id || '');
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error updating avatar",
|
|
description: getErrorMessage(error)
|
|
});
|
|
}
|
|
};
|
|
const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id;
|
|
if (loading) {
|
|
return <div className="min-h-screen bg-background">
|
|
<Header />
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="animate-pulse space-y-6">
|
|
<div className="h-32 bg-muted rounded-lg"></div>
|
|
<div className="h-8 bg-muted rounded w-1/3"></div>
|
|
<div className="h-4 bg-muted rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
</div>;
|
|
}
|
|
if (!profile) {
|
|
return <div className="min-h-screen bg-background">
|
|
<Header />
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="text-center py-12">
|
|
<User className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
|
<h1 className="text-2xl font-bold mb-4">Profile Not Found</h1>
|
|
<p className="text-muted-foreground mb-6">
|
|
The profile you're looking for doesn't exist.
|
|
</p>
|
|
<Button onClick={() => navigate('/')}>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Go Home
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>;
|
|
}
|
|
return <div className="min-h-screen bg-background">
|
|
<Header />
|
|
|
|
<main className="container mx-auto px-4 py-8">
|
|
{/* Profile Header */}
|
|
<div className="relative mb-8">
|
|
<Card>
|
|
<CardContent className="p-8">
|
|
<div className="flex flex-col md:flex-row gap-6">
|
|
<div className="flex flex-col items-center md:items-start">
|
|
<PhotoUpload
|
|
variant="avatar"
|
|
maxFiles={1}
|
|
maxSizeMB={1}
|
|
existingPhotos={profile.avatar_url ? [profile.avatar_url] : []}
|
|
onUploadComplete={handleAvatarUpload}
|
|
currentImageId={avatarImageId}
|
|
onError={error => {
|
|
toast({
|
|
title: "Upload Error",
|
|
description: error,
|
|
variant: "destructive"
|
|
});
|
|
}}
|
|
className="mb-4"
|
|
/>
|
|
|
|
<div className="flex flex-col gap-2 mt-2">
|
|
{isOwnProfile && !editing && <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
|
<Edit3 className="w-4 h-4 mr-2" />
|
|
Edit Profile
|
|
</Button>}
|
|
|
|
{!isOwnProfile && <UserBlockButton targetUserId={profile.user_id} targetUsername={profile.username} />}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
{editing && isOwnProfile ? <div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="username">Username</Label>
|
|
<div className="relative">
|
|
<Input id="username" value={editForm.username} onChange={e => setEditForm(prev => ({
|
|
...prev,
|
|
username: e.target.value
|
|
}))} placeholder="your_username" className={`pr-10 ${formErrors.username ? 'border-destructive' : ''}`} />
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
{usernameValidation.isChecking ? <Loader2 className="w-4 h-4 text-muted-foreground animate-spin" /> : editForm.username === profile?.username ? <Check className="w-4 h-4 text-muted-foreground" /> : usernameValidation.isValid ? <Check className="w-4 h-4 text-green-500" /> : usernameValidation.error ? <AlertCircle className="w-4 h-4 text-destructive" /> : null}
|
|
</div>
|
|
</div>
|
|
{formErrors.username && <p className="text-sm text-destructive mt-1">{formErrors.username}</p>}
|
|
{usernameValidation.error && editForm.username !== profile?.username && <p className="text-sm text-destructive mt-1">{usernameValidation.error}</p>}
|
|
{usernameValidation.isValid && editForm.username !== profile?.username && <p className="text-sm text-green-600 mt-1">Username is available!</p>}
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Your profile URL will be /profile/{editForm.username}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="display_name">Display Name</Label>
|
|
<Input id="display_name" value={editForm.display_name} onChange={e => setEditForm(prev => ({
|
|
...prev,
|
|
display_name: e.target.value
|
|
}))} placeholder="Your display name" className={formErrors.display_name ? 'border-destructive' : ''} />
|
|
{formErrors.display_name && <p className="text-sm text-destructive mt-1">{formErrors.display_name}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="bio">Bio</Label>
|
|
<Textarea id="bio" value={editForm.bio} onChange={e => setEditForm(prev => ({
|
|
...prev,
|
|
bio: e.target.value
|
|
}))} placeholder="Tell us about yourself..." rows={3} className={formErrors.bio ? 'border-destructive' : ''} />
|
|
{formErrors.bio && <p className="text-sm text-destructive mt-1">{formErrors.bio}</p>}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleSaveProfile}
|
|
size="sm"
|
|
loading={isSaving}
|
|
loadingText="Saving..."
|
|
disabled={usernameValidation.isChecking || editForm.username !== profile?.username && !usernameValidation.isValid}
|
|
trackingLabel="save-profile-changes"
|
|
>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
Save Changes
|
|
</Button>
|
|
<Button variant="outline" onClick={() => {
|
|
setEditing(false);
|
|
setFormErrors({});
|
|
setEditForm({
|
|
username: profile?.username || '',
|
|
display_name: profile?.display_name || '',
|
|
bio: profile?.bio || ''
|
|
});
|
|
}} size="sm">
|
|
<X className="w-4 h-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div> : <div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h1 className="text-3xl font-bold">
|
|
{profile.display_name || profile.username}
|
|
</h1>
|
|
{profile.display_name && <Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80" onClick={() => navigate(`/profile/${profile.username}`)}>@{profile.username}</Badge>}
|
|
</div>
|
|
|
|
{profile.bio && (
|
|
<p className="text-muted-foreground mb-4 max-w-2xl">
|
|
{profile.bio}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="w-4 h-4" />
|
|
Joined {new Date(profile.created_at).toLocaleDateString('en-US', {
|
|
month: 'long',
|
|
year: 'numeric'
|
|
})}
|
|
</div>
|
|
|
|
{/* Show pronouns if enabled and present (privacy already enforced by get_filtered_profile) */}
|
|
{profile.show_pronouns && profile.preferred_pronouns && (
|
|
<div className="flex items-center gap-1">
|
|
<User className="w-4 h-4" />
|
|
{profile.preferred_pronouns}
|
|
</div>
|
|
)}
|
|
|
|
{/* Show personal location (privacy already enforced by get_filtered_profile) */}
|
|
<PersonalLocationDisplay
|
|
personalLocation={profile.personal_location}
|
|
/>
|
|
|
|
{/* Show location (privacy already enforced by get_filtered_profile) */}
|
|
<LocationDisplay
|
|
location={profile.location ? {
|
|
...profile.location,
|
|
city: profile.location.city ?? undefined
|
|
} : undefined}
|
|
/>
|
|
</div>
|
|
</div>}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<div className="text-2xl font-bold text-primary">{calculatedStats.rideCount}</div>
|
|
<div className="text-sm text-muted-foreground">Rides</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<div className="text-2xl font-bold text-accent">{calculatedStats.coasterCount}</div>
|
|
<div className="text-sm text-muted-foreground">Coasters</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<div className="text-2xl font-bold text-secondary">{calculatedStats.parkCount}</div>
|
|
<div className="text-sm text-muted-foreground">Parks</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<div className="text-2xl font-bold text-accent">{profile.review_count}</div>
|
|
<div className="text-sm text-muted-foreground">Reviews</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Profile Tabs */}
|
|
<Tabs defaultValue="activity" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
|
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
|
<TabsTrigger value="lists">Rankings</TabsTrigger>
|
|
<TabsTrigger value="credits">Ride Credits</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="activity" className="mt-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Recent Activity</CardTitle>
|
|
<CardDescription>
|
|
Latest submissions, reviews, credits, and rankings
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{activityLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : recentActivity.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<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>
|
|
<p className="text-muted-foreground">
|
|
Latest submissions, reviews, credits, and rankings will appear here
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{recentActivity.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 className="flex-shrink-0 mt-1">
|
|
{activity.type === 'review' ? (
|
|
<Star className="w-5 h-5 text-accent" />
|
|
) : activity.type === 'credit' ? (
|
|
<Trophy className="w-5 h-5 text-accent" />
|
|
) : activity.type === 'submission' ? (
|
|
activity.submission_type === 'photo' ? (
|
|
<Image className="w-5 h-5 text-accent" />
|
|
) : (
|
|
<FileText className="w-5 h-5 text-accent" />
|
|
)
|
|
) : (
|
|
<Trophy className="w-5 h-5 text-accent" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
{activity.type === 'review' ? (
|
|
(() => {
|
|
const reviewActivity = activity as ReviewActivity;
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<p className="font-medium">
|
|
{reviewActivity.title || reviewActivity.content || 'Left a review'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 mb-2">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star key={i} className={`w-3 h-3 ${i < (reviewActivity.rating ?? 0) ? 'fill-accent text-accent' : 'text-muted-foreground'}`} />
|
|
))}
|
|
</div>
|
|
{reviewActivity.parks ? (
|
|
<Link to={`/parks/${reviewActivity.parks.slug || ''}`} className="text-sm text-muted-foreground hover:text-accent transition-colors">
|
|
{reviewActivity.parks.name || 'Unknown Park'}
|
|
</Link>
|
|
) : reviewActivity.rides ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
<Link to={`/parks/${reviewActivity.rides.parks?.slug || ''}/rides/${reviewActivity.rides.slug || ''}`} className="hover:text-accent transition-colors">
|
|
{reviewActivity.rides.name || 'Unknown Ride'}
|
|
</Link>
|
|
{reviewActivity.rides.parks && (
|
|
<span className="text-muted-foreground/70"> at {reviewActivity.rides.parks.name || 'Unknown Park'}</span>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
})()
|
|
) : isSubmissionActivity(activity) ? (
|
|
<>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<p className="font-medium">
|
|
{activity.content?.action === 'edit' ? 'Edited' : 'Submitted'}{' '}
|
|
{activity.submission_type === 'photo' ? 'photos for' : activity.submission_type || 'content'}
|
|
{activity.content?.name && ` ${activity.content.name}`}
|
|
</p>
|
|
{activity.status === 'pending' && (
|
|
<Badge variant="secondary" className="text-xs">Pending</Badge>
|
|
)}
|
|
{activity.status === 'approved' && (
|
|
<Badge variant="default" className="text-xs">Approved</Badge>
|
|
)}
|
|
{activity.status === 'rejected' && (
|
|
<Badge variant="destructive" className="text-xs">Rejected</Badge>
|
|
)}
|
|
{activity.status === 'partially_approved' && (
|
|
<Badge variant="outline" className="text-xs">Partially Approved</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Photo preview for photo submissions */}
|
|
{activity.submission_type === 'photo' && activity.photo_preview && (
|
|
<div className="flex gap-2 items-center mb-2">
|
|
<img
|
|
src={activity.photo_preview}
|
|
alt="Photo preview"
|
|
className="w-16 h-16 rounded object-cover border"
|
|
/>
|
|
{activity.photo_count && activity.photo_count > 1 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
+{activity.photo_count - 1} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Entity link for photo submissions */}
|
|
{activity.submission_type === 'photo' && activity.content?.entity_slug && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{activity.entity_type === 'park' ? (
|
|
<Link to={`/parks/${activity.content.entity_slug}`} className="hover:text-accent transition-colors">
|
|
{activity.content.entity_name || 'View park'}
|
|
</Link>
|
|
) : activity.entity_type === 'ride' ? (
|
|
<>
|
|
<Link to={`/parks/${activity.content.park_slug}/rides/${activity.content.entity_slug}`} className="hover:text-accent transition-colors">
|
|
{activity.content.entity_name || 'View ride'}
|
|
</Link>
|
|
{activity.content.park_name && (
|
|
<span className="text-muted-foreground/70"> at {activity.content.park_name}</span>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* Links for entity submissions */}
|
|
{activity.status === 'approved' && activity.submission_type !== 'photo' && (
|
|
<>
|
|
{activity.submission_type === 'park' && activity.content?.slug && (
|
|
<Link
|
|
to={`/parks/${activity.content.slug}`}
|
|
className="text-sm text-accent hover:underline"
|
|
>
|
|
View park →
|
|
</Link>
|
|
)}
|
|
{activity.submission_type === 'ride' && activity.content?.slug && activity.content?.park_slug && (
|
|
<div className="text-sm">
|
|
<Link
|
|
to={`/parks/${activity.content.park_slug}/rides/${activity.content.slug}`}
|
|
className="text-accent hover:underline"
|
|
>
|
|
View ride →
|
|
</Link>
|
|
{activity.content.park_name && (
|
|
<span className="text-muted-foreground ml-1">
|
|
at {activity.content.park_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activity.submission_type === 'company' && activity.content?.slug && (
|
|
<Link
|
|
to={`/${activity.content.company_type === 'operator' ? 'operators' : activity.content.company_type === 'property_owner' ? 'owners' : activity.content.company_type === 'manufacturer' ? 'manufacturers' : 'designers'}/${activity.content.slug}`}
|
|
className="text-sm text-accent hover:underline"
|
|
>
|
|
View {activity.content.company_type || 'company'} →
|
|
</Link>
|
|
)}
|
|
{activity.submission_type === 'ride_model' && activity.content?.slug && activity.content?.manufacturer_slug && (
|
|
<Link
|
|
to={`/manufacturers/${activity.content.manufacturer_slug}/models/${activity.content.slug}`}
|
|
className="text-sm text-accent hover:underline"
|
|
>
|
|
View model →
|
|
</Link>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activity.content?.description && activity.submission_type !== 'photo' && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{activity.content.description}
|
|
</p>
|
|
)}
|
|
</>
|
|
) : isRankingActivity(activity) ? (
|
|
<>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Link
|
|
to={`/profile/${profile?.username}/lists`}
|
|
className="font-medium hover:text-accent transition-colors"
|
|
>
|
|
Created ranking: {activity.title || 'Untitled'}
|
|
</Link>
|
|
<Badge variant="outline" className="text-xs capitalize">
|
|
{(activity.list_type || '').replace('_', ' ')}
|
|
</Badge>
|
|
</div>
|
|
{activity.description && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{activity.description}
|
|
</p>
|
|
)}
|
|
</>
|
|
) : isCreditActivity(activity) ? (
|
|
<>
|
|
<p className="font-medium mb-1">Added ride credit</p>
|
|
{activity.rides && (
|
|
<div className="text-sm text-muted-foreground">
|
|
<Link to={`/parks/${activity.rides.parks?.slug}/rides/${activity.rides.slug}`} className="hover:text-accent transition-colors">
|
|
{activity.rides.name}
|
|
</Link>
|
|
{activity.rides.parks && (
|
|
<span className="text-muted-foreground/70"> at {activity.rides.parks.name}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activity.ride_count > 1 && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Ridden {activity.ride_count} times
|
|
</p>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex-shrink-0 text-xs text-muted-foreground">
|
|
{formatDistanceToNow(new Date(activity.created_at), { addSuffix: true })}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="reviews" className="mt-6">
|
|
<UserReviewsList userId={profile.user_id} reviewCount={profile.review_count} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="lists" className="mt-6">
|
|
<UserListManager />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="credits" className="mt-6">
|
|
<RideCreditsManager userId={profile.user_id} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</main>
|
|
|
|
{/* Username Change Confirmation Dialog */}
|
|
<AlertDialog open={showUsernameDialog} onOpenChange={setShowUsernameDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Change Username?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Changing your username will also change your profile URL from{' '}
|
|
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
|
/profile/{profile?.username}
|
|
</code>{' '}
|
|
to{' '}
|
|
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
|
/profile/{editForm.username}
|
|
</code>
|
|
. This means any existing bookmarks or links to your profile will no longer work.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmUsernameChange}>
|
|
Change Username
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>;
|
|
} |