feat: add review writing page and integrate Steam account linking
- Implemented WriteReviewPage for users to submit reviews with ratings and playtime. - Created global CSS styles for consistent theming across the application. - Established RootLayout for metadata and global styles. - Developed LoginPage for user authentication with email/password and Steam login options. - Built Home page to display game search results and recent reviews. - Added LinkSteamPage for linking Steam accounts to user profiles. - Created ProfilePage to manage user information and display their reviews. - Developed GameCard and ReviewCard components for displaying games and reviews. - Implemented Header component for navigation and user session management. - Added Providers component to wrap the application with session context. - Integrated NextAuth for user authentication with Steam and credentials. - Set up Prisma client for database interactions. - Created Steam API utility functions for fetching game and user data. - Configured TypeScript settings for the project.
This commit is contained in:
218
src/app/game/[appId]/page.tsx
Normal file
218
src/app/game/[appId]/page.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ReviewCard } from '@/components/ReviewCard'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface GameDetails {
|
||||
app_id: number
|
||||
name: string
|
||||
description: string | null
|
||||
header_image: string | null
|
||||
background_image: string | null
|
||||
release_date: string | null
|
||||
developers: string[]
|
||||
publishers: string[]
|
||||
genres: string[]
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
content: string
|
||||
rating: number
|
||||
playtime_hours: number | null
|
||||
upvotes: number
|
||||
downvotes: number
|
||||
user_vote: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function GamePage({ params }: { params: Promise<{ appId: string }> }) {
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
const [game, setGame] = useState<GameDetails | null>(null)
|
||||
const [reviews, setReviews] = useState<Review[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [resolvedParams, setResolvedParams] = useState<{ appId: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
params.then(setResolvedParams)
|
||||
}, [params])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedParams) return
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [gameRes, reviewsRes] = await Promise.all([
|
||||
fetch(`/api/games/${resolvedParams.appId}`),
|
||||
fetch(`/api/reviews?appId=${resolvedParams.appId}`)
|
||||
])
|
||||
|
||||
const gameData = await gameRes.json()
|
||||
const reviewsData = await reviewsRes.json()
|
||||
|
||||
setGame(gameData)
|
||||
setReviews(reviewsData)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [resolvedParams])
|
||||
|
||||
const handleVote = async (reviewId: string, voteType: 1 | -1) => {
|
||||
await fetch(`/api/reviews/${reviewId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voteType })
|
||||
})
|
||||
|
||||
const res = await fetch(`/api/reviews?appId=${resolvedParams?.appId}`)
|
||||
const data = await res.json()
|
||||
setReviews(data)
|
||||
}
|
||||
|
||||
const canReview = session?.user?.steamId
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="text-white">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!game) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="text-white">Game not found</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="relative h-64 md:h-96 bg-cover bg-center"
|
||||
style={{ backgroundImage: game.background_image ? `url(${game.background_image})` : undefined }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#1a1a2e] to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-32 relative z-10">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<div className="w-full md:w-1/3">
|
||||
<img
|
||||
src={game.header_image || '/placeholder.png'}
|
||||
alt={game.name}
|
||||
className="w-full rounded-xl shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-2/3">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">{game.name}</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{game.genres.map((genre) => (
|
||||
<span
|
||||
key={genre}
|
||||
className="px-3 py-1 bg-[#16213e] text-gray-300 rounded-full text-sm"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{game.developers.length > 0 && (
|
||||
<p className="text-gray-400 mb-2">
|
||||
<span className="text-white">Developer:</span> {game.developers.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{game.publishers.length > 0 && (
|
||||
<p className="text-gray-400 mb-2">
|
||||
<span className="text-white">Publisher:</span> {game.publishers.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{game.release_date && (
|
||||
<p className="text-gray-400 mb-4">
|
||||
<span className="text-white">Release Date:</span> {game.release_date}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canReview ? (
|
||||
<Link
|
||||
href={`/game/${game.app_id}/write`}
|
||||
className="inline-block px-6 py-3 bg-[#00d4ff] hover:bg-[#00b8e6] text-[#1a1a2e] font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Write a Review
|
||||
</Link>
|
||||
) : session ? (
|
||||
<Link
|
||||
href="/profile/link-steam"
|
||||
className="inline-block px-6 py-3 bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Link Steam to Write Review
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block px-6 py-3 bg-[#00d4ff] hover:bg-[#00b8e6] text-[#1a1a2e] font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Sign In to Write Review
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{game.description && (
|
||||
<div className="mt-8 bg-[#16213e] rounded-xl p-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">About</h2>
|
||||
<p className="text-gray-300 whitespace-pre-line">{game.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 mb-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Reviews ({reviews.length})
|
||||
</h2>
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<div className="text-center py-12 bg-[#16213e] rounded-xl">
|
||||
<p className="text-gray-400">No reviews yet. Be the first to write one!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reviews.map((review) => (
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
id={review.id}
|
||||
username={review.username}
|
||||
avatarUrl={review.avatar_url}
|
||||
content={review.content}
|
||||
rating={review.rating}
|
||||
playtimeHours={review.playtime_hours}
|
||||
upvotes={review.upvotes}
|
||||
downvotes={review.downvotes}
|
||||
userVote={review.user_vote}
|
||||
createdAt={review.created_at}
|
||||
onVote={handleVote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user