From 95770879303097553ed177b009e0955063567aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sun, 1 Mar 2026 13:59:06 +0100 Subject: [PATCH] add games feature: star rating, game state, favorites, game card Co-Authored-By: Claude Opus 4.6 --- .../games/components/favorite-button.tsx | 38 +++++++++++ src/features/games/components/game-card.tsx | 38 +++++++++++ .../games/components/game-state-select.tsx | 48 +++++++++++++ src/features/games/components/star-rating.tsx | 67 +++++++++++++++++++ src/features/games/schema.ts | 36 ++++++++++ 5 files changed, 227 insertions(+) create mode 100644 src/features/games/components/favorite-button.tsx create mode 100644 src/features/games/components/game-card.tsx create mode 100644 src/features/games/components/game-state-select.tsx create mode 100644 src/features/games/components/star-rating.tsx create mode 100644 src/features/games/schema.ts diff --git a/src/features/games/components/favorite-button.tsx b/src/features/games/components/favorite-button.tsx new file mode 100644 index 0000000..a47b574 --- /dev/null +++ b/src/features/games/components/favorite-button.tsx @@ -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 ( + + ) +} diff --git a/src/features/games/components/game-card.tsx b/src/features/games/components/game-card.tsx new file mode 100644 index 0000000..6bc20b7 --- /dev/null +++ b/src/features/games/components/game-card.tsx @@ -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 ( + +
+
+
+

{game.title}

+ + {game.source} + +
+
+ {game.playtime_hours > 0 && {formatPlaytime(game.playtime_hours)}} + {game.last_played && Last: {game.last_played}} +
+
+ +
+
+ + +
+
+ ) +} diff --git a/src/features/games/components/game-state-select.tsx b/src/features/games/components/game-state-select.tsx new file mode 100644 index 0000000..7f75911 --- /dev/null +++ b/src/features/games/components/game-state-select.tsx @@ -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 ( + + ) +} diff --git a/src/features/games/components/star-rating.tsx b/src/features/games/components/star-rating.tsx new file mode 100644 index 0000000..52f3b18 --- /dev/null +++ b/src/features/games/components/star-rating.tsx @@ -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 ( +
+ {[1, 2, 3, 4, 5].map((i) => { + const filled = stars >= i + const halfFilled = !filled && stars >= i - 0.5 + + return ( + + ) + })} +
+ ) +} diff --git a/src/features/games/schema.ts b/src/features/games/schema.ts new file mode 100644 index 0000000..46b2fc9 --- /dev/null +++ b/src/features/games/schema.ts @@ -0,0 +1,36 @@ +import type { GameState } from "@/shared/db/schema" + +export const gameStateLabels: Record = { + not_set: "Not Set", + wishlisted: "Wishlisted", + playlisted: "Playlisted", + playing: "Playing", + finished: "Finished", + perfected: "Perfected", + abandoned: "Abandoned", + bad_game: "Bad Game", +} + +export const gameStateColors: Record = { + 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` +}