Add ride detail pages

This commit is contained in:
gpt-engineer-app[bot]
2025-09-20 00:59:50 +00:00
parent 90bb0216b7
commit 3a4b52ec18
9 changed files with 1247 additions and 29 deletions

187
package-lock.json generated
View File

@@ -17,7 +17,7 @@
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
@@ -1330,16 +1330,16 @@
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz",
"integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==",
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.15",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
@@ -1358,6 +1358,181 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",

View File

@@ -20,7 +20,7 @@
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",

View File

@@ -3,18 +3,22 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import Index from "./pages/Index";
import Parks from "./pages/Parks";
import ParkDetail from "./pages/ParkDetail";
import RideDetail from "./pages/RideDetail";
import Rides from "./pages/Rides";
import Manufacturers from "./pages/Manufacturers";
import Auth from "./pages/Auth";
import Profile from "./pages/Profile";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<TooltipProvider>
<Toaster />
<Sonner />
@@ -26,11 +30,15 @@ const App = () => (
<Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} />
<Route path="/rides" element={<Rides />} />
<Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/auth" element={<Auth />} />
<Route path="/profile" element={<Profile />} />
<Route path="/profile/:username" element={<Profile />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</AuthProvider>
</QueryClientProvider>
);

View File

@@ -0,0 +1,109 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { User, Settings, LogOut, Trophy } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
export function AuthButtons() {
const { user, profile, signOut } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const [loggingOut, setLoggingOut] = useState(false);
const handleSignOut = async () => {
setLoggingOut(true);
try {
await signOut();
toast({
title: "Signed out",
description: "You've been signed out successfully.",
});
navigate('/');
} catch (error: any) {
toast({
variant: "destructive",
title: "Error signing out",
description: error.message,
});
} finally {
setLoggingOut(false);
}
};
if (!user) {
return (
<>
<Button
variant="ghost"
size="sm"
className="hidden sm:flex"
onClick={() => navigate('/auth?tab=signin')}
>
Sign In
</Button>
<Button
size="sm"
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
onClick={() => navigate('/auth?tab=signup')}
>
Join ThrillWiki
</Button>
</>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={profile?.avatar_url || ''} alt={profile?.display_name || profile?.username || ''} />
<AvatarFallback>
{(profile?.display_name || profile?.username || user.email || 'U').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{profile?.display_name || profile?.username}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/profile#lists')}>
<Trophy className="mr-2 h-4 w-4" />
<span>My Lists</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/profile#settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} disabled={loggingOut}>
<LogOut className="mr-2 h-4 w-4" />
<span>{loggingOut ? 'Signing out...' : 'Sign out'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -6,6 +6,7 @@ import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { Link, useNavigate } from 'react-router-dom';
import { SearchDropdown } from '@/components/search/SearchDropdown';
import { AuthButtons } from '@/components/auth/AuthButtons';
export function Header() {
const navigate = useNavigate();
@@ -66,12 +67,7 @@ export function Header() {
{/* User Actions */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="hidden sm:flex">
Sign In
</Button>
<Button size="sm" className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
Join ThrillWiki
</Button>
<AuthButtons />
{/* Mobile Menu */}
<Sheet>

104
src/hooks/useAuth.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';
import { Profile } from '@/types/database';
interface AuthContextType {
user: User | null;
session: Session | null;
profile: Profile | null;
loading: boolean;
signOut: () => Promise<void>;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const fetchProfile = async (userId: string) => {
try {
const { data, error } = await supabase
.from('profiles')
.select(`*, location:locations(*)`)
.eq('user_id', userId)
.maybeSingle();
if (error && error.code !== 'PGRST116') {
console.error('Error fetching profile:', error);
return;
}
setProfile(data as Profile);
} catch (error) {
console.error('Error fetching profile:', error);
}
};
const refreshProfile = async () => {
if (user) {
await fetchProfile(user.id);
}
};
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
fetchProfile(session.user.id);
}
setLoading(false);
});
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
await fetchProfile(session.user.id);
} else {
setProfile(null);
}
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error signing out:', error);
throw error;
}
};
const value = {
user,
session,
profile,
loading,
signOut,
refreshProfile,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

384
src/pages/Auth.tsx Normal file
View File

@@ -0,0 +1,384 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
export default function Auth() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
username: '',
displayName: ''
});
const defaultTab = searchParams.get('tab') || 'signin';
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const { data, error } = await supabase.auth.signInWithPassword({
email: formData.email,
password: formData.password,
});
if (error) throw error;
toast({
title: "Welcome back!",
description: "You've been signed in successfully.",
});
const redirectTo = searchParams.get('redirect') || '/';
navigate(redirectTo);
} catch (error: any) {
toast({
variant: "destructive",
title: "Sign in failed",
description: error.message,
});
} finally {
setLoading(false);
}
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
if (formData.password !== formData.confirmPassword) {
toast({
variant: "destructive",
title: "Passwords don't match",
description: "Please make sure your passwords match.",
});
setLoading(false);
return;
}
if (formData.password.length < 6) {
toast({
variant: "destructive",
title: "Password too short",
description: "Password must be at least 6 characters long.",
});
setLoading(false);
return;
}
try {
const { data, error } = await supabase.auth.signUp({
email: formData.email,
password: formData.password,
options: {
data: {
username: formData.username,
display_name: formData.displayName
}
}
});
if (error) throw error;
toast({
title: "Welcome to ThrillWiki!",
description: "Please check your email to verify your account.",
});
navigate('/');
} catch (error: any) {
toast({
variant: "destructive",
title: "Sign up failed",
description: error.message,
});
} finally {
setLoading(false);
}
};
const handleSocialSignIn = async (provider: 'google' | 'github') => {
try {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) throw error;
} catch (error: any) {
toast({
variant: "destructive",
title: "Social sign in failed",
description: error.message,
});
}
};
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-4">
<div className="relative">
<Zap className="h-12 w-12 text-primary" />
<div className="absolute inset-0 blur-sm bg-primary/20 rounded-full"></div>
</div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
ThrillWiki
</h1>
</div>
<p className="text-muted-foreground">
Join the ultimate theme park community
</p>
</div>
<Card>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="signin">
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your ThrillWiki account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signin-email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="signin-email"
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signin-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="signin-password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="Your password"
value={formData.password}
onChange={handleInputChange}
className="pl-10 pr-10"
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<Button
variant="outline"
onClick={() => handleSocialSignIn('google')}
className="w-full"
>
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</Button>
<Button
variant="outline"
onClick={() => handleSocialSignIn('github')}
className="w-full"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</Button>
</div>
</div>
</CardContent>
</TabsContent>
<TabsContent value="signup">
<CardHeader>
<CardTitle>Create account</CardTitle>
<CardDescription>
Join the ThrillWiki community today
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSignUp} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="username"
name="username"
placeholder="username"
value={formData.username}
onChange={handleInputChange}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
name="displayName"
placeholder="Display Name"
value={formData.displayName}
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signup-email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="signup-email"
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signup-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="signup-password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="Create a password"
value={formData.password}
onChange={handleInputChange}
className="pl-10 pr-10"
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="Confirm your password"
value={formData.confirmPassword}
onChange={handleInputChange}
className="pl-10"
required
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account..." : "Create Account"}
</Button>
</form>
<Alert className="mt-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
By signing up, you agree to our Terms of Service and Privacy Policy.
</AlertDescription>
</Alert>
</CardContent>
</TabsContent>
</Tabs>
</Card>
</div>
</main>
</div>
);
}

