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