1+ import { GamevaultGame } from "@/api/models/GamevaultGame" ;
12import { Media } from "@/components/Media" ;
23import { useAuth } from "@/context/AuthContext" ;
34import { useDownloads } from "@/context/DownloadContext" ;
4- import { Game , getGameCoverMediaId } from "@/hooks/useGames" ;
5+ import { getGameCoverMediaId } from "@/hooks/useGames" ;
56import { CloudArrowDownIcon } from "@heroicons/react/16/solid" ;
7+ import { StarIcon as StarSolid } from "@heroicons/react/24/solid" ;
8+ import { StarIcon as StarOutline } from "@heroicons/react/24/outline" ;
69import { Button } from "@tw/button" ;
710import {
811 Dropdown ,
@@ -12,27 +15,52 @@ import {
1215 DropdownMenu ,
1316} from "@tw/dropdown" ;
1417import clsx from "clsx" ;
15- import { useCallback } from "react" ;
18+ import { useCallback , useMemo , useState } from "react" ;
19+ import { useNavigate } from "react-router" ;
1620
17- export function GameCard ( { game } : { game : Game } ) {
18- const coverId = getGameCoverMediaId ( game ) ;
19- const { serverUrl } = useAuth ( ) ;
21+ export function GameCard ( { game } : { game : GamevaultGame } ) {
22+ const coverId = getGameCoverMediaId ( game ) as number | string | null ;
23+ const { serverUrl, user, authFetch } = useAuth ( ) ;
24+ // Derive initial bookmarked state from raw API shape (bookmarked_users or bookmarkedUsers)
25+ const currentUserId = ( user as any ) ?. id ?? ( user as any ) ?. ID ;
26+ const initialBookmarked = useMemo ( ( ) => {
27+ if ( ! currentUserId ) return false ;
28+ const raw = ( game as any ) . bookmarked_users || ( game as any ) . bookmarkedUsers ;
29+ if ( ! Array . isArray ( raw ) ) return false ;
30+ return raw . some ( ( u : any ) => ( u ?. id ?? u ?. ID ) === currentUserId ) ;
31+ } , [ game , currentUserId ] ) ;
32+ const [ bookmarked , setBookmarked ] = useState < boolean > ( initialBookmarked ) ;
33+ const [ bookmarkBusy , setBookmarkBusy ] = useState ( false ) ;
34+
35+ const toggleBookmark = useCallback (
36+ async ( e : React . MouseEvent ) => {
37+ e . preventDefault ( ) ;
38+ e . stopPropagation ( ) ;
39+ if ( ! serverUrl || ! currentUserId || bookmarkBusy ) return ;
40+ const base = serverUrl . replace ( / \/ + $ / , "" ) ;
41+ const url = `${ base } /api/users/me/bookmark/${ game . id } ` ;
42+ const next = ! bookmarked ;
43+ setBookmarked ( next ) ; // optimistic
44+ setBookmarkBusy ( true ) ;
45+ try {
46+ const res = await authFetch ( url , { method : next ? "POST" : "DELETE" } ) ;
47+ if ( ! res . ok ) throw new Error ( `Bookmark toggle failed (${ res . status } )` ) ;
48+ } catch ( err ) {
49+ // rollback on error
50+ setBookmarked ( ! next ) ;
51+ } finally {
52+ setBookmarkBusy ( false ) ;
53+ }
54+ } ,
55+ [ serverUrl , currentUserId , bookmarkBusy , authFetch , game . id , bookmarked ] ,
56+ ) ;
2057 const { startDownload } = useDownloads ( ) as any ;
2158
2259 const filename = ( ( ) => {
23- const p = game . path || ( game as any ) . Path ;
24- if ( ! p ) return `${ game . title } .zip` ;
25- // Derive filename similar to Path.GetFileName
26- try {
27- const parts = p . split ( / \\ | \/ / ) ;
28- const last = parts [ parts . length - 1 ] ;
29- return last || `${ game . title } .zip` ;
30- } catch {
31- return `${ game . title } .zip` ;
32- }
60+ return `${ game . title } .zip` ;
3361 } ) ( ) ;
3462
35- const rawSize = ( game as any ) . size ;
63+ const rawSize = game . size ;
3664
3765 const formatBytes = useCallback ( ( bytes ?: number ) => {
3866 if ( bytes === undefined || bytes === null || isNaN ( bytes ) ) return null ;
@@ -71,14 +99,15 @@ export function GameCard({ game }: { game: Game }) {
7199 [ game . id ] ,
72100 ) ;
73101
74- const handleClientOpen = useCallback (
102+ const navigate = useNavigate ( ) ;
103+
104+ const handleOpenGameView = useCallback (
75105 ( e : React . MouseEvent ) => {
76106 e . preventDefault ( ) ;
77107 e . stopPropagation ( ) ;
78- const url = `gamevault://show?gameid=${ game . id } ` ;
79- window . location . href = url ;
108+ navigate ( `/library/${ game . id } ` ) ;
80109 } ,
81- [ game . id ] ,
110+ [ navigate , game . id ] ,
82111 ) ;
83112
84113 return (
@@ -92,18 +121,43 @@ export function GameCard({ game }: { game: Game }) {
92121 < div className = "relative aspect-[3/4] w-full bg-bg-muted flex items-center justify-center overflow-hidden" >
93122 { coverId ? (
94123 < Media
95- media = { { id : coverId } as any }
124+ media = { {
125+ id : typeof coverId === 'number' ? coverId : Number ( coverId ) || 0 ,
126+ created_at : new Date ( 0 ) ,
127+ entity_version : 0 ,
128+ } as any }
96129 size = { 300 }
97130 className = "h-full w-full object-contain rounded-none"
98131 square
99132 alt = { game . title }
100- onClick = { handleClientOpen }
133+ onClick = { handleOpenGameView }
101134 />
102135 ) : (
103- < div onClick = { handleClientOpen } className = "text-xs text-fg-muted" >
136+ < div onClick = { handleOpenGameView } className = "text-xs text-fg-muted" >
104137 No Cover
105138 </ div >
106139 ) }
140+ { /* Top-right bookmark toggle */ }
141+ < button
142+ type = "button"
143+ onClick = { toggleBookmark }
144+ aria-label = { bookmarked ? "Remove bookmark" : "Add bookmark" }
145+ aria-pressed = { bookmarked }
146+ disabled = { ! currentUserId || bookmarkBusy }
147+ className = { clsx (
148+ "absolute top-1 right-1 h-8 w-8 flex items-center justify-center rounded-md border shadow-sm backdrop-blur-sm transition-colors" ,
149+ "disabled:opacity-50 disabled:cursor-not-allowed" ,
150+ bookmarked
151+ ? "bg-yellow-400/20 border-yellow-400"
152+ : "bg-zinc-900/40 dark:bg-zinc-700/50 border-white/20 hover:bg-zinc-800/60 dark:hover:bg-zinc-600/60" ,
153+ ) }
154+ >
155+ { bookmarked ? (
156+ < StarSolid className = "h-5 w-5 text-yellow-400" />
157+ ) : (
158+ < StarOutline className = "h-5 w-5 text-white" />
159+ ) }
160+ </ button >
107161 { /* Bottom-right download actions */ }
108162 < div className = "absolute bottom-0 right-0 p-1 z-10 flex justify-end opacity-85" >
109163 < Dropdown >
@@ -130,7 +184,7 @@ export function GameCard({ game }: { game: Game }) {
130184 < h3 className = "text-sm font-medium truncate" title = { game . title } >
131185 { game . metadata ?. title || game . title }
132186 </ h3 >
133- { game . sort_title && game . sort_title !== game . title && (
187+ { ( game as any ) . sort_title && ( game as any ) . sort_title !== game . title && (
134188 < p
135189 className = "mt-0.5 text-xs text-fg-muted truncate"
136190 title = { game . title }
0 commit comments