1
+ import { type BBox } from 'geojson'
2
+ import { Download as DownloadIcon } from 'lucide-react'
3
+ import { useEffect , useState } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { useMap } from 'react-map-gl'
6
+ import { Form , useNavigation , useActionData } from 'react-router'
7
+ import { Button } from '../ui/button'
8
+ import { Checkbox } from '../ui/checkbox'
9
+ import {
10
+ Dialog ,
11
+ DialogContent ,
12
+ DialogDescription ,
13
+ DialogFooter ,
14
+ DialogHeader ,
15
+ DialogTitle ,
16
+ DialogTrigger ,
17
+ } from '../ui/dialog'
18
+ import { Input } from '../ui/input'
19
+ import { Label } from '../ui/label'
20
+ import {
21
+ Select ,
22
+ SelectContent ,
23
+ SelectItem ,
24
+ SelectTrigger ,
25
+ SelectValue ,
26
+ } from '../ui/select'
27
+ import { toast } from '../ui/use-toast'
28
+
29
+ // Custom Loading Animation Component
30
+ const PulsingDownloadAnimation = ( ) => {
31
+ const { t } = useTranslation ( 'download' )
32
+ return (
33
+ < div className = "flex items-center justify-center" >
34
+ < div className = "relative" >
35
+ { /* Main download icon */ }
36
+ < div className = "text-blue-600 animate-bounce" >
37
+ < svg xmlns = "http://www.w3.org/2000/svg" width = "24" height = "24" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
38
+ < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" > </ path >
39
+ < polyline points = "7 10 12 15 17 10" > </ polyline >
40
+ < line x1 = "12" y1 = "15" x2 = "12" y2 = "3" > </ line >
41
+ </ svg >
42
+ </ div >
43
+
44
+ { /* Animated ripples */ }
45
+ < div className = "absolute top-0 left-0 h-full w-full animate-ping rounded-full border-2 border-blue-400 opacity-75" > </ div >
46
+ < div className = "absolute top-0 left-0 h-full w-full animate-pulse rounded-full border border-blue-300 opacity-75" style = { { animationDelay : "0.3s" } } > </ div >
47
+
48
+ { /* Small data points moving toward the download icon */ }
49
+ < div className = "absolute -top-4 -left-4 h-2 w-2 animate-ping rounded-full bg-blue-500" style = { { animationDelay : "0.1s" } } > </ div >
50
+ < div className = "absolute -top-4 left-0 h-2 w-2 animate-ping rounded-full bg-blue-500" style = { { animationDelay : "0.4s" } } > </ div >
51
+ < div className = "absolute -top-4 left-4 h-2 w-2 animate-ping rounded-full bg-blue-500" style = { { animationDelay : "0.7s" } } > </ div >
52
+ </ div >
53
+ < span className = "ml-3 text-blue-600 font-medium" > { t ( 'processingData' ) } </ span >
54
+ </ div >
55
+ ) ;
56
+ } ;
57
+
58
+ // Data Ready Animation
59
+ const DataReadyAnimation = ( ) => {
60
+ const { t } = useTranslation ( 'download' )
61
+ return (
62
+ < div className = "flex items-center justify-center text-light-blue" >
63
+ < svg xmlns = "http://www.w3.org/2000/svg" width = "24" height = "24" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = "animate-pulse" >
64
+ < path d = "M22 11.08V12a10 10 0 1 1-5.93-9.14" > </ path >
65
+ < polyline points = "22 4.3 12 14.01 9 11.01" > </ polyline >
66
+ </ svg >
67
+ < span className = "ml-2" > { t ( 'readyToDownload' ) } </ span >
68
+ </ div >
69
+ ) ;
70
+ } ;
71
+
72
+ export default function Download ( props : any ) {
73
+ const { t } = useTranslation ( 'download' )
74
+ const actionData = useActionData ( )
75
+ const navigation = useNavigation ( )
76
+ const isLoading = navigation . state === "submitting"
77
+ const devices = props . devices . features || [ ]
78
+ const { osem : mapRef } = useMap ( )
79
+
80
+ const [ isDownloadReady , setIsDownloadReady ] = useState ( false )
81
+ const [ showReadyAnimation , setShowReadyAnimation ] = useState ( false )
82
+ const [ errorMessage , setErrorMessage ] = useState < string | null > ( null )
83
+
84
+ // Update download ready state when actionData changes
85
+ useEffect ( ( ) => {
86
+ if ( actionData && actionData . error ) {
87
+ setErrorMessage ( actionData . error )
88
+ } else {
89
+ setErrorMessage ( null )
90
+ // Only set download ready if there's no error
91
+ if ( actionData ) {
92
+ setIsDownloadReady ( true )
93
+ setShowReadyAnimation ( true )
94
+ }
95
+ }
96
+ } , [ actionData ] )
97
+
98
+ // Reset download ready state when format changes
99
+ const [ format , setFormat ] = useState < string > ( 'csv' )
100
+ const handleFormatChange = ( value : string ) => {
101
+ setFormat ( value )
102
+ setShowReadyAnimation ( false )
103
+ setIsDownloadReady ( false )
104
+ setErrorMessage ( null ) ;
105
+ }
106
+
107
+ const [ downloadStarted , setDownloadStarted ] = useState ( false )
108
+
109
+ // Add this function to handle download start
110
+ const handleDownloadStart = ( ) => {
111
+ const Delay = 3500 ;
112
+ setDownloadStarted ( true )
113
+ setShowReadyAnimation ( false )
114
+ toast ( {
115
+ description : t ( 'toast' ) ,
116
+ duration : Delay ,
117
+ variant :"success"
118
+ } )
119
+
120
+ // Reset the download started state after a delay
121
+ setTimeout ( ( ) => {
122
+ setDownloadStarted ( false )
123
+ setOpen ( false )
124
+ } , Delay )
125
+ }
126
+
127
+ // Filter devices inside the current bounds
128
+ const bounds = mapRef ?. getMap ( ) . getBounds ( ) . toArray ( ) . flat ( ) as BBox ?? undefined ;
129
+ const devicesInBounds =
130
+ bounds && bounds . length === 4
131
+ ? devices . filter ( ( device : any ) => {
132
+ // Ensure the device has coordinates
133
+
134
+ if ( ! device . geometry || ! device . geometry . coordinates ) return false
135
+
136
+ const [ longitude , latitude ] = device . geometry . coordinates
137
+
138
+ // Check if bounds are defined properly
139
+ const [ minLon , minLat ] = bounds . slice ( 0 , 2 ) // [minLongitude, minLatitude]
140
+ const [ maxLon , maxLat ] = bounds . slice ( 2 , 4 ) // [maxLongitude, maxLatitude]
141
+
142
+ return (
143
+ longitude >= minLon &&
144
+ longitude <= maxLon &&
145
+ latitude >= minLat &&
146
+ latitude <= maxLat
147
+ )
148
+ } )
149
+ : [ ]
150
+
151
+ let deviceIDs : Array < string > = [ ] ;
152
+ devicesInBounds . map ( ( device : any ) => {
153
+ deviceIDs . push ( device . properties . id ) ;
154
+ } )
155
+
156
+ const [ aggregate , setAggregate ] = useState < string > ( '10m' )
157
+ const [ fields , setFields ] = useState ( {
158
+ title : true ,
159
+ unit : true ,
160
+ value : true ,
161
+ timestamp : true ,
162
+ } )
163
+ const [ open , setOpen ] = useState ( false )
164
+ const handleFieldChange = ( field : keyof typeof fields ) => {
165
+ setFields ( ( prev ) => ( { ...prev , [ field ] : ! prev [ field ] } ) )
166
+ setIsDownloadReady ( false )
167
+ setErrorMessage ( null ) ;
168
+ setShowReadyAnimation ( false ) ;
169
+ }
170
+
171
+ return (
172
+ < Dialog open = { open } onOpenChange = { ( ) => {
173
+ setOpen ( ! open ) ;
174
+ setIsDownloadReady ( false ) ;
175
+ setErrorMessage ( null ) ;
176
+ setShowReadyAnimation ( false ) ; } } >
177
+ < DialogTrigger asChild className = "pointer-events-auto" onClick = { ( ) => setOpen ( true ) } >
178
+ < div className = "pointer-events-auto box-border h-10 w-10" >
179
+ < button
180
+ type = "button"
181
+ className = "h-10 w-10 rounded-full border border-green-700 bg-white text-center text-black hover:bg-slate-50 transition-all hover:shadow-md"
182
+ aria-label = { t ( 'download' ) }
183
+ >
184
+ < DownloadIcon className = "mx-auto h-6 w-6" />
185
+ </ button >
186
+ </ div >
187
+ </ DialogTrigger >
188
+ < DialogContent className = "max-w-1/2" style = { { maxHeight : '100vh' , overflowY : 'auto' } } >
189
+ < DialogHeader >
190
+ < DialogTitle > { t ( 'downloadOptions' ) } </ DialogTitle >
191
+ < DialogDescription >
192
+ { t ( 'downloadDescription' ) }
193
+ </ DialogDescription >
194
+ </ DialogHeader >
195
+ < div className = "grid gap-4 py-3" >
196
+ < Form action = { '/explore' } method = 'post' >
197
+ < div className = "grid gap-2" >
198
+ < div className = "flex justify-between items-center" >
199
+ < Label htmlFor = 'devices' > { t ( 'devices' ) } </ Label >
200
+ < span className = "text-sm text-blue-600 font-medium" > { deviceIDs . length } 📡 { t ( 'selected' ) } </ span >
201
+ </ div >
202
+ < Input type = "text" id = 'devices' name = 'devices' value = { deviceIDs } readOnly />
203
+ < Label htmlFor = "format" > { t ( 'format' ) } </ Label >
204
+ < Select value = { format } onValueChange = { handleFormatChange } name = 'format' >
205
+ < SelectTrigger id = "format" >
206
+ < SelectValue placeholder = { t ( 'selectFormat' ) } />
207
+ </ SelectTrigger >
208
+ < SelectContent >
209
+ < SelectItem value = "csv" > CSV</ SelectItem >
210
+ < SelectItem value = "json" > JSON</ SelectItem >
211
+ < SelectItem value = "txt" > { t ( 'text' ) } </ SelectItem >
212
+ </ SelectContent >
213
+ </ Select >
214
+ < Label htmlFor = "aggregate" > { t ( 'aggregateTo' ) } </ Label >
215
+ < Select value = { aggregate } onValueChange = { ( value ) => { setAggregate ( value ) ; setIsDownloadReady ( false ) ; setErrorMessage ( null ) ; setShowReadyAnimation ( false ) ; } } name = 'aggregate' >
216
+ < SelectTrigger id = "aggregate" >
217
+ < SelectValue placeholder = { t ( 'aggregateTo' ) } />
218
+ </ SelectTrigger >
219
+ < SelectContent >
220
+ < SelectItem value = "raw" > { t ( 'rawData' ) } </ SelectItem >
221
+ < SelectItem value = "10m" > { t ( '10minutes' ) } </ SelectItem >
222
+ < SelectItem value = "1h" > { t ( '1hour' ) } </ SelectItem >
223
+ < SelectItem value = "1d" > { t ( '1day' ) } </ SelectItem >
224
+ < SelectItem value = "1m" > { t ( '1month' ) } </ SelectItem >
225
+ < SelectItem value = "1y" > { t ( '1year' ) } </ SelectItem >
226
+ </ SelectContent >
227
+ </ Select >
228
+ </ div >
229
+ < div className = "grid gap-2 mt-4" >
230
+ < fieldset className = "grid grid-cols-2 gap-3" id = 'fields' >
231
+ < legend > { t ( 'fieldsToInclude' ) } </ legend >
232
+ < div className = "flex items-center space-x-2" >
233
+ < Checkbox
234
+ id = "title"
235
+ checked = { fields . title }
236
+ onCheckedChange = { ( ) => handleFieldChange ( 'title' ) }
237
+ name = "title"
238
+ />
239
+ < Label htmlFor = "title" className = "cursor-pointer" >
240
+ { t ( 'title' ) }
241
+ </ Label >
242
+ </ div >
243
+ < div className = "flex items-center space-x-2" >
244
+ < Checkbox
245
+ id = "unit"
246
+ checked = { fields . unit }
247
+ onCheckedChange = { ( ) => handleFieldChange ( 'unit' ) }
248
+ name = "unit"
249
+ />
250
+ < Label htmlFor = "unit" className = "cursor-pointer" >
251
+ { t ( 'unit' ) }
252
+ </ Label >
253
+ </ div >
254
+ < div className = "flex items-center space-x-2" >
255
+ < Checkbox
256
+ id = "value"
257
+ checked = { fields . value }
258
+ onCheckedChange = { ( ) => handleFieldChange ( 'value' ) }
259
+ name = "value"
260
+ />
261
+ < Label htmlFor = "value" className = "cursor-pointer" >
262
+ { t ( 'value' ) }
263
+ </ Label >
264
+ </ div >
265
+ < div className = "flex items-center space-x-2" >
266
+ < Checkbox
267
+ id = "timestamp"
268
+ checked = { fields . timestamp }
269
+ onCheckedChange = { ( ) => handleFieldChange ( 'timestamp' ) }
270
+ name = "timestamp"
271
+ />
272
+ < Label htmlFor = "timestamp" className = "cursor-pointer" >
273
+ { t ( 'timestamp' ) }
274
+ </ Label >
275
+ </ div >
276
+ </ fieldset >
277
+ </ div >
278
+
279
+ < div className = "h-16 flex items-center justify-center mt-2" >
280
+ { isLoading ? (
281
+ < PulsingDownloadAnimation />
282
+ ) : showReadyAnimation ? (
283
+ < DataReadyAnimation />
284
+ ) : null }
285
+ </ div >
286
+ { errorMessage && (
287
+ < div className = "p-2 bg-red-100 border border-red-300 text-red-700 rounded flex items-center" >
288
+ < svg xmlns = "http://www.w3.org/2000/svg" width = "50" height = "50" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = "mr-2 text-red-500 animate-pulse" >
289
+ < circle cx = "12" cy = "12" r = "10" > </ circle >
290
+ < line x1 = "12" y1 = "8" x2 = "12" y2 = "12" > </ line >
291
+ < line x1 = "12" y1 = "16" x2 = "12.01" y2 = "16" > </ line >
292
+ </ svg >
293
+ < p > { t ( 'error' ) } < a href = { actionData ?. link } className = 'text-blue-100' target = '_blank' > { t ( 'clickHere' ) } </ a > { " " } { t ( 'toGoToArchive' ) } </ p >
294
+ </ div >
295
+ ) }
296
+ < DialogFooter >
297
+ < div className = "w-full mt-4 flex items-center justify-center space-x-4" >
298
+ < Button
299
+ type = "submit"
300
+ className = "bg-blue-100 hover:bg-blue-200 transition-colors text-dark"
301
+ disabled = { isLoading || deviceIDs . length === 0 }
302
+ >
303
+ { isLoading ? t ( 'processing' ) : t ( 'generateFile' ) }
304
+ </ Button >
305
+ { actionData && isDownloadReady ? (
306
+ < a
307
+ href = { actionData . href }
308
+ download = { actionData . download }
309
+ className = { `px-4 py-2 ${ downloadStarted ? 'bg-blue-300 animate-pulse' : 'bg-green-100' } text-dark rounded hover:bg-green-400 transition-colors flex items-center` }
310
+ onClick = { handleDownloadStart }
311
+ >
312
+ < svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = "mr-2" >
313
+ < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" > </ path >
314
+ < polyline points = "7 10 12 15 17 10" > </ polyline >
315
+ < line x1 = "12" y1 = "15" x2 = "12" y2 = "3" > </ line >
316
+ </ svg >
317
+ { downloadStarted ? t ( 'downloading' ) : `${ format . toUpperCase ( ) } ${ t ( 'data' ) } ${ t ( 'download' ) } ` }
318
+ </ a >
319
+ ) : null }
320
+ </ div >
321
+ </ DialogFooter >
322
+ </ Form >
323
+ </ div >
324
+ </ DialogContent >
325
+ </ Dialog >
326
+ )
327
+ }
0 commit comments