440
src/pages/Profile.tsx Normal file
View File

@@ -0,0 +1,440 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
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 {
User,
MapPin,
Calendar,
Star,
Trophy,
Settings,
Camera,
Edit3,
Save,
X,
ArrowLeft
} from 'lucide-react';
import { Profile as ProfileType } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
export default function Profile() {
const { username } = useParams<{ username?: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const [profile, setProfile] = useState<ProfileType | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [currentUser, setCurrentUser] = useState<any>(null);
const [editForm, setEditForm] = useState({
display_name: '',
bio: '',
avatar_url: ''
});
useEffect(() => {
getCurrentUser();
if (username) {
fetchProfile(username);
} else {
fetchCurrentUserProfile();
}
}, [username]);
const getCurrentUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
setCurrentUser(user);
};
const fetchProfile = async (profileUsername: string) => {
try {
const { data, error } = await supabase
.from('profiles')
.select(`*, location:locations(*)`)
.eq('username', profileUsername)
.maybeSingle();
if (error) throw error;
if (data) {
setProfile(data as ProfileType);
setEditForm({
display_name: data.display_name || '',
bio: data.bio || '',
avatar_url: data.avatar_url || ''
});
}
} catch (error: any) {
console.error('Error fetching profile:', error);
toast({
variant: "destructive",
title: "Error loading profile",
description: error.message,
});
} 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({
display_name: data.display_name || '',
bio: data.bio || '',
avatar_url: data.avatar_url || ''
});
}
} catch (error: any) {
console.error('Error fetching profile:', error);
toast({
variant: "destructive",
title: "Error loading profile",
description: error.message,
});
} finally {
setLoading(false);
}
};
const handleSaveProfile = async () => {
if (!profile || !currentUser) return;
try {
const { error } = await supabase
.from('profiles')
.update({
display_name: editForm.display_name,
bio: editForm.bio,
avatar_url: editForm.avatar_url
})
.eq('user_id', currentUser.id);
if (error) throw error;
setProfile(prev => prev ? {
...prev,
display_name: editForm.display_name,
bio: editForm.bio,
avatar_url: editForm.avatar_url
} : null);
setEditing(false);
toast({
title: "Profile updated",
description: "Your profile has been updated successfully.",
});
} catch (error: any) {
toast({
variant: "destructive",
title: "Error updating profile",
description: error.message,
});
}
};
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">
<Avatar className="w-32 h-32 mb-4">
<AvatarImage src={profile.avatar_url || ''} alt={profile.display_name || profile.username} />
<AvatarFallback className="text-2xl">
{(profile.display_name || profile.username).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{isOwnProfile && !editing && (
<Button
variant="outline"
size="sm"
onClick={() => setEditing(true)}
className="mt-2"
>
<Edit3 className="w-4 h-4 mr-2" />
Edit Profile
</Button>
)}
</div>
<div className="flex-1">
{editing && isOwnProfile ? (
<div className="space-y-4">
<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"
/>
</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}
/>
</div>
<div>
<Label htmlFor="avatar_url">Avatar URL</Label>
<Input
id="avatar_url"
value={editForm.avatar_url}
onChange={(e) => setEditForm(prev => ({ ...prev, avatar_url: e.target.value }))}
placeholder="https://..."
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveProfile} size="sm">
<Save className="w-4 h-4 mr-2" />
Save Changes
</Button>
<Button
variant="outline"
onClick={() => setEditing(false)}
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">@{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>
{profile.location && (
<div className="flex items-center gap-1">
<MapPin className="w-4 h-4" />
{profile.location.city}, {profile.location.country}
</div>
)}
</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">{profile.ride_count}</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">{profile.coaster_count}</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">{profile.park_count}</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">{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">Top Lists</TabsTrigger>
<TabsTrigger value="credits">Ride Credits</TabsTrigger>
</TabsList>
<TabsContent value="activity" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Latest reviews, ratings, and achievements
</CardDescription>
</CardHeader>
<CardContent>
<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">Activity Feed Coming Soon</h3>
<p className="text-muted-foreground">
Track reviews, ratings, and achievements
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reviews" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Reviews ({profile.review_count})</CardTitle>
<CardDescription>
Parks and rides reviews
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<Star className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">Reviews Coming Soon</h3>
<p className="text-muted-foreground">
User reviews and ratings will appear here
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="lists" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Top Lists</CardTitle>
<CardDescription>
Personal rankings and favorite collections
</CardDescription>
</CardHeader>
<CardContent>
<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">Top Lists Coming Soon</h3>
<p className="text-muted-foreground">
Create and share your favorite park and ride rankings
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="credits" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Ride Credits</CardTitle>
<CardDescription>
Track all the rides you've experienced
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<User className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">Ride Credits Coming Soon</h3>
<p className="text-muted-foreground">
Log and track your ride experiences
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</div>
);
}

View File

@@ -102,6 +102,8 @@ export interface Profile {
park_count: number;
review_count: number;
reputation_score: number;
created_at: string;
updated_at: string;
}
export interface Review {