add games feature: star rating, game state, favorites, game card

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:59:06 +01:00
parent d907f26683
commit 9577087930
5 changed files with 227 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import { usePlaylistMutations, useUpdateGame } from "@/shared/db/hooks"
import { Heart } from "lucide-react"
import { useCallback } from "react"
interface FavoriteButtonProps {
gameId: string
isFavorite: boolean
onChange?: () => void
}
export function FavoriteButton({ gameId, isFavorite, onChange }: FavoriteButtonProps) {
const updateGame = useUpdateGame()
const { addGame, removeGame } = usePlaylistMutations()
const toggle = useCallback(async () => {
const newVal = !isFavorite
await updateGame(gameId, { is_favorite: newVal })
if (newVal) {
await addGame("favorites", gameId)
} else {
await removeGame("favorites", gameId)
}
onChange?.()
}, [gameId, isFavorite, updateGame, addGame, removeGame, onChange])
return (
<button
type="button"
onClick={toggle}
className="transition-colors"
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<Heart
className={`h-5 w-5 ${isFavorite ? "fill-red-500 text-red-500" : "text-muted-foreground"}`}
/>
</button>
)
}

View File

@@ -0,0 +1,38 @@
import { Badge } from "@/shared/components/ui/badge"
import { Card } from "@/shared/components/ui/card"
import type { Game } from "@/shared/db/schema"
import { formatPlaytime } from "../schema"
import { FavoriteButton } from "./favorite-button"
import { GameStateSelect } from "./game-state-select"
import { StarRating } from "./star-rating"
interface GameCardProps {
game: Game
onUpdate?: () => void
}
export function GameCard({ game, onUpdate }: GameCardProps) {
return (
<Card className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-medium">{game.title}</h3>
<Badge variant="outline" className="shrink-0 text-xs">
{game.source}
</Badge>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
{game.playtime_hours > 0 && <span>{formatPlaytime(game.playtime_hours)}</span>}
{game.last_played && <span>Last: {game.last_played}</span>}
</div>
</div>
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
</div>
<div className="mt-3 flex items-center justify-between">
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
<GameStateSelect gameId={game.id} state={game.game_state} onChange={onUpdate} />
</div>
</Card>
)
}

View File

@@ -0,0 +1,48 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { useUpdateGame } from "@/shared/db/hooks"
import type { GameState } from "@/shared/db/schema"
import { gameStateColors, gameStateLabels } from "../schema"
interface GameStateSelectProps {
gameId: string
state: GameState
onChange?: () => void
}
const states = Object.keys(gameStateLabels) as GameState[]
export function GameStateSelect({ gameId, state, onChange }: GameStateSelectProps) {
const updateGame = useUpdateGame()
const handleChange = async (value: string) => {
await updateGame(gameId, { game_state: value as GameState })
onChange?.()
}
return (
<Select value={state} onValueChange={handleChange}>
<SelectTrigger className="h-8 w-36 text-xs">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${gameStateColors[state]}`} />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{states.map((s) => (
<SelectItem key={s} value={s}>
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${gameStateColors[s]}`} />
{gameStateLabels[s]}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@@ -0,0 +1,67 @@
import { useUpdateGame } from "@/shared/db/hooks"
import { Star } from "lucide-react"
interface StarRatingProps {
gameId: string
rating: number
onChange?: () => void
}
export function StarRating({ gameId, rating, onChange }: StarRatingProps) {
const updateGame = useUpdateGame()
const stars = rating < 0 ? 0 : rating / 2
const handleClick = async (starIndex: number, isHalf: boolean) => {
const newRating = isHalf ? starIndex * 2 - 1 : starIndex * 2
const finalRating = newRating === rating ? -1 : newRating
await updateGame(gameId, { rating: finalRating })
onChange?.()
}
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((i) => {
const filled = stars >= i
const halfFilled = !filled && stars >= i - 0.5
return (
<button
key={i}
type="button"
className="relative h-5 w-5 text-yellow-500"
aria-label={`Rate ${i} stars`}
>
{/* left half click */}
<span
className="absolute inset-y-0 left-0 w-1/2 cursor-pointer"
onClick={() => handleClick(i, true)}
onKeyDown={() => {}}
role="button"
tabIndex={-1}
/>
{/* right half click */}
<span
className="absolute inset-y-0 right-0 w-1/2 cursor-pointer"
onClick={() => handleClick(i, false)}
onKeyDown={() => {}}
role="button"
tabIndex={-1}
/>
{filled ? (
<Star className="h-5 w-5 fill-current" />
) : halfFilled ? (
<div className="relative">
<Star className="h-5 w-5 text-muted-foreground/30" />
<div className="absolute inset-0 overflow-hidden" style={{ width: "50%" }}>
<Star className="h-5 w-5 fill-current" />
</div>
</div>
) : (
<Star className="h-5 w-5 text-muted-foreground/30" />
)}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import type { GameState } from "@/shared/db/schema"
export const gameStateLabels: Record<GameState, string> = {
not_set: "Not Set",
wishlisted: "Wishlisted",
playlisted: "Playlisted",
playing: "Playing",
finished: "Finished",
perfected: "Perfected",
abandoned: "Abandoned",
bad_game: "Bad Game",
}
export const gameStateColors: Record<GameState, string> = {
not_set: "bg-gray-400",
wishlisted: "bg-purple-500",
playlisted: "bg-blue-500",
playing: "bg-green-500",
finished: "bg-emerald-600",
perfected: "bg-yellow-500",
abandoned: "bg-red-500",
bad_game: "bg-red-700",
}
export function formatRating(rating: number): string {
if (rating < 0) return "Unrated"
const stars = rating / 2
const full = Math.floor(stars)
const half = stars % 1 >= 0.5 ? "½" : ""
return `${"★".repeat(full)}${half}${"☆".repeat(5 - full - (half ? 1 : 0))}`
}
export function formatPlaytime(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)}m`
return `${hours.toFixed(1)}h`
}