@stoker-platform/web-app 0.5.108 → 0.5.110

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,24 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.110
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: add image thumbnails option
8
+ - @stoker-platform/node-client@0.5.62
9
+ - @stoker-platform/utils@0.5.53
10
+ - @stoker-platform/web-client@0.5.63
11
+
12
+ ## 0.5.109
13
+
14
+ ### Patch Changes
15
+
16
+ - feat: add file options
17
+ - Updated dependencies
18
+ - @stoker-platform/utils@0.5.52
19
+ - @stoker-platform/node-client@0.5.61
20
+ - @stoker-platform/web-client@0.5.62
21
+
3
22
  ## 0.5.108
4
23
 
5
24
  ### 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.110",
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.62",
55
+ "@stoker-platform/utils": "0.5.53",
56
+ "@stoker-platform/web-client": "0.5.63",
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,
@@ -26,6 +27,7 @@ import {
26
27
  getMetadata,
27
28
  updateMetadata,
28
29
  } from "firebase/storage"
30
+ import type { FirebaseStorage } from "firebase/storage"
29
31
  import { getAuth } from "firebase/auth"
30
32
  import { Progress } from "./components/ui/progress"
31
33
  import { Button } from "./components/ui/button"
@@ -68,6 +70,84 @@ import {
68
70
  AlertDialogTitle,
69
71
  } from "./components/ui/alert-dialog"
70
72
  import { FilePermissionsDialog, FilePermissions } from "./FilePermissions"
73
+ import { prepareFile } from "./utils/prepareFile"
74
+
75
+ const IMAGE_FILE_EXTENSIONS = new Set([
76
+ "avif",
77
+ "bmp",
78
+ "gif",
79
+ "heic",
80
+ "heif",
81
+ "ico",
82
+ "jpeg",
83
+ "jpg",
84
+ "png",
85
+ "svg",
86
+ "webp",
87
+ ])
88
+
89
+ const isImageFile = (name: string): boolean => {
90
+ const dot = name.lastIndexOf(".")
91
+ if (dot <= 0) return false
92
+ return IMAGE_FILE_EXTENSIONS.has(name.slice(dot + 1).toLowerCase())
93
+ }
94
+
95
+ interface FileImageThumbnailProps {
96
+ storage: FirebaseStorage
97
+ fullPath: string
98
+ fileName: string
99
+ pathPrefix: string
100
+ }
101
+
102
+ const FileImageThumbnail = ({ storage, fullPath, fileName, pathPrefix }: FileImageThumbnailProps) => {
103
+ const [phase, setPhase] = useState<"loading" | "display" | "fallback">("loading")
104
+ const [url, setUrl] = useState<string | null>(null)
105
+
106
+ useEffect(() => {
107
+ if (!pathPrefix || !fullPath.startsWith(pathPrefix)) {
108
+ setPhase("fallback")
109
+ return
110
+ }
111
+ let cancelled = false
112
+ setPhase("loading")
113
+ setUrl(null)
114
+ const load = async () => {
115
+ try {
116
+ const fileRef = ref(storage, fullPath)
117
+ const downloadUrl = await getDownloadURL(fileRef)
118
+ if (!cancelled) {
119
+ setUrl(downloadUrl)
120
+ setPhase("display")
121
+ }
122
+ } catch {
123
+ if (!cancelled) {
124
+ setPhase("fallback")
125
+ }
126
+ }
127
+ }
128
+ void load()
129
+ return () => {
130
+ cancelled = true
131
+ }
132
+ }, [storage, fullPath, pathPrefix])
133
+
134
+ if (phase === "loading") {
135
+ return <div className="h-12 w-12 shrink-0 rounded-md border bg-muted animate-pulse" aria-hidden />
136
+ }
137
+ if (phase === "fallback" || !url) {
138
+ return <File className="h-5 w-5 shrink-0 text-gray-500" aria-hidden />
139
+ }
140
+ return (
141
+ <img
142
+ src={url}
143
+ alt={`Thumbnail for ${fileName}`}
144
+ className="max-h-12 w-12 shrink-0 rounded-md object-contain"
145
+ loading="lazy"
146
+ decoding="async"
147
+ onError={() => setPhase("fallback")}
148
+ />
149
+ )
150
+ }
71
151
 
72
152
  interface FilesProps {
73
153
  collection: CollectionSchema
@@ -88,6 +168,7 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
88
168
 
89
169
  const [collectionTitle, setCollectionTitle] = useState("")
90
170
  const [meta, setMeta] = useState<CollectionMeta | undefined>(undefined)
171
+ const [fileOptions, setFileOptions] = useState<FileOptions | undefined>(undefined)
91
172
  const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([])
92
173
  const [isDragOver, setIsDragOver] = useState(false)
93
174
  const [currentPath, setCurrentPath] = useState("")
@@ -168,6 +249,8 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
168
249
  setCollectionTitle(titles?.collection || labels.collection)
169
250
  const meta = await getCachedConfigValue(customization, [...collectionAdminPath, "meta"])
170
251
  setMeta(meta)
252
+ const fileOptions = await getCachedConfigValue(customization, [...collectionAdminPath, "fileOptions"])
253
+ setFileOptions(fileOptions)
171
254
  }
172
255
  initialize()
173
256
  }, [])
@@ -238,7 +321,15 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
238
321
  const fileArray = Array.from(files)
