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:
38
src/features/games/components/favorite-button.tsx
Normal file
38
src/features/games/components/favorite-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/features/games/components/game-card.tsx
Normal file
38
src/features/games/components/game-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
src/features/games/components/game-state-select.tsx
Normal file
48
src/features/games/components/game-state-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/features/games/components/star-rating.tsx
Normal file
67
src/features/games/components/star-rating.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/features/games/schema.ts
Normal file
36
src/features/games/schema.ts
Normal 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`
|
||||
}
|
||||
Reference in New Issue
Block a user