@@ -6,7 +6,17 @@ import {
66 DialogDescription ,
77 DialogTitle ,
88} from "@/components/tailwind/dialog" ;
9+ import {
10+ Table ,
11+ TableBody ,
12+ TableCell ,
13+ TableHead ,
14+ TableHeader ,
15+ TableRow ,
16+ } from "@tw/table" ;
17+ import { CheckIcon } from "@heroicons/react/24/solid" ;
918import { Button } from "@tw/button" ;
19+ import { useMemo , useState } from "react" ;
1020
1121interface VersionSelectDialogProps {
1222 open : boolean ;
@@ -16,34 +26,179 @@ interface VersionSelectDialogProps {
1626 onClose : ( ) => void ;
1727}
1828
29+ type SortKey = "version" | "indexed_at" | "type" | "early_access" | "size" ;
30+
1931export function VersionSelectDialog ( {
2032 open,
2133 gameTitle,
2234 versions,
2335 onSelect,
2436 onClose,
2537} : VersionSelectDialogProps ) {
38+ const [ sortBy , setSortBy ] = useState < SortKey > ( "indexed_at" ) ;
39+ const [ sortDirection , setSortDirection ] = useState < "asc" | "desc" > ( "desc" ) ;
40+
41+ const toSizeNumber = ( size : string ) => {
42+ const parsed = Number ( size ) ;
43+ return Number . isFinite ( parsed ) ? parsed : 0 ;
44+ } ;
45+
46+ const formatSize = ( rawSize : string ) => {
47+ const bytes = toSizeNumber ( rawSize ) ;
48+ if ( bytes <= 0 ) return "0 B" ;
49+ if ( bytes < 1024 ) return `${ bytes } B` ;
50+ const units = [ "KB" , "MB" , "GB" , "TB" , "PB" ] ;
51+ let value = bytes / 1024 ;
52+ let unitIndex = 0 ;
53+ while ( value >= 1024 && unitIndex < units . length - 1 ) {
54+ value /= 1024 ;
55+ unitIndex ++ ;
56+ }
57+ const precision = value < 10 ? 2 : value < 100 ? 1 : 0 ;
58+ return `${ value . toFixed ( precision ) } ${ units [ unitIndex ] } ` ;
59+ } ;
60+
61+ const formatGameType = ( type : string ) =>
62+ type
63+ . replace ( / _ / g, " " )
64+ . toLowerCase ( )
65+ . replace ( / \b \w / g, ( c ) => c . toUpperCase ( ) ) ;
66+
67+ const formatIndexedAt = ( value : Date ) => {
68+ const date = value instanceof Date ? value : new Date ( value ) ;
69+ if ( Number . isNaN ( date . getTime ( ) ) ) return "-" ;
70+ return date . toLocaleString ( ) ;
71+ } ;
72+
73+ const sortedVersions = useMemo ( ( ) => {
74+ const multiplier = sortDirection === "asc" ? 1 : - 1 ;
75+ return [ ...versions ] . sort ( ( left , right ) => {
76+ let compareValue = 0 ;
77+
78+ if ( sortBy === "version" ) {
79+ compareValue = ( left . version || "" ) . localeCompare ( right . version || "" , undefined , {
80+ sensitivity : "base" ,
81+ numeric : true ,
82+ } ) ;
83+ } else if ( sortBy === "indexed_at" ) {
84+ compareValue =
85+ new Date ( left . indexed_at ) . getTime ( ) - new Date ( right . indexed_at ) . getTime ( ) ;
86+ } else if ( sortBy === "type" ) {
87+ compareValue = left . type . localeCompare ( right . type , undefined , {
88+ sensitivity : "base" ,
89+ } ) ;
90+ } else if ( sortBy === "early_access" ) {
91+ compareValue = Number ( left . early_access ) - Number ( right . early_access ) ;
92+ } else if ( sortBy === "size" ) {
93+ compareValue = toSizeNumber ( left . size ) - toSizeNumber ( right . size ) ;
94+ }
95+
96+ return compareValue * multiplier ;
97+ } ) ;
98+ } , [ versions , sortBy , sortDirection ] ) ;
99+
100+ const handleSort = ( nextSortBy : SortKey ) => {
101+ if ( sortBy === nextSortBy ) {
102+ setSortDirection ( ( prev ) => ( prev === "asc" ? "desc" : "asc" ) ) ;
103+ return ;
104+ }
105+ setSortBy ( nextSortBy ) ;
106+ setSortDirection ( "asc" ) ;
107+ } ;
108+
109+ const sortIndicator = ( key : SortKey ) => {
110+ if ( sortBy !== key ) return "↕" ;
111+ return sortDirection === "asc" ? "↑" : "↓" ;
112+ } ;
113+
26114 if ( ! open ) return null ;
27115
28116 return (
29- < Dialog open onClose = { onClose } size = "lg " >
117+ < Dialog open onClose = { onClose } size = "5xl " >
30118 < DialogTitle > Select Version</ DialogTitle >
31119 < DialogDescription >
32120 Choose which version of { gameTitle } you want to download.
33121 </ DialogDescription >
34122 < DialogBody className = "pt-3" >
35- < div className = "flex flex-col gap-2" >
36- { versions . map ( ( version ) => (
37- < button
38- key = { version . id }
39- type = "button"
40- onClick = { ( ) => onSelect ( version ) }
41- className = "w-full rounded-md border border-zinc-300/60 dark:border-zinc-700 px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800"
42- >
43- ({ version . id } ) { version . version || "Unknown Version" }
44- </ button >
45- ) ) }
46- </ div >
123+ < Table className = "[--gutter:--spacing(3)] max-h-[55vh] overflow-y-auto [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-zinc-300 [&::-webkit-scrollbar-thumb]:rounded-full dark:[&::-webkit-scrollbar-thumb]:bg-zinc-600 [scrollbar-color:#a1a1aa_transparent] dark:[scrollbar-color:#52525b_transparent]" >
124+ < TableHead >
125+ < TableRow >
126+ < TableHeader className = "sticky top-0 z-10 bg-white dark:bg-zinc-900" >
127+ < button
128+ type = "button"
129+ onClick = { ( ) => handleSort ( "version" ) }
130+ className = "inline-flex items-center gap-1"
131+ >
132+ Version { sortIndicator ( "version" ) }
133+ </ button >
134+ </ TableHeader >
135+ < TableHeader className = "sticky top-0 z-10 bg-white dark:bg-zinc-900" >
136+ < button
137+ type = "button"
138+ onClick = { ( ) => handleSort ( "indexed_at" ) }
139+ className = "inline-flex items-center gap-1"
140+ >
141+ Date Added { sortIndicator ( "indexed_at" ) }
142+ </ button >
143+ </ TableHeader >
144+ < TableHeader className = "sticky top-0 z-10 bg-white dark:bg-zinc-900" >
145+ < button
146+ type = "button"
147+ onClick = { ( ) => handleSort ( "type" ) }
148+ className = "inline-flex items-center gap-1"
149+ >
150+ Game Type { sortIndicator ( "type" ) }
151+ </ button >
152+ </ TableHeader >
153+ < TableHeader className = "sticky top-0 z-10 bg-white dark:bg-zinc-900 text-center" >
154+ < button
155+ type = "button"
156+ onClick = { ( ) => handleSort ( "early_access" ) }
157+ className = "inline-flex items-center gap-1"
158+ >
159+ Early Access { sortIndicator ( "early_access" ) }
160+ </ button >
161+ </ TableHeader >
162+ < TableHeader className = "sticky top-0 z-10 bg-white dark:bg-zinc-900" >
163+ < button
164+ type = "button"
165+ onClick = { ( ) => handleSort ( "size" ) }
166+ className = "inline-flex items-center gap-1"
167+ >
168+ Size { sortIndicator ( "size" ) }
169+ </ button >
170+ </ TableHeader >
171+ </ TableRow >
172+ </ TableHead >
173+ < TableBody >
174+ { sortedVersions . map ( ( version ) => (
175+ < TableRow
176+ key = { version . id }
177+ className = "cursor-pointer hover:bg-zinc-100/80 dark:hover:bg-zinc-800/70"
178+ onClick = { ( ) => onSelect ( version ) }
179+ >
180+ < TableCell >
181+ < span
182+ className = "block max-w-[25rem] overflow-hidden text-ellipsis whitespace-nowrap"
183+ title = { `(${ version . id } ) ${ version . version || "Unknown Version" } ` }
184+ >
185+ { version . version || "Unknown Version" }
186+ </ span >
187+ </ TableCell >
188+ < TableCell > { formatIndexedAt ( version . indexed_at ) } </ TableCell >
189+ < TableCell > { formatGameType ( version . type ) } </ TableCell >
190+ < TableCell className = "text-center" >
191+ { version . early_access ? (
192+ < span className = "inline-flex items-center justify-center" title = "Early Access" >
193+ < CheckIcon className = "h-4 w-4" />
194+ </ span >
195+ ) : null }
196+ </ TableCell >
197+ < TableCell > { formatSize ( version . size ) } </ TableCell >
198+ </ TableRow >
199+ ) ) }
200+ </ TableBody >
201+ </ Table >
47202 </ DialogBody >
48203 < DialogActions >
49204 < Button plain onClick = { onClose } >
0 commit comments