239
322
 
240
323
  for (const file of fileArray) {
241
- const filename = (customFilename || file.name).trim()
324
+ let preferredFilename = (customFilename || file.name).trim()
325
+ let uploadFile = file
326
+ if (fileOptions?.maxImageWidth) {
327
+ const prepared = await prepareFile(file, preferredFilename, fileOptions)
328
+ uploadFile = prepared.file
329
+ preferredFilename = prepared.filename
330
+ }
331
+
332
+ const filename = preferredFilename
242
333
  const validationError = validateStorageName(filename)
243
334
  if (validationError) {
244
335
  toast({
@@ -251,7 +342,7 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
251
342
  const filePath = currentPath ? `${basePath}/${currentPath}/${filename}` : `${basePath}/${filename}`
252
343
  const storageRef = ref(storage, filePath)
253
344
  const uploadItem: UploadProgress = {
254
- file,
345
+ file: uploadFile,
255
346
  progress: 0,
256
347
  status: "uploading",
257
348
  }
@@ -280,46 +371,62 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
280
371
  })
281
372
  } catch {
282
373
  setUploadProgress((prev) =>
283
- prev.map((item) => (item.file === file ? { ...item, status: "error" } : item)),
374
+ prev.map((item) => (item.file === uploadFile ? { ...item, status: "error" } : item)),
284
375
  )
285
376
  continue
286
377
  }
287
378
 
288
- const uploadTask = uploadBytesResumable(storageRef, file, metadata)
379
+ const uploadTask = uploadBytesResumable(storageRef, uploadFile, metadata)
289
380
 
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
- })
381
+ await new Promise<void>((resolve) => {
382
+ uploadTask.on(
383
+ "state_changed",
384
+ (snapshot) => {
385
+ const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
386
+ setUploadProgress((prev) =>
387
+ prev.map((item) => (item.file === uploadFile ? { ...item, progress } : item)),
388
+ )
389
+ },
390
+ (error) => {
391
+ void runHooks("postFileAddError", globalConfig, customization, {
392
+ record,
393
+ fullPath: filePath,
394
+ filename,
395
+ permissions: {
396
+ read: metadata.customMetadata.read,
397
+ update: metadata.customMetadata.update,
398
+ delete: metadata.customMetadata.delete,
399
+ },
400
+ error,
401
+ }).catch(() => {})
402
+ setUploadProgress((prev) =>
403
+ prev.map((item) =>
404
+ item.file === uploadFile
405
+ ? { ...item, status: "error", error: error.message }
406
+ : item,
407
+ ),
408
+ )
409
+ console.error(error.message)
410
+ toast({
411
+ title: "Upload failed",
412
+ description: `Failed to upload ${filename}`,
413
+ variant: "destructive",
414
+ })
415
+ resolve()
416
+ },
417
+ async () => {
418
+ setUploadProgress((prev) =>
419
+ prev.map((item) =>
420
+ item.file === uploadFile
421
+ ? { ...item, status: "completed", completedAt: Date.now() }
422
+ : item,
423
+ ),
424
+ )
425
+ toast({
426
+ title: "Upload successful",
427
+ description: `${filename} uploaded successfully`,
428
+ })
321
429
 
322
- try {
323
430
  await runHooks("postFileAdd", globalConfig, customization, {
324
431
  record,
325
432
  fullPath: filePath,
@@ -329,14 +436,13 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
329
436
  update: metadata.customMetadata.update,
330
437
  delete: metadata.customMetadata.delete,
331
438
  },
332
- })
333
- } catch {
334
- return
335
- }
439
+ }).catch(() => {})
336
440
 
337
- loadDirectory(currentPath)
338
- },
339
- )
441
+ loadDirectory(currentPath)
442
+ resolve()
443
+ },
444
+ )
445
+ })
340
446
  }
341
447
 
342
448
  setShowFilenameDialog(false)
@@ -346,7 +452,7 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
346
452
  setPendingUploadFiles([])
347
453
  setIsMultipleFileUpload(false)
348
454
  },
349
- [record, currentPath, basePath, currentUser],
455
+ [record, currentPath, basePath, currentUser, fileOptions],
350
456
  )
351
457
 
352
458
  const handleFileUpload = useCallback(
@@ -1010,9 +1116,20 @@ export const RecordFiles = ({ collection, record }: FilesProps) => {
1010
1116
  >
1011
1117
  <div className="flex items-center space-x-3 flex-1 min-w-0">
1012
1118
  {item.isFolder ? (
1013
- <Folder className="h-5 w-5 text-blue-500" />
1119
+ <Folder className="h-5 w-5 shrink-0 text-blue-500" />
1120
+ ) : fileOptions?.thumbnails === true && isImageFile(item.name) ? (
1121
+ <FileImageThumbnail
1122
+ storage={storage}
1123
+ fullPath={item.fullPath}
1124
+ fileName={item.name}
1125
+ pathPrefix={
1126
+ record
1127
+ ? `${tenantId}/${labels.collection}/${record.id}`
1128
+ : ""
1129
+ }
1130
+ />
1014
1131
  ) : (
1015
- <File className="h-5 w-5 text-gray-500" />
1132
+ <File className="h-5 w-5 shrink-0 text-gray-500" />
1016
1133
  )}
1017
1134
 
1018
1135
  {editingFile === item.name && !isDisabled ? (
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
+ }