@haus-tech/product-import-export-plugin 3.0.29-0 → 3.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/api/product-export.controller.d.ts +19 -1
- package/src/api/product-export.controller.js +145 -10
- package/src/api/product-export.controller.js.map +1 -1
- package/src/dashboard/bulk-export.tsx +41 -0
- package/src/dashboard/export-dialog.tsx +287 -0
- package/src/dashboard/export.tsx +14 -326
- package/src/dashboard/exported-list.tsx +234 -0
- package/src/dashboard/import.tsx +92 -136
- package/src/dashboard/index.tsx +56 -3
- package/src/events/product-exported.event.d.ts +16 -0
- package/src/events/product-exported.event.js +19 -0
- package/src/events/product-exported.event.js.map +1 -0
- package/src/handlers/product-exported.handler.d.ts +2 -0
- package/src/handlers/product-exported.handler.js +15 -0
- package/src/handlers/product-exported.handler.js.map +1 -0
- package/src/product-import-export.plugin.js +2 -0
- package/src/product-import-export.plugin.js.map +1 -1
- package/src/services/product-export-queue.service.d.ts +31 -0
- package/src/services/product-export-queue.service.js +135 -0
- package/src/services/product-export-queue.service.js.map +1 -0
- package/src/services/product-export.service.d.ts +1 -0
- package/src/services/product-export.service.js +41 -2
- package/src/services/product-export.service.js.map +1 -1
package/src/dashboard/export.tsx
CHANGED
|
@@ -1,337 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
DialogContent,
|
|
5
|
-
DialogDescription,
|
|
6
|
-
DialogFooter,
|
|
7
|
-
DialogHeader,
|
|
8
|
-
DialogTitle,
|
|
9
|
-
Input,
|
|
10
|
-
Label,
|
|
11
|
-
RadioGroup,
|
|
12
|
-
RadioGroupItem,
|
|
13
|
-
DataTableBulkActionItem,
|
|
14
|
-
} from '@vendure/dashboard'
|
|
15
|
-
import { DownloadIcon } from 'lucide-react'
|
|
16
|
-
import { useState, useEffect } from 'react'
|
|
17
|
-
import { toast } from 'sonner'
|
|
18
|
-
import {
|
|
19
|
-
getServerLocation,
|
|
20
|
-
getChannelHeader,
|
|
21
|
-
type PluginInitOptions,
|
|
22
|
-
type ExportFields,
|
|
23
|
-
type ProductFields,
|
|
24
|
-
type VariantFields,
|
|
25
|
-
} from './utils'
|
|
1
|
+
import { Button } from '@vendure/dashboard'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { ExportDialog } from './export-dialog'
|
|
26
4
|
|
|
27
|
-
|
|
28
|
-
function ExportDialog({
|
|
29
|
-
open,
|
|
30
|
-
onOpenChange,
|
|
31
|
-
productIds,
|
|
32
|
-
}: {
|
|
33
|
-
open: boolean
|
|
34
|
-
onOpenChange: (open: boolean) => void
|
|
35
|
-
productIds: string[]
|
|
36
|
-
}) {
|
|
37
|
-
const [fileName, setFileName] = useState('')
|
|
38
|
-
const [customFields, setCustomFields] = useState<string[]>([])
|
|
39
|
-
const [selectedFields, setSelectedFields] = useState<string[]>([])
|
|
40
|
-
const [exportAssetsAs, setExportAssetsAs] = useState<'url' | 'json'>('url')
|
|
41
|
-
const [selectedExportFields, setSelectedExportFields] = useState<ExportFields>([])
|
|
42
|
-
const [availableExportFields] = useState<ExportFields>([
|
|
43
|
-
'productId',
|
|
44
|
-
'name',
|
|
45
|
-
'slug',
|
|
46
|
-
'description',
|
|
47
|
-
'assets',
|
|
48
|
-
'facets',
|
|
49
|
-
'optionGroups',
|
|
50
|
-
'sku',
|
|
51
|
-
'optionValues',
|
|
52
|
-
'price',
|
|
53
|
-
'taxCategory',
|
|
54
|
-
'stockOnHand',
|
|
55
|
-
'trackInventory',
|
|
56
|
-
'variantAssets',
|
|
57
|
-
'variantFacets',
|
|
58
|
-
'enabled',
|
|
59
|
-
])
|
|
60
|
-
const [config, setConfig] = useState<PluginInitOptions['exportOptions'] | null>(null)
|
|
61
|
-
const [toggleAllChecked, setToggleAllChecked] = useState(false)
|
|
62
|
-
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
const fetchConfig = async () => {
|
|
65
|
-
try {
|
|
66
|
-
const serverPath = getServerLocation()
|
|
67
|
-
const [configRes, customFieldsRes] = await Promise.all([
|
|
68
|
-
fetch(`${serverPath}/product-import-export/config`, {
|
|
69
|
-
method: 'GET',
|
|
70
|
-
headers: {
|
|
71
|
-
'Content-Type': 'application/json',
|
|
72
|
-
...getChannelHeader(),
|
|
73
|
-
},
|
|
74
|
-
credentials: 'include',
|
|
75
|
-
}),
|
|
76
|
-
fetch(`${serverPath}/product-export/custom-fields`, {
|
|
77
|
-
method: 'POST',
|
|
78
|
-
headers: {
|
|
79
|
-
'Content-Type': 'application/json',
|
|
80
|
-
...getChannelHeader(),
|
|
81
|
-
},
|
|
82
|
-
credentials: 'include',
|
|
83
|
-
body: JSON.stringify(productIds),
|
|
84
|
-
}),
|
|
85
|
-
])
|
|
86
|
-
|
|
87
|
-
const configData = await configRes.json()
|
|
88
|
-
const customFieldsData = await customFieldsRes.json()
|
|
89
|
-
setConfig(configData.exportOptions)
|
|
90
|
-
setCustomFields(customFieldsData.map((field: { name: string }) => field.name))
|
|
91
|
-
setSelectedFields([...customFieldsData.map((field: { name: string }) => field.name)])
|
|
92
|
-
setExportAssetsAs(
|
|
93
|
-
configData.exportOptions?.defaultExportAssetsAs ||
|
|
94
|
-
configData.exportOptions?.exportAssetsAsOptions?.[0] ||
|
|
95
|
-
'url',
|
|
96
|
-
)
|
|
97
|
-
if (
|
|
98
|
-
configData.exportOptions?.defaultExportFields ||
|
|
99
|
-
configData.exportOptions?.requiredExportFields
|
|
100
|
-
) {
|
|
101
|
-
setSelectedExportFields([
|
|
102
|
-
...(configData.exportOptions.defaultExportFields || []),
|
|
103
|
-
...(configData.exportOptions.requiredExportFields || []),
|
|
104
|
-
])
|
|
105
|
-
}
|
|
106
|
-
} catch (error) {
|
|
107
|
-
console.error('Failed to fetch config:', error)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (open && productIds.length > 0) {
|
|
112
|
-
fetchConfig()
|
|
113
|
-
}
|
|
114
|
-
}, [open, productIds])
|
|
115
|
-
|
|
116
|
-
const toggleFieldSelection = (fieldName: string) => {
|
|
117
|
-
setSelectedFields((prev) =>
|
|
118
|
-
prev.includes(fieldName) ? prev.filter((f) => f !== fieldName) : [...prev, fieldName],
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const toggleExportFieldSelection = (fieldName: ProductFields | VariantFields) => {
|
|
123
|
-
setSelectedExportFields((prev) =>
|
|
124
|
-
prev.includes(fieldName) ? prev.filter((f) => f !== fieldName) : [...prev, fieldName],
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const toggleSelectAll = (checked: boolean) => {
|
|
129
|
-
setToggleAllChecked(checked)
|
|
130
|
-
if (checked) {
|
|
131
|
-
setSelectedExportFields([...availableExportFields])
|
|
132
|
-
setSelectedFields([...customFields])
|
|
133
|
-
} else {
|
|
134
|
-
setSelectedExportFields([...(config?.requiredExportFields || [])])
|
|
135
|
-
setSelectedFields([])
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const handleExport = async () => {
|
|
140
|
-
const trimmedFileName = fileName.trim()
|
|
141
|
-
const finalFileName = trimmedFileName || config?.defaultFileName || 'products_export.csv'
|
|
142
|
-
|
|
143
|
-
toast.info(`Exporting ${productIds.length} products to ${finalFileName}`)
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const serverPath = getServerLocation()
|
|
147
|
-
const res = await fetch(
|
|
148
|
-
`${serverPath}/product-export/export?fileName=${encodeURIComponent(finalFileName)}&customFields=${encodeURIComponent(selectedFields.join(','))}&exportAssetsAs=${exportAssetsAs}&selectedExportFields=${encodeURIComponent(selectedExportFields.join(','))}`,
|
|
149
|
-
{
|
|
150
|
-
method: 'POST',
|
|
151
|
-
headers: {
|
|
152
|
-
'Content-Type': 'application/json',
|
|
153
|
-
...getChannelHeader(),
|
|
154
|
-
},
|
|
155
|
-
credentials: 'include',
|
|
156
|
-
body: JSON.stringify(productIds),
|
|
157
|
-
},
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
if (!res.ok) {
|
|
161
|
-
const json = await res.json()
|
|
162
|
-
throw new Error(json?.message || 'Export failed')
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
console.log('res', res)
|
|
166
|
-
const header = res.headers.get('Content-Disposition')
|
|
167
|
-
|
|
168
|
-
const parts = header!.split(';')
|
|
169
|
-
const filename = parts[1].split('=')[1]
|
|
170
|
-
const blob = await res.blob()
|
|
171
|
-
|
|
172
|
-
const blobUrl = window.URL.createObjectURL(blob)
|
|
173
|
-
const a = document.createElement('a')
|
|
174
|
-
document.body.appendChild(a)
|
|
175
|
-
a.setAttribute('hidden', 'true')
|
|
176
|
-
a.href = blobUrl
|
|
177
|
-
a.download = filename
|
|
178
|
-
a.setAttribute('target', '_blank')
|
|
179
|
-
a.click()
|
|
180
|
-
|
|
181
|
-
onOpenChange(false)
|
|
182
|
-
toast.success('Products exported successfully')
|
|
183
|
-
} catch (error: any) {
|
|
184
|
-
console.error(error)
|
|
185
|
-
toast.error(error.message || 'Failed to export products')
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return (
|
|
190
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
191
|
-
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
192
|
-
<DialogHeader>
|
|
193
|
-
<DialogTitle>Export products to CSV</DialogTitle>
|
|
194
|
-
<DialogDescription>Configure export options</DialogDescription>
|
|
195
|
-
</DialogHeader>
|
|
196
|
-
|
|
197
|
-
<div className="space-y-4">
|
|
198
|
-
<div>
|
|
199
|
-
<Label htmlFor="file-name">File name (optional)</Label>
|
|
200
|
-
<Input
|
|
201
|
-
id="file-name"
|
|
202
|
-
value={fileName}
|
|
203
|
-
onChange={(e) => setFileName(e.target.value)}
|
|
204
|
-
placeholder={config?.defaultFileName || 'products_export.csv'}
|
|
205
|
-
className="mt-1"
|
|
206
|
-
/>
|
|
207
|
-
</div>
|
|
208
|
-
|
|
209
|
-
<div className="flex items-center space-x-2">
|
|
210
|
-
<input
|
|
211
|
-
type="checkbox"
|
|
212
|
-
id="select-all"
|
|
213
|
-
checked={toggleAllChecked}
|
|
214
|
-
onChange={(e) => toggleSelectAll(e.target.checked)}
|
|
215
|
-
/>
|
|
216
|
-
<Label htmlFor="select-all" className="font-normal">
|
|
217
|
-
Select all
|
|
218
|
-
</Label>
|
|
219
|
-
</div>
|
|
220
|
-
|
|
221
|
-
<div>
|
|
222
|
-
<Label>Fields to export</Label>
|
|
223
|
-
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
224
|
-
{availableExportFields.map((field) => (
|
|
225
|
-
<div key={field} className="flex items-center space-x-2">
|
|
226
|
-
<input
|
|
227
|
-
type="checkbox"
|
|
228
|
-
id={`field-${field}`}
|
|
229
|
-
checked={selectedExportFields.includes(field)}
|
|
230
|
-
onChange={() => toggleExportFieldSelection(field)}
|
|
231
|
-
disabled={config?.requiredExportFields?.includes(field)}
|
|
232
|
-
/>
|
|
233
|
-
<Label htmlFor={`field-${field}`} className="font-normal">
|
|
234
|
-
{field}
|
|
235
|
-
</Label>
|
|
236
|
-
</div>
|
|
237
|
-
))}
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
|
|
241
|
-
{customFields.length > 0 && (
|
|
242
|
-
<div>
|
|
243
|
-
<Label>Custom fields to include in the export</Label>
|
|
244
|
-
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
245
|
-
{customFields.map((field) => (
|
|
246
|
-
<div key={field} className="flex items-center space-x-2">
|
|
247
|
-
<input
|
|
248
|
-
type="checkbox"
|
|
249
|
-
id={`custom-${field}`}
|
|
250
|
-
checked={selectedFields.includes(field)}
|
|
251
|
-
onChange={() => toggleFieldSelection(field)}
|
|
252
|
-
/>
|
|
253
|
-
<Label htmlFor={`custom-${field}`} className="font-normal">
|
|
254
|
-
{field}
|
|
255
|
-
</Label>
|
|
256
|
-
</div>
|
|
257
|
-
))}
|
|
258
|
-
</div>
|
|
259
|
-
</div>
|
|
260
|
-
)}
|
|
261
|
-
|
|
262
|
-
<div>
|
|
263
|
-
<Label>Export assets as:</Label>
|
|
264
|
-
<RadioGroup
|
|
265
|
-
value={exportAssetsAs}
|
|
266
|
-
onValueChange={(val) => setExportAssetsAs(val as 'url' | 'json')}
|
|
267
|
-
>
|
|
268
|
-
{config?.exportAssetsAsOptions?.includes('url') && (
|
|
269
|
-
<div className="flex items-center space-x-2">
|
|
270
|
-
<RadioGroupItem value="url" id="assets-url" />
|
|
271
|
-
<Label htmlFor="assets-url" className="font-normal">
|
|
272
|
-
URL
|
|
273
|
-
</Label>
|
|
274
|
-
</div>
|
|
275
|
-
)}
|
|
276
|
-
{config?.exportAssetsAsOptions?.includes('json') && (
|
|
277
|
-
<div className="flex items-center space-x-2">
|
|
278
|
-
<RadioGroupItem value="json" id="assets-json" />
|
|
279
|
-
<Label htmlFor="assets-json" className="font-normal">
|
|
280
|
-
JSON
|
|
281
|
-
</Label>
|
|
282
|
-
</div>
|
|
283
|
-
)}
|
|
284
|
-
</RadioGroup>
|
|
285
|
-
</div>
|
|
286
|
-
</div>
|
|
287
|
-
|
|
288
|
-
<DialogFooter>
|
|
289
|
-
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
290
|
-
Cancel
|
|
291
|
-
</Button>
|
|
292
|
-
<Button type="button" onClick={handleExport}>
|
|
293
|
-
Export
|
|
294
|
-
</Button>
|
|
295
|
-
</DialogFooter>
|
|
296
|
-
</DialogContent>
|
|
297
|
-
</Dialog>
|
|
298
|
-
)
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Export Bulk Action Component for Product List
|
|
302
|
-
export function ExportProductsBulkAction({
|
|
303
|
-
selection,
|
|
304
|
-
table,
|
|
305
|
-
}: {
|
|
306
|
-
selection: Array<{ id: string }>
|
|
307
|
-
table: any
|
|
308
|
-
}) {
|
|
5
|
+
export function ProductExportBlock() {
|
|
309
6
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
310
|
-
const productIds = selection.map((product) => product.id)
|
|
311
7
|
|
|
312
|
-
const
|
|
313
|
-
setDialogOpen(
|
|
314
|
-
table.resetRowSelection()
|
|
8
|
+
const handleExport = () => {
|
|
9
|
+
setDialogOpen(true)
|
|
315
10
|
}
|
|
316
11
|
|
|
317
12
|
return (
|
|
318
|
-
|
|
319
|
-
<
|
|
320
|
-
onClick={
|
|
321
|
-
|
|
322
|
-
icon={DownloadIcon}
|
|
323
|
-
/>
|
|
13
|
+
<div className="space-y-4">
|
|
14
|
+
<div>
|
|
15
|
+
<Button onClick={handleExport}>Export all products</Button>
|
|
16
|
+
</div>
|
|
324
17
|
<ExportDialog
|
|
325
18
|
open={dialogOpen}
|
|
326
|
-
onOpenChange={
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
} else {
|
|
330
|
-
handleClose()
|
|
331
|
-
}
|
|
332
|
-
}}
|
|
333
|
-
productIds={productIds}
|
|
19
|
+
onOpenChange={setDialogOpen}
|
|
20
|
+
productIds={[]}
|
|
21
|
+
isExportAll={true}
|
|
334
22
|
/>
|
|
335
|
-
|
|
23
|
+
</div>
|
|
336
24
|
)
|
|
337
25
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from '@vendure/dashboard'
|
|
10
|
+
import { getServerLocation, getChannelHeader } from './utils'
|
|
11
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
12
|
+
import { DownloadIcon, ChevronLeftIcon, ChevronRightIcon, TrashIcon } from 'lucide-react'
|
|
13
|
+
import { toast } from 'sonner'
|
|
14
|
+
|
|
15
|
+
interface ExportedFile {
|
|
16
|
+
fileName: string
|
|
17
|
+
size: number
|
|
18
|
+
created: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ExportedList() {
|
|
22
|
+
const [exportedFiles, setExportedFiles] = useState<ExportedFile[]>([])
|
|
23
|
+
const [error, setError] = useState<string | null>(null)
|
|
24
|
+
const [currentPage, setCurrentPage] = useState(1)
|
|
25
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
26
|
+
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
|
|
27
|
+
const itemsPerPage = 10
|
|
28
|
+
|
|
29
|
+
const getExportFiles = async () => {
|
|
30
|
+
const serverPath = getServerLocation()
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${serverPath}/product-export/exported-files`, {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
...getChannelHeader(),
|
|
37
|
+
},
|
|
38
|
+
credentials: 'include',
|
|
39
|
+
})
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
setError('Could not get exported files, please try again later')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const data = await res.json()
|
|
45
|
+
setExportedFiles(data)
|
|
46
|
+
setError(null)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
setError('Could not get exported files, please try again later')
|
|
49
|
+
console.error('Error getting exported files', error)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
getExportFiles()
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
// Pagination calculations
|
|
58
|
+
const paginatedFiles = useMemo(() => {
|
|
59
|
+
const startIndex = (currentPage - 1) * itemsPerPage
|
|
60
|
+
const endIndex = startIndex + itemsPerPage
|
|
61
|
+
return exportedFiles.slice(startIndex, endIndex)
|
|
62
|
+
}, [exportedFiles, currentPage])
|
|
63
|
+
|
|
64
|
+
const totalPages = Math.ceil(exportedFiles.length / itemsPerPage)
|
|
65
|
+
|
|
66
|
+
const downLoadFile = async (fileName: string) => {
|
|
67
|
+
const serverPath = getServerLocation()
|
|
68
|
+
const res = await fetch(`${serverPath}/product-export/download/${fileName}`, {
|
|
69
|
+
method: 'GET',
|
|
70
|
+
headers: {
|
|
71
|
+
...getChannelHeader(),
|
|
72
|
+
},
|
|
73
|
+
credentials: 'include',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const blob = await res.blob()
|
|
77
|
+
const url = URL.createObjectURL(blob)
|
|
78
|
+
const a = document.createElement('a')
|
|
79
|
+
a.href = url
|
|
80
|
+
a.download = fileName
|
|
81
|
+
document.body.appendChild(a)
|
|
82
|
+
a.click()
|
|
83
|
+
document.body.removeChild(a)
|
|
84
|
+
URL.revokeObjectURL(url)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const handleDeleteClick = (fileName: string) => {
|
|
88
|
+
setFileToDelete(fileName)
|
|
89
|
+
setDeleteDialogOpen(true)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const deleteFile = async () => {
|
|
93
|
+
if (!fileToDelete) return
|
|
94
|
+
|
|
95
|
+
const serverPath = getServerLocation()
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`${serverPath}/product-export/delete/${fileToDelete}`, {
|
|
98
|
+
method: 'DELETE',
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
...getChannelHeader(),
|
|
102
|
+
},
|
|
103
|
+
credentials: 'include',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const json = await res.json()
|
|
108
|
+
throw new Error(json?.message || 'Failed to delete file')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
toast.success('File deleted successfully')
|
|
112
|
+
setDeleteDialogOpen(false)
|
|
113
|
+
setFileToDelete(null)
|
|
114
|
+
await getExportFiles()
|
|
115
|
+
} catch (error: any) {
|
|
116
|
+
console.error('Delete error:', error)
|
|
117
|
+
toast.error(error.message || 'Failed to delete file')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const formatFileSize = (bytes: number): string => {
|
|
122
|
+
if (bytes < 1024) return bytes + ' B'
|
|
123
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
|
124
|
+
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (error) {
|
|
128
|
+
return (
|
|
129
|
+
<div className="text-center text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed">
|
|
130
|
+
<p className="text-red-500">{error}</p>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div>
|
|
137
|
+
{exportedFiles.length > 0 && (
|
|
138
|
+
<div className="space-y-2">
|
|
139
|
+
<div className="border rounded-md divide-y">
|
|
140
|
+
{paginatedFiles.map((file) => (
|
|
141
|
+
<div key={file.fileName} className="p-4 hover:bg-muted/50 transition-colors">
|
|
142
|
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
143
|
+
<div className="flex-1">
|
|
144
|
+
<p className="font-medium">{file.fileName}</p>
|
|
145
|
+
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 text-sm text-muted-foreground mt-1">
|
|
146
|
+
<span>Size: {formatFileSize(file.size)}</span>
|
|
147
|
+
<span>
|
|
148
|
+
Created:{' '}
|
|
149
|
+
{new Date(file.created).toLocaleString(navigator.language || undefined)}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="flex items-center gap-2">
|
|
154
|
+
<Button variant="outline" size="sm" onClick={() => downLoadFile(file.fileName)}>
|
|
155
|
+
<DownloadIcon className="mr-2 h-4 w-4" />
|
|
156
|
+
Download
|
|
157
|
+
</Button>
|
|
158
|
+
<Button
|
|
159
|
+
variant="outline"
|
|
160
|
+
size="sm"
|
|
161
|
+
onClick={() => handleDeleteClick(file.fileName)}
|
|
162
|
+
className="text-destructive hover:text-destructive"
|
|
163
|
+
>
|
|
164
|
+
<TrashIcon className="h-4 w-4" /> Delete
|
|
165
|
+
</Button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Pagination controls */}
|
|
173
|
+
{totalPages > 1 && (
|
|
174
|
+
<div className="flex items-center justify-between py-2 border-t">
|
|
175
|
+
<div className="text-sm text-muted-foreground">
|
|
176
|
+
Showing {(currentPage - 1) * itemsPerPage + 1} to{' '}
|
|
177
|
+
{Math.min(currentPage * itemsPerPage, exportedFiles.length)} of{' '}
|
|
178
|
+
{exportedFiles.length}
|
|
179
|
+
</div>
|
|
180
|
+
<div className="flex items-center gap-1">
|
|
181
|
+
<Button
|
|
182
|
+
variant="outline"
|
|
183
|
+
size="sm"
|
|
184
|
+
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
|
185
|
+
disabled={currentPage === 1}
|
|
186
|
+
>
|
|
187
|
+
<ChevronLeftIcon className="h-4 w-4" />
|
|
188
|
+
</Button>
|
|
189
|
+
<div className="text-sm text-muted-foreground px-2">
|
|
190
|
+
{currentPage} / {totalPages}
|
|
191
|
+
</div>
|
|
192
|
+
<Button
|
|
193
|
+
variant="outline"
|
|
194
|
+
size="sm"
|
|
195
|
+
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
|
196
|
+
disabled={currentPage === totalPages}
|
|
197
|
+
>
|
|
198
|
+
<ChevronRightIcon className="h-4 w-4" />
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{exportedFiles.length === 0 && (
|
|
207
|
+
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
208
|
+
<p>You have no exported files</p>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
213
|
+
<DialogContent>
|
|
214
|
+
<DialogHeader>
|
|
215
|
+
<DialogTitle>Delete {fileToDelete}?</DialogTitle>
|
|
216
|
+
<DialogDescription>
|
|
217
|
+
Are you sure you want to delete this file? This action cannot be undone.
|
|
218
|
+
</DialogDescription>
|
|
219
|
+
</DialogHeader>
|
|
220
|
+
<DialogFooter>
|
|
221
|
+
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
|
222
|
+
Cancel
|
|
223
|
+
</Button>
|
|
224
|
+
<Button variant="destructive" onClick={deleteFile}>
|
|
225
|
+
Delete
|
|
226
|
+
</Button>
|
|
227
|
+
</DialogFooter>
|
|
228
|
+
</DialogContent>
|
|
229
|
+
</Dialog>
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default ExportedList
|