11import { useEffect , useMemo , useState } from 'react'
2+ import { Link } from 'react-router-dom'
23import {
34 AlertTriangle ,
45 Apple ,
6+ ArrowLeft ,
57 Download as DownloadIcon ,
68 Loader2 ,
79 Monitor ,
@@ -42,17 +44,18 @@ function classify(asset) {
4244 ? 'Intel / AMD64'
4345 : null
4446
45- if ( name . endsWith ( '.dmg' ) ) return { platform : 'mac' , kind : 'Disk image' , arch }
46- if ( name . endsWith ( '.zip' ) ) return { platform : 'mac' , kind : 'Zip archive' , arch }
47+ if ( name . endsWith ( '.dmg' ) ) return { platform : 'mac' , kind : 'Disk image' , arch, rank : 0 }
48+ if ( name . endsWith ( '.zip' ) ) return { platform : 'mac' , kind : 'Zip archive' , arch, rank : 1 }
4749 if ( name . endsWith ( '.exe' ) )
4850 return {
4951 platform : 'windows' ,
5052 kind : name . includes ( 'setup' ) ? 'Installer' : 'Portable' ,
5153 arch,
54+ rank : name . includes ( 'setup' ) ? 0 : 1 ,
5255 }
53- if ( name . endsWith ( '.appimage' ) ) return { platform : 'linux' , kind : 'AppImage' , arch }
54- if ( name . endsWith ( '.deb' ) ) return { platform : 'linux' , kind : 'Debian package' , arch }
55- if ( name . endsWith ( '.rpm' ) ) return { platform : 'linux' , kind : 'RPM package' , arch }
56+ if ( name . endsWith ( '.appimage' ) ) return { platform : 'linux' , kind : 'AppImage' , arch, rank : 1 }
57+ if ( name . endsWith ( '.deb' ) ) return { platform : 'linux' , kind : 'Debian package' , arch, rank : 0 }
58+ if ( name . endsWith ( '.rpm' ) ) return { platform : 'linux' , kind : 'RPM package' , arch, rank : 2 }
5659 return null
5760}
5861
@@ -65,12 +68,44 @@ function detectPlatform() {
6568 return null
6669}
6770
71+ /** Prefer Apple Silicon builds on arm64 Macs when both exist. */
72+ function detectMacArchPreference ( ) {
73+ const ua = navigator . userAgent || ''
74+ if ( / a r m 6 4 | a a r c h 6 4 / i. test ( ua ) ) return 'Apple Silicon'
75+ return 'Intel / AMD64'
76+ }
77+
6878function formatSize ( bytes ) {
6979 if ( ! bytes ) return ''
7080 const mb = bytes / ( 1024 * 1024 )
7181 return `${ mb . toFixed ( 1 ) } MB`
7282}
7383
84+ function pickPrimaryAsset ( grouped , platform ) {
85+ const assets = grouped [ platform ] || [ ]
86+ if ( ! assets . length ) return null
87+
88+ const archPref = platform === 'mac' ? detectMacArchPreference ( ) : null
89+ const sorted = [ ...assets ] . sort ( ( a , b ) => {
90+ if ( archPref ) {
91+ const aMatch = a . arch === archPref ? 0 : 1
92+ const bMatch = b . arch === archPref ? 0 : 1
93+ if ( aMatch !== bMatch ) return aMatch - bMatch
94+ }
95+ return ( a . rank ?? 9 ) - ( b . rank ?? 9 )
96+ } )
97+ return sorted [ 0 ]
98+ }
99+
100+ function latestDesktopRelease ( releases ) {
101+ return releases
102+ . filter ( ( r ) => r . tag_name ?. startsWith ( TAG_PREFIX ) && ! r . draft )
103+ . sort (
104+ ( a , b ) =>
105+ new Date ( b . published_at ) . getTime ( ) - new Date ( a . published_at ) . getTime ( ) ,
106+ ) [ 0 ]
107+ }
108+
74109export default function Download ( ) {
75110 const [ state , setState ] = useState ( { status : 'loading' } )
76111 const detected = useMemo ( ( ) => detectPlatform ( ) , [ ] )
@@ -87,11 +122,7 @@ export default function Download() {
87122 } )
88123 . then ( ( releases ) => {
89124 if ( cancelled ) return
90- const release = releases . find (
91- ( r ) => r . tag_name ?. startsWith ( TAG_PREFIX ) && ! r . draft ,
92- )
93- // No desktop release yet is a *different* answer from "we could not
94- // check", and the page must not blur the two into one empty state.
125+ const release = latestDesktopRelease ( releases )
95126 if ( ! release ) return setState ( { status : 'none' } )
96127 setState ( { status : 'ready' , release } )
97128 } )
@@ -112,12 +143,33 @@ export default function Download() {
112143 const meta = classify ( asset )
113144 if ( meta ) out [ meta . platform ] . push ( { ...asset , ...meta } )
114145 }
146+ for ( const key of Object . keys ( out ) ) {
147+ out [ key ] . sort ( ( a , b ) => ( a . rank ?? 9 ) - ( b . rank ?? 9 ) )
148+ }
115149 return out
116150 } , [ state ] )
117151
152+ const primary = useMemo (
153+ ( ) => ( detected ? pickPrimaryAsset ( grouped , detected ) : null ) ,
154+ [ grouped , detected ] ,
155+ )
156+
157+ const versionLabel =
158+ state . status === 'ready'
159+ ? state . release . tag_name . replace ( TAG_PREFIX , 'Version ' )
160+ : null
161+
118162 return (
119163 < div className = "min-h-screen bg-white text-gray-900" >
120164 < div className = "max-w-3xl mx-auto px-6 py-16" >
165+ < Link
166+ to = "/login"
167+ className = "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors mb-8"
168+ >
169+ < ArrowLeft className = "h-4 w-4" />
170+ Back to sign in
171+ </ Link >
172+
121173 < header className = "mb-12" >
122174 < div className = "flex items-center gap-3 mb-4" >
123175 < div className = "h-10 w-10 rounded-xl bg-gray-900 flex items-center justify-center" >
@@ -144,7 +196,10 @@ export default function Download() {
144196 tone = "error"
145197 title = "Could not reach GitHub"
146198 body = { `The download list could not be loaded (${ state . message } ). This is a problem fetching the release list, not a sign that no build exists — you can browse releases directly on GitHub.` }
147- action = { { href : `https://github.com/${ REPO } /releases` , label : 'Open releases on GitHub' } }
199+ action = { {
200+ href : `https://github.com/${ REPO } /releases` ,
201+ label : 'Open releases on GitHub' ,
202+ } }
148203 />
149204 ) }
150205
@@ -153,21 +208,38 @@ export default function Download() {
153208 tone = "info"
154209 title = "No desktop build published yet"
155210 body = "The release list loaded fine — there is simply no desktop-v* release with attached installers. Builds are produced by the desktop-release workflow when a desktop-v* tag is pushed."
156- action = { { href : `https://github.com/${ REPO } /releases` , label : 'Open releases on GitHub' } }
211+ action = { {
212+ href : `https://github.com/${ REPO } /releases` ,
213+ label : 'Open releases on GitHub' ,
214+ } }
157215 />
158216 ) }
159217
160218 { state . status === 'ready' && (
161219 < >
162- < div className = "flex items-baseline gap-3 mb-8 pb-4 border-b border-gray-200" >
163- < span className = "text-sm font-semibold text-gray-900" >
164- { state . release . tag_name . replace ( TAG_PREFIX , 'Version ' ) }
165- </ span >
220+ < div className = "flex flex-wrap items-baseline gap-3 mb-6 pb-4 border-b border-gray-200" >
221+ < span className = "text-sm font-semibold text-gray-900" > { versionLabel } </ span >
166222 < span className = "text-sm text-gray-400" >
167223 released { new Date ( state . release . published_at ) . toLocaleDateString ( ) }
168224 </ span >
169225 </ div >
170226
227+ { primary && (
228+ < div className = "mb-10" >
229+ < a
230+ href = { primary . browser_download_url }
231+ className = "inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-full bg-gray-900 px-6 py-3.5 text-sm font-semibold text-white shadow-lg transition-all hover:bg-gray-800 active:scale-[0.98]"
232+ >
233+ < DownloadIcon className = "h-4 w-4" />
234+ Download for { PLATFORMS [ detected ] . label }
235+ { primary . arch ? ` (${ primary . arch } )` : '' }
236+ </ a >
237+ < p className = "mt-2 text-xs text-gray-400" >
238+ { primary . kind } · { primary . name } · { formatSize ( primary . size ) }
239+ </ p >
240+ </ div >
241+ ) }
242+
171243 { Object . entries ( PLATFORMS ) . map ( ( [ key , meta ] ) => {
172244 const assets = grouped [ key ] || [ ]
173245 if ( ! assets . length ) return null
@@ -190,6 +262,13 @@ export default function Download() {
190262 >
191263 desktop/README.md
192264 </ a >
265+ . All installers for this release are also on{ ' ' }
266+ < a
267+ className = "underline hover:text-gray-600 transition-colors"
268+ href = { state . release . html_url }
269+ >
270+ GitHub
271+ </ a >
193272 .
194273 </ p >
195274 </ >
@@ -227,7 +306,9 @@ function PlatformSection({ platform, assets, highlight, showMacNote }) {
227306 } `}
228307 >
229308 < span className = "flex items-center gap-3 min-w-0" >
230- < Package className = { `h-4 w-4 shrink-0 ${ highlight ? 'text-gray-300' : 'text-gray-400' } ` } />
309+ < Package
310+ className = { `h-4 w-4 shrink-0 ${ highlight ? 'text-gray-300' : 'text-gray-400' } ` }
311+ />
231312 < span className = "min-w-0" >
232313 < span className = "block text-sm font-medium truncate" >
233314 { asset . kind }
@@ -251,7 +332,9 @@ function PlatformSection({ platform, assets, highlight, showMacNote }) {
251332 < p className = "mt-3 text-xs text-gray-400 leading-relaxed" >
252333 Builds are unsigned unless signing credentials are configured, so the first
253334 launch needs < span className = "text-gray-600" > right-click → Open</ span > (or{ ' ' }
254- < code className = "text-gray-600" > xattr -dr com.apple.quarantine /Applications/DeepSQL.app</ code >
335+ < code className = "text-gray-600" >
336+ xattr -dr com.apple.quarantine /Applications/DeepSQL.app
337+ </ code >
255338 ).
256339 </ p >
257340 ) }
0 commit comments