@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 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.108",
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.60",
55
- "@stoker-platform/utils": "0.5.51",
56
- "@stoker-platform/web-client": "0.5.61",
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
- const filename = (customFilename || file.name).trim()
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 === file ? { ...item, status: "error" } : item)),
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, file, metadata)
301
+ const uploadTask = uploadBytesResumable(storageRef, uploadFile, metadata)
289
302
 
290
- uploadTask.on(
291
- "state_changed",
292
- (snapshot) => {
293
- const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
294
- setUploadProgress((prev) =>
295
- prev.map((item) => (item.file === file ? { ...item, progress } : item)),
296
- )
297
- },
298
- (error) => {
299
- setUploadProgress((prev) =>
300
- prev.map((item) =>
301
- item.file === file ? { ...item, status: "error", error: error.message } : item,
302
- ),
303
- )
304
- console.error(error.message)
305
- toast({
306
- title: "Upload failed",
307
- description: `Failed to upload ${filename}`,
308
- variant: "destructive",
309
- })
310
- },
311
- async () => {
312
- setUploadProgress((prev) =>
313
- prev.map((item) =>
314
- item.file === file ? { ...item, status: "completed", completedAt: Date.now() } : item,
315
- ),
316
- )
317
- toast({
318
- title: "Upload successful",
319
- description: `${filename} uploaded successfully`,
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
- loadDirectory(currentPath)
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
+ }