@stoker-platform/web-app 0.5.108 → 0.5.109
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 +4 -4
- package/src/Files.tsx +72 -44
- package/src/Form.tsx +24 -2
- package/src/utils/prepareFile.ts +130 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @stoker-platform/web-app
|
|
2
2
|
|
|
3
|
+
## 0.5.109
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- feat: add file options
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @stoker-platform/utils@0.5.52
|
|
10
|
+
- @stoker-platform/node-client@0.5.61
|
|
11
|
+
- @stoker-platform/web-client@0.5.62
|
|
12
|
+
|
|
3
13
|
## 0.5.108
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stoker-platform/web-app",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.109",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"scripts": {
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
52
52
|
"@react-google-maps/api": "^2.20.8",
|
|
53
53
|
"@sentry/react": "^10.50.0",
|
|
54
|
-
"@stoker-platform/node-client": "0.5.
|
|
55
|
-
"@stoker-platform/utils": "0.5.
|
|
56
|
-
"@stoker-platform/web-client": "0.5.
|
|
54
|
+
"@stoker-platform/node-client": "0.5.61",
|
|
55
|
+
"@stoker-platform/utils": "0.5.52",
|
|
56
|
+
"@stoker-platform/web-client": "0.5.62",
|
|
57
57
|
"@tanstack/react-table": "^8.21.3",
|
|
58
58
|
"@types/react": "18.3.13",
|
|
59
59
|
"@types/react-dom": "18.3.1",
|
package/src/Files.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Helmet } from "react-helmet"
|
|
|
2
2
|
import {
|
|
3
3
|
CollectionMeta,
|
|
4
4
|
CollectionSchema,
|
|
5
|
+
FileOptions,
|
|
5
6
|
StokerCollection,
|
|
6
7
|
StokerRecord,
|
|
7
8
|
StorageItem,
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
AlertDialogTitle,
|
|
69
70
|
} from "./components/ui/alert-dialog"
|
|
70
71
|
import { FilePermissionsDialog, FilePermissions } from "./FilePermissions"
|
|
72
|
+
import { prepareFile } from "./utils/prepareFile"
|
|
71
73
|
|
|
72
74
|
interface FilesProps {
|
|
73
75
|
collection: CollectionSchema
|
|
@@ -88,6 +90,7 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
|
|
|
88
90
|
|
|
89
91
|
const [collectionTitle, setCollectionTitle] = useState("")
|
|
90
92
|
const [meta, setMeta] = useState<CollectionMeta | undefined>(undefined)
|
|
93
|
+
const [fileOptions, setFileOptions] = useState<FileOptions | undefined>(undefined)
|
|
91
94
|
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([])
|
|
92
95
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
93
96
|
const [currentPath, setCurrentPath] = useState("")
|
|
@@ -168,6 +171,8 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
|
|
|
168
171
|
setCollectionTitle(titles?.collection || labels.collection)
|
|
169
172
|
const meta = await getCachedConfigValue(customization, [...collectionAdminPath, "meta"])
|
|
170
173
|
setMeta(meta)
|
|
174
|
+
const fileOptions = await getCachedConfigValue(customization, [...collectionAdminPath, "fileOptions"])
|
|
175
|
+
setFileOptions(fileOptions)
|
|
171
176
|
}
|
|
172
177
|
initialize()
|
|
173
178
|
}, [])
|
|
@@ -238,7 +243,15 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
|
|
|
238
243
|
const fileArray = Array.from(files)
|
|
239
244
|
|
|
240
245
|
for (const file of fileArray) {
|
|
241
|
-
|
|
246
|
+
let preferredFilename = (customFilename || file.name).trim()
|
|
247
|
+
let uploadFile = file
|
|
248
|
+
if (fileOptions?.maxImageWidth) {
|
|
249
|
+
const prepared = await prepareFile(file, preferredFilename, fileOptions)
|
|
250
|
+
uploadFile = prepared.file
|
|
251
|
+
preferredFilename = prepared.filename
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const filename = preferredFilename
|
|
242
255
|
const validationError = validateStorageName(filename)
|
|
243
256
|
if (validationError) {
|
|
244
257
|
toast({
|
|
@@ -251,7 +264,7 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
|
|
|
251
264
|
const filePath = currentPath ? `${basePath}/${currentPath}/${filename}` : `${basePath}/${filename}`
|
|
252
265
|
const storageRef = ref(storage, filePath)
|
|
253
266
|
const uploadItem: UploadProgress = {
|
|
254
|
-
file,
|
|
267
|
+
file: uploadFile,
|
|
255
268
|
progress: 0,
|
|
256
269
|
status: "uploading",
|
|
257
270
|
}
|
|
@@ -280,46 +293,62 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
|
|
|
280
293
|
})
|
|
281
294
|
} catch {
|
|
282
295
|
setUploadProgress((prev) =>
|
|
283
|
-
prev.map((item) => (item.file ===
|
|
296
|
+
prev.map((item) => (item.file === uploadFile ? { ...item, status: "error" } : item)),
|
|
284
297
|
)
|
|
285
298
|
continue
|
|
286
299
|
}
|
|
287
300
|
|
|
288
|
-
const uploadTask = uploadBytesResumable(storageRef,
|
|
301
|
+
const uploadTask = uploadBytesResumable(storageRef, uploadFile, metadata)
|
|
289
302
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
303
|
+
await new Promise<void>((resolve) => {
|
|
304
|
+
uploadTask.on(
|
|
305
|
+
"state_changed",
|
|
306
|
+
(snapshot) => {
|
|
307
|
+
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
|
|
308
|
+
setUploadProgress((prev) =>
|
|
309
|
+
prev.map((item) => (item.file === uploadFile ? { ...item, progress } : item)),
|
|
310
|
+
)
|
|
311
|
+
},
|
|
312
|
+
(error) => {
|
|
313
|
+
void runHooks("postFileAddError", globalConfig, customization, {
|
|
314
|
+
record,
|
|
315
|
+
fullPath: filePath,
|
|
316
|
+
filename,
|
|
317
|
+
permissions: {
|
|
318
|
+
read: metadata.customMetadata.read,
|
|
319
|
+
update: metadata.customMetadata.update,
|
|
320
|
+
delete: metadata.customMetadata.delete,
|
|
321
|
+
},
|
|
322
|
+
error,
|
|
323
|
+
}).catch(() => {})
|
|
324
|
+
setUploadProgress((prev) =>
|
|
325
|
+
prev.map((item) =>
|
|
326
|
+
item.file === uploadFile
|
|
327
|
+
? { ...item, status: "error", error: error.message }
|
|
328
|
+
: item,
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
console.error(error.message)
|
|
332
|
+
toast({
|
|
333
|
+
title: "Upload failed",
|
|
334
|
+
description: `Failed to upload ${filename}`,
|
|
335
|
+
variant: "destructive",
|
|
336
|
+
})
|
|
337
|
+
resolve()
|
|
338
|
+
},
|
|
339
|
+
async () => {
|
|
340
|
+
setUploadProgress((prev) =>
|
|
341
|
+
prev.map((item) =>
|
|
342
|
+
item.file === uploadFile
|
|
343
|
+
? { ...item, status: "completed", completedAt: Date.now() }
|
|
344
|
+
: item,
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
toast({
|
|
348
|
+
title: "Upload successful",
|
|
349
|
+
description: `${filename} uploaded successfully`,
|
|
350
|
+
})
|
|
321
351
|
|
|
322
|
-
try {
|
|
323
352
|
await runHooks("postFileAdd", globalConfig, customization, {
|
|
324
353
|
record,
|
|
325
354
|
fullPath: filePath,
|
|
@@ -329,14 +358,13 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
|
|
|
329
358
|
update: metadata.customMetadata.update,
|
|
330
359
|
delete: metadata.customMetadata.delete,
|
|
331
360
|
},
|
|
332
|
-
})
|
|
333
|
-
} catch {
|
|
334
|
-
return
|
|
335
|
-
}
|
|
361
|
+
}).catch(() => {})
|
|
336
362
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
363
|
+
loadDirectory(currentPath)
|
|
364
|
+
resolve()
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
})
|
|
340
368
|
}
|
|
341
369
|
|
|
342
370
|
setShowFilenameDialog(false)
|
|
@@ -346,7 +374,7 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
|
|
|
346
374
|
setPendingUploadFiles([])
|
|
347
375
|
setIsMultipleFileUpload(false)
|
|
348
376
|
},
|
|
349
|
-
[record, currentPath, basePath, currentUser],
|
|
377
|
+
[record, currentPath, basePath, currentUser, fileOptions],
|
|
350
378
|
)
|
|
351
379
|
|
|
352
380
|
const handleFileUpload = useCallback(
|
package/src/Form.tsx
CHANGED
|
@@ -2621,7 +2621,18 @@ function RecordForm({
|
|
|
2621
2621
|
uploadTask.on(
|
|
2622
2622
|
"state_changed",
|
|
2623
2623
|
undefined,
|
|
2624
|
-
() => {
|
|
2624
|
+
(error) => {
|
|
2625
|
+
void runHooks("postFileAddError", globalConfig, customization, {
|
|
2626
|
+
record: targetRecord,
|
|
2627
|
+
fullPath: filePath,
|
|
2628
|
+
filename,
|
|
2629
|
+
permissions: {
|
|
2630
|
+
read: metadata.customMetadata.read,
|
|
2631
|
+
update: metadata.customMetadata.update,
|
|
2632
|
+
delete: metadata.customMetadata.delete,
|
|
2633
|
+
},
|
|
2634
|
+
error,
|
|
2635
|
+
}).catch(() => {})
|
|
2625
2636
|
toast({
|
|
2626
2637
|
title: "Upload failed",
|
|
2627
2638
|
description: `Failed to upload ${filename}`,
|
|
@@ -2768,7 +2779,18 @@ function RecordForm({
|
|
|
2768
2779
|
uploadTask.on(
|
|
2769
2780
|
"state_changed",
|
|
2770
2781
|
undefined,
|
|
2771
|
-
() => {
|
|
2782
|
+
(error) => {
|
|
2783
|
+
void runHooks("postFileAddError", globalConfig, customization, {
|
|
2784
|
+
record: targetRecord,
|
|
2785
|
+
fullPath: filePath,
|
|
2786
|
+
filename,
|
|
2787
|
+
permissions: {
|
|
2788
|
+
read: metadata.customMetadata.read,
|
|
2789
|
+
update: metadata.customMetadata.update,
|
|
2790
|
+
delete: metadata.customMetadata.delete,
|
|
2791
|
+
},
|
|
2792
|
+
error,
|
|
2793
|
+
}).catch(() => {})
|
|
2772
2794
|
setIsUploading((prev) => ({ ...prev, [fieldName]: false }))
|
|
2773
2795
|
toast({
|
|
2774
2796
|
title: "Upload failed",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { FileOptions } from "@stoker-platform/types"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_WIDTH = 1920
|
|
4
|
+
const DEFAULT_JPEG_QUALITY = 1
|
|
5
|
+
|
|
6
|
+
const replaceExtension = (filename: string, newExt: string): string => {
|
|
7
|
+
const trimmed = filename.trim()
|
|
8
|
+
const dot = trimmed.lastIndexOf(".")
|
|
9
|
+
if (dot <= 0) {
|
|
10
|
+
return `${trimmed}${newExt}`
|
|
11
|
+
}
|
|
12
|
+
return `${trimmed.slice(0, dot)}${newExt}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const loadImageFromFile = (file: File): Promise<HTMLImageElement> => {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const url = URL.createObjectURL(file)
|
|
18
|
+
const img = new Image()
|
|
19
|
+
img.decoding = "async"
|
|
20
|
+
img.onload = () => {
|
|
21
|
+
URL.revokeObjectURL(url)
|
|
22
|
+
resolve(img)
|
|
23
|
+
}
|
|
24
|
+
img.onerror = () => {
|
|
25
|
+
URL.revokeObjectURL(url)
|
|
26
|
+
reject(new Error("Image decode failed"))
|
|
27
|
+
}
|
|
28
|
+
img.src = url
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const prepareFile = async (
|
|
33
|
+
file: File,
|
|
34
|
+
preferredFileName: string,
|
|
35
|
+
fileOptions: FileOptions,
|
|
36
|
+
): Promise<{ file: File; filename: string }> => {
|
|
37
|
+
if (!file.type.startsWith("image/") || file.type === "image/svg+xml") {
|
|
38
|
+
return { file, filename: preferredFileName }
|
|
39
|
+
}
|
|
40
|
+
const usePngOutput = file.type === "image/png"
|
|
41
|
+
|
|
42
|
+
let bitmap: ImageBitmap | undefined
|
|
43
|
+
try {
|
|
44
|
+
let width: number
|
|
45
|
+
let height: number
|
|
46
|
+
let img: HTMLImageElement | undefined
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
bitmap = await createImageBitmap(file)
|
|
50
|
+
width = bitmap.width
|
|
51
|
+
height = bitmap.height
|
|
52
|
+
} catch {
|
|
53
|
+
img = await loadImageFromFile(file)
|
|
54
|
+
width = img.naturalWidth
|
|
55
|
+
height = img.naturalHeight
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (width < 1 || height < 1) {
|
|
59
|
+
return { file, filename: preferredFileName }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const maxWidth =
|
|
63
|
+
typeof fileOptions.maxImageWidth === "number" && fileOptions.maxImageWidth > 0
|
|
64
|
+
? Math.floor(fileOptions.maxImageWidth)
|
|
65
|
+
: DEFAULT_MAX_WIDTH
|
|
66
|
+
|
|
67
|
+
let targetW = width
|
|
68
|
+
let targetH = height
|
|
69
|
+
if (width > maxWidth || height > maxWidth) {
|
|
70
|
+
if (width >= height) {
|
|
71
|
+
targetW = maxWidth
|
|
72
|
+
targetH = Math.max(1, Math.round((height / width) * maxWidth))
|
|
73
|
+
} else {
|
|
74
|
+
targetH = maxWidth
|
|
75
|
+
targetW = Math.max(1, Math.round((width / height) * maxWidth))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const didDownscale = targetW < width || targetH < height
|
|
79
|
+
|
|
80
|
+
const canvas = document.createElement("canvas")
|
|
81
|
+
canvas.width = targetW
|
|
82
|
+
canvas.height = targetH
|
|
83
|
+
const ctx = canvas.getContext("2d")
|
|
84
|
+
if (!ctx) {
|
|
85
|
+
return { file, filename: preferredFileName }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (bitmap) {
|
|
89
|
+
ctx.drawImage(bitmap, 0, 0, targetW, targetH)
|
|
90
|
+
} else if (img) {
|
|
91
|
+
ctx.drawImage(img, 0, 0, targetW, targetH)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const blob = await new Promise<Blob | null>((resolve) => {
|
|
95
|
+
if (usePngOutput) {
|
|
96
|
+
canvas.toBlob((b) => resolve(b), "image/png")
|
|
97
|
+
} else {
|
|
98
|
+
canvas.toBlob((b) => resolve(b), "image/jpeg", DEFAULT_JPEG_QUALITY)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if (!blob) {
|
|
103
|
+
return { file, filename: preferredFileName }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const noBenefit =
|
|
107
|
+
blob.size >= file.size &&
|
|
108
|
+
!didDownscale &&
|
|
109
|
+
((usePngOutput && file.type === "image/png") || (!usePngOutput && file.type === "image/jpeg"))
|
|
110
|
+
if (noBenefit) {
|
|
111
|
+
return { file, filename: preferredFileName }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const outMime = usePngOutput ? "image/png" : "image/jpeg"
|
|
115
|
+
const ext = usePngOutput ? ".png" : ".jpg"
|
|
116
|
+
const nextFilename = replaceExtension(preferredFileName, ext)
|
|
117
|
+
const outFile = new File([blob], nextFilename, {
|
|
118
|
+
type: outMime,
|
|
119
|
+
lastModified: Date.now(),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return { file: outFile, filename: nextFilename }
|
|
123
|
+
} catch {
|
|
124
|
+
return { file, filename: preferredFileName }
|
|
125
|
+
} finally {
|
|
126
|
+
if (bitmap) {
|
|
127
|
+
bitmap.close()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|