@jmruthers/pace-core 0.5.87 → 0.5.88

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.
Files changed (242) hide show
  1. package/dist/{AuthService-Df3IozMG.d.ts → AuthService-DcTI5Ov4.d.ts} +9 -0
  2. package/dist/{DataTable-FA6EUX5M.js → DataTable-PWBMKMOG.js} +7 -7
  3. package/dist/{PublicLoadingSpinner-DecuJBX0.d.ts → PublicLoadingSpinner-BQXD1fbO.d.ts} +160 -130
  4. package/dist/{UnifiedAuthProvider-K2IZAY5F.js → UnifiedAuthProvider-5D3HEQND.js} +4 -4
  5. package/dist/{UnifiedAuthProvider-B391Aqum.d.ts → UnifiedAuthProvider-BVKmQd9u.d.ts} +4 -0
  6. package/dist/auth-DReDSLq9.d.ts +16 -0
  7. package/dist/{chunk-CBSD3BZ3.js → chunk-3RZBKQ5Y.js} +2 -6
  8. package/dist/{chunk-CBSD3BZ3.js.map → chunk-3RZBKQ5Y.js.map} +1 -1
  9. package/dist/{chunk-NTW3KGS4.js → chunk-6UHXQH7P.js} +5 -5
  10. package/dist/{chunk-YVUZWLQG.js → chunk-AQGF5OG7.js} +3 -3
  11. package/dist/{chunk-CVMVPYAL.js → chunk-BDZUMRBD.js} +3 -5
  12. package/dist/chunk-BDZUMRBD.js.map +1 -0
  13. package/dist/{chunk-KAY3K5TP.js → chunk-BNXBJOGL.js} +4 -4
  14. package/dist/{chunk-I7O3RSMN.js → chunk-CJIZS3UE.js} +1298 -769
  15. package/dist/chunk-CJIZS3UE.js.map +1 -0
  16. package/dist/{chunk-S3JKDMD5.js → chunk-CXKMRKRF.js} +4 -4
  17. package/dist/{chunk-5BN3YGNK.js → chunk-DP5X5ORK.js} +217 -27
  18. package/dist/chunk-DP5X5ORK.js.map +1 -0
  19. package/dist/{chunk-ZFLOV3OM.js → chunk-H3P2RGKZ.js} +352 -16
  20. package/dist/chunk-H3P2RGKZ.js.map +1 -0
  21. package/dist/{chunk-RIXPZJUB.js → chunk-KTPG5VCH.js} +2 -2
  22. package/dist/{chunk-WUXCWRL6.js → chunk-XJ2HZOBU.js} +6 -1
  23. package/dist/chunk-XJ2HZOBU.js.map +1 -0
  24. package/dist/{chunk-2FQEQUJT.js → chunk-XXVM53P4.js} +4 -4
  25. package/dist/{chunk-I2VVV5PQ.js → chunk-YY4YYM3E.js} +2 -2
  26. package/dist/components.d.ts +6 -55
  27. package/dist/components.js +24 -205
  28. package/dist/components.js.map +1 -1
  29. package/dist/{file-reference-9xUOnwyt.d.ts → file-reference-C9isKNPn.d.ts} +67 -2
  30. package/dist/hooks.js +9 -8
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/index.d.ts +152 -26
  33. package/dist/index.js +64 -194
  34. package/dist/index.js.map +1 -1
  35. package/dist/providers.d.ts +5 -3
  36. package/dist/providers.js +3 -3
  37. package/dist/rbac/index.js +8 -8
  38. package/dist/types.d.ts +2 -1
  39. package/dist/types.js +3 -3
  40. package/dist/utils.js +2 -2
  41. package/docs/DOCUMENTATION_AUDIT.md +6 -6
  42. package/docs/DOCUMENTATION_STANDARD.md +137 -0
  43. package/docs/README.md +1 -1
  44. package/docs/api/classes/ColumnFactory.md +1 -1
  45. package/docs/api/classes/ErrorBoundary.md +1 -1
  46. package/docs/api/classes/InvalidScopeError.md +1 -1
  47. package/docs/api/classes/MissingUserContextError.md +1 -1
  48. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  49. package/docs/api/classes/PermissionDeniedError.md +1 -1
  50. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  51. package/docs/api/classes/RBACAuditManager.md +1 -1
  52. package/docs/api/classes/RBACCache.md +1 -1
  53. package/docs/api/classes/RBACEngine.md +1 -1
  54. package/docs/api/classes/RBACError.md +1 -1
  55. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  56. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  57. package/docs/api/classes/StorageUtils.md +83 -40
  58. package/docs/api/enums/FileCategory.md +56 -1
  59. package/docs/api/interfaces/AggregateConfig.md +1 -1
  60. package/docs/api/interfaces/ButtonProps.md +1 -1
  61. package/docs/api/interfaces/CardProps.md +1 -1
  62. package/docs/api/interfaces/ColorPalette.md +1 -1
  63. package/docs/api/interfaces/ColorShade.md +1 -1
  64. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  65. package/docs/api/interfaces/DataRecord.md +1 -1
  66. package/docs/api/interfaces/DataTableAction.md +1 -1
  67. package/docs/api/interfaces/DataTableColumn.md +1 -1
  68. package/docs/api/interfaces/DataTableProps.md +1 -1
  69. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  70. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  71. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  72. package/docs/api/interfaces/EventLogoProps.md +11 -11
  73. package/docs/api/interfaces/FileDisplayProps.md +10 -10
  74. package/docs/api/interfaces/FileMetadata.md +1 -1
  75. package/docs/api/interfaces/FileReference.md +1 -1
  76. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  77. package/docs/api/interfaces/FileUploadOptions.md +8 -8
  78. package/docs/api/interfaces/FileUploadProps.md +137 -42
  79. package/docs/api/interfaces/FooterProps.md +1 -1
  80. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  81. package/docs/api/interfaces/InputProps.md +1 -1
  82. package/docs/api/interfaces/LabelProps.md +1 -1
  83. package/docs/api/interfaces/LoginFormProps.md +1 -1
  84. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  85. package/docs/api/interfaces/NavigationContextType.md +1 -1
  86. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  87. package/docs/api/interfaces/NavigationItem.md +1 -1
  88. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  89. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  90. package/docs/api/interfaces/Organisation.md +1 -1
  91. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  92. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  93. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  94. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  95. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  96. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  97. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  98. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  99. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  100. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  101. package/docs/api/interfaces/PaletteData.md +1 -1
  102. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  103. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  104. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  105. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  106. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  107. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  108. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  109. package/docs/api/interfaces/RBACConfig.md +1 -1
  110. package/docs/api/interfaces/RBACLogger.md +1 -1
  111. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  112. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  113. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  114. package/docs/api/interfaces/RouteConfig.md +1 -1
  115. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  116. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  117. package/docs/api/interfaces/StorageConfig.md +1 -1
  118. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  119. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  120. package/docs/api/interfaces/StorageListOptions.md +1 -1
  121. package/docs/api/interfaces/StorageListResult.md +1 -1
  122. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  123. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  124. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  125. package/docs/api/interfaces/StyleImport.md +1 -1
  126. package/docs/api/interfaces/SwitchProps.md +1 -1
  127. package/docs/api/interfaces/ToastActionElement.md +1 -1
  128. package/docs/api/interfaces/ToastProps.md +1 -1
  129. package/docs/api/interfaces/UnifiedAuthContextType.md +83 -50
  130. package/docs/api/interfaces/UnifiedAuthProviderProps.md +13 -13
  131. package/docs/api/interfaces/UseEventLogoOptions.md +74 -0
  132. package/docs/api/interfaces/UseEventLogoReturn.md +81 -0
  133. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  134. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  135. package/docs/api/interfaces/UsePublicEventLogoOptions.md +6 -6
  136. package/docs/api/interfaces/UsePublicEventLogoReturn.md +6 -6
  137. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  138. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  139. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  140. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  141. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  142. package/docs/api/interfaces/UserEventAccess.md +11 -11
  143. package/docs/api/interfaces/UserMenuProps.md +1 -1
  144. package/docs/api/interfaces/UserProfile.md +1 -1
  145. package/docs/api/modules.md +290 -95
  146. package/docs/api-reference/components.md +1 -18
  147. package/docs/api-reference/hooks.md +1 -4
  148. package/docs/best-practices/testing.md +2 -0
  149. package/docs/documentation-index.md +1 -1
  150. package/docs/getting-started/faq.md +1 -1
  151. package/docs/implementation-guides/file-reference-system.md +592 -58
  152. package/docs/implementation-guides/file-upload-storage.md +137 -73
  153. package/docs/rbac/super-admin-guide.md +18 -70
  154. package/docs/testing/README.md +2 -0
  155. package/package.json +1 -1
  156. package/src/__tests__/TEST_STANDARD.md +674 -0
  157. package/src/__tests__/helpers/test-utils.tsx +3 -2
  158. package/src/components/DataTable/__tests__/{DataTable.comprehensive.test.tsx.skip → DataTable.comprehensive.test.tsx} +17 -18
  159. package/src/components/DataTable/__tests__/{DataTable.test.tsx.skip → DataTable.test.tsx} +14 -22
  160. package/src/components/DataTable/__tests__/{ssr.strict-mode.test.tsx.skip → ssr.strict-mode.test.tsx} +42 -47
  161. package/src/components/DataTable/components/__tests__/COVERAGE_NOTE.md +1 -1
  162. package/src/components/DataTable/examples/__tests__/PerformanceExample.test.tsx +13 -4
  163. package/src/components/DataTable/utils/__tests__/COVERAGE_NOTE.md +1 -1
  164. package/src/components/DataTable/utils/__tests__/performanceUtils.test.ts +10 -6
  165. package/src/components/FileDisplay/FileDisplay.test.tsx +257 -0
  166. package/src/components/{FileDisplay.tsx → FileDisplay/FileDisplay.tsx} +111 -10
  167. package/src/components/FileDisplay/index.tsx +4 -0
  168. package/src/components/FileUpload/FileUpload.test.tsx +171 -621
  169. package/src/components/FileUpload/FileUpload.tsx +512 -168
  170. package/src/components/FileUpload/index.tsx +4 -0
  171. package/src/components/Progress/Progress.test.tsx +38 -0
  172. package/src/components/PublicLayout/EventLogo.tsx +6 -4
  173. package/src/components/Select/Select.test.tsx +1 -1
  174. package/src/components/SessionRestorationLoader.tsx +48 -0
  175. package/src/components/Toast/Toast.tsx +13 -8
  176. package/src/components/index.ts +16 -16
  177. package/src/hooks/__tests__/ServiceHooks.test.tsx +615 -0
  178. package/src/hooks/public/usePublicEventLogo.ts +16 -20
  179. package/src/hooks/useEventLogo.ts +316 -0
  180. package/src/hooks/useEvents.ts +0 -5
  181. package/src/hooks/useFileReference.test.ts +659 -0
  182. package/src/hooks/useFileReference.ts +207 -3
  183. package/src/hooks/useSessionRestoration.ts +64 -0
  184. package/src/index.ts +17 -5
  185. package/src/providers/{UnifiedAuthProvider.test.simple.tsx → UnifiedAuthProvider.smoke.test.tsx} +81 -60
  186. package/src/providers/services/AuthServiceProvider.tsx +27 -3
  187. package/src/providers/services/UnifiedAuthProvider.tsx +34 -5
  188. package/src/rbac/{engine.test.simple.ts → RBACEngine.smoke.test.ts} +17 -12
  189. package/src/services/AuthService.ts +142 -20
  190. package/src/services/EventService.ts +0 -4
  191. package/src/types/auth.ts +15 -0
  192. package/src/types/file-reference.ts +73 -1
  193. package/src/types/index.ts +1 -0
  194. package/src/utils/__tests__/organisationContext.unit.test.ts +2 -4
  195. package/src/utils/appNameResolver.simple.test.ts +99 -29
  196. package/src/utils/file-reference.test.ts +535 -0
  197. package/src/utils/file-reference.ts +200 -30
  198. package/src/utils/organisationContext.test.ts +5 -19
  199. package/src/utils/organisationContext.ts +3 -5
  200. package/src/utils/storage/README.md +269 -262
  201. package/src/utils/storage/config.ts +9 -0
  202. package/src/utils/storage/helpers.test.ts +631 -0
  203. package/src/utils/storage/helpers.ts +112 -14
  204. package/src/utils/storage/index.ts +3 -0
  205. package/src/validation/__tests__/sanitization.unit.test.ts +1 -1
  206. package/src/validation/__tests__/schemaUtils.unit.test.ts +1 -1
  207. package/src/validation/__tests__/user.unit.test.ts +1 -1
  208. package/dist/chunk-5BN3YGNK.js.map +0 -1
  209. package/dist/chunk-CVMVPYAL.js.map +0 -1
  210. package/dist/chunk-I7O3RSMN.js.map +0 -1
  211. package/dist/chunk-WUXCWRL6.js.map +0 -1
  212. package/dist/chunk-ZFLOV3OM.js.map +0 -1
  213. package/docs/CONTENT_AUDIT_REPORT.md +0 -253
  214. package/docs/STYLE_GUIDE.md +0 -37
  215. package/examples/RBAC/__tests__/PermissionExample.test.tsx +0 -150
  216. package/examples/public-pages/__tests__/PublicPageUsageExample.test.tsx +0 -159
  217. package/src/__tests__/TEST_GUIDE_CURSOR.md +0 -1605
  218. package/src/__tests__/TEST_GUIDE_HUMAN.md +0 -103
  219. package/src/components/FileUpload/FileUpload.example.tsx +0 -218
  220. package/src/components/FileUpload/index.ts +0 -6
  221. package/src/components/FileUpload.tsx +0 -176
  222. package/src/components/Progress/index.ts +0 -3
  223. package/src/components/PublicLayout/__tests__/EventLogo.test.tsx +0 -666
  224. package/src/components/SuperAdminGuard.tsx +0 -116
  225. package/src/components/__tests__/FileDisplay.test.tsx +0 -575
  226. package/src/components/__tests__/FileUpload.test.tsx +0 -446
  227. package/src/components/__tests__/SuperAdminGuard.test.tsx +0 -627
  228. package/src/components/examples/PermissionExample.tsx +0 -173
  229. package/src/hooks/__tests__/usePublicEvent.unit.test.ts +0 -583
  230. package/src/hooks/__tests__/usePublicEventLogo.unit.test.ts +0 -640
  231. package/src/types/__tests__/file-reference.test.ts +0 -447
  232. package/src/utils/__tests__/file-reference.test.ts +0 -383
  233. /package/dist/{DataTable-FA6EUX5M.js.map → DataTable-PWBMKMOG.js.map} +0 -0
  234. /package/dist/{UnifiedAuthProvider-K2IZAY5F.js.map → UnifiedAuthProvider-5D3HEQND.js.map} +0 -0
  235. /package/dist/{chunk-NTW3KGS4.js.map → chunk-6UHXQH7P.js.map} +0 -0
  236. /package/dist/{chunk-YVUZWLQG.js.map → chunk-AQGF5OG7.js.map} +0 -0
  237. /package/dist/{chunk-KAY3K5TP.js.map → chunk-BNXBJOGL.js.map} +0 -0
  238. /package/dist/{chunk-S3JKDMD5.js.map → chunk-CXKMRKRF.js.map} +0 -0
  239. /package/dist/{chunk-RIXPZJUB.js.map → chunk-KTPG5VCH.js.map} +0 -0
  240. /package/dist/{chunk-2FQEQUJT.js.map → chunk-XXVM53P4.js.map} +0 -0
  241. /package/dist/{chunk-I2VVV5PQ.js.map → chunk-YY4YYM3E.js.map} +0 -0
  242. /package/src/providers/{OrganisationProvider.test.simple.tsx → OrganisationProvider.context.test.tsx} +0 -0
@@ -1,237 +1,581 @@
1
- /**
2
- * File upload component with app-segregated storage
3
- */
1
+ // File Upload Component
2
+ // Provides a file upload interface using the file reference system
4
3
 
5
- import React, { useCallback, useState } from 'react';
4
+ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
6
5
  import { SupabaseClient } from '@supabase/supabase-js';
7
- import { validateFileSize, formatFileSize } from '../../utils/storage';
8
- import { useFileUpload } from '../../hooks/useStorage';
9
- import { Button } from '../Button/Button';
10
- import { Progress } from '../Progress/Progress';
11
- import { Alert } from '../Alert/Alert';
6
+ import { FileCategory, FileUploadResult, UploadProgress } from '../../types/file-reference';
7
+ import { useFileReference } from '../../hooks/useFileReference';
8
+ import { getCurrentAppName } from '../../utils/appNameResolver';
9
+ import { getAppId } from '../../utils/appIdResolver';
12
10
 
13
11
  export interface FileUploadProps {
14
12
  supabase: SupabaseClient;
15
- appName: string;
16
- orgId: string;
17
- onUploadComplete?: (result: { success: boolean; path?: string; error?: string }) => void;
18
- onUploadStart?: () => void;
13
+ table_name: string;
14
+ record_id: string;
15
+ organisation_id: string;
16
+ app_id?: string; // Optional - will be resolved from app name if not provided
17
+ category: FileCategory;
19
18
  accept?: string;
20
19
  maxSize?: number;
21
20
  multiple?: boolean;
22
21
  disabled?: boolean;
22
+ isPublic?: boolean; // Whether files should be uploaded to public-files bucket
23
23
  className?: string;
24
+ showPreview?: boolean; // Show image preview for accepted files
25
+ showProgress?: boolean; // Show upload progress bar
26
+ onUploadSuccess?: (result: FileUploadResult) => void;
27
+ onUploadError?: (error: string, file?: File) => void;
28
+ onProgress?: (progress: UploadProgress) => void;
24
29
  children?: React.ReactNode;
25
30
  }
26
31
 
32
+ interface FileUploadState {
33
+ file: File;
34
+ progress: UploadProgress;
35
+ preview?: string;
36
+ result?: FileUploadResult;
37
+ }
38
+
27
39
  export function FileUpload({
28
40
  supabase,
29
- appName,
30
- orgId,
31
- onUploadComplete,
32
- onUploadStart,
41
+ table_name,
42
+ record_id,
43
+ organisation_id,
44
+ app_id,
45
+ category,
33
46
  accept = '*/*',
34
- maxSize,
47
+ maxSize = 10 * 1024 * 1024, // 10MB default
35
48
  multiple = false,
36
49
  disabled = false,
50
+ isPublic = false,
37
51
  className = '',
52
+ showPreview = true,
53
+ showProgress = true,
54
+ onUploadSuccess,
55
+ onUploadError,
56
+ onProgress,
38
57
  children
39
58
  }: FileUploadProps) {
40
- const [dragActive, setDragActive] = useState(false);
41
- const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
42
- const [validationErrors, setValidationErrors] = useState<string[]>([]);
43
-
44
- const { uploadWithProgress, uploadProgress, isUploading, uploadError } = useFileUpload({
45
- supabase,
46
- appName,
47
- orgId
48
- });
49
-
50
- // Validate files
51
- const validateFiles = useCallback((files: File[]): string[] => {
52
- const errors: string[] = [];
53
-
54
- files.forEach((file, index) => {
55
- // Check file size
56
- const sizeValidation = validateFileSize(file);
57
- if (!sizeValidation.isValid) {
58
- errors.push(`File ${index + 1}: ${sizeValidation.error}`);
59
+ const [isDragging, setIsDragging] = useState(false);
60
+ const [uploadStates, setUploadStates] = useState<Map<string, FileUploadState>>(new Map());
61
+ const [resolvedAppId, setResolvedAppId] = useState<string | null>(app_id || null);
62
+ const [isResolvingAppId, setIsResolvingAppId] = useState(!app_id);
63
+ const [appIdError, setAppIdError] = useState<string | null>(null);
64
+ const fileInputRef = useRef<HTMLInputElement>(null);
65
+ const { uploadFile, isLoading, error } = useFileReference(supabase);
66
+
67
+ // Resolve app_id from app name if not provided
68
+ useEffect(() => {
69
+ if (app_id) {
70
+ // If app_id is provided, use it directly
71
+ setResolvedAppId(app_id);
72
+ setIsResolvingAppId(false);
73
+ setAppIdError(null);
74
+ return;
75
+ }
76
+
77
+ // Otherwise, resolve from app name
78
+ const resolveAppId = async () => {
79
+ setIsResolvingAppId(true);
80
+ setAppIdError(null);
81
+
82
+ try {
83
+ const appName = getCurrentAppName();
84
+ if (!appName) {
85
+ const errorMsg = 'App ID is required. Either provide app_id prop or set app name via setRBACAppName()';
86
+ setAppIdError(errorMsg);
87
+ setIsResolvingAppId(false);
88
+ return;
89
+ }
90
+
91
+ const resolvedId = await getAppId(supabase, appName);
92
+ if (!resolvedId) {
93
+ const errorMsg = `Failed to resolve app ID for app name "${appName}". Make sure the app is registered in rbac_apps table.`;
94
+ setAppIdError(errorMsg);
95
+ setIsResolvingAppId(false);
96
+ return;
97
+ }
98
+
99
+ setResolvedAppId(resolvedId);
100
+ setIsResolvingAppId(false);
101
+ } catch (err) {
102
+ const errorMessage = err instanceof Error ? err.message : 'Failed to resolve app ID';
103
+ setAppIdError(errorMessage);
104
+ setIsResolvingAppId(false);
59
105
  }
106
+ };
107
+
108
+ resolveAppId();
109
+ }, [app_id, supabase]);
60
110
 
61
- // Check custom max size
62
- if (maxSize && file.size > maxSize) {
63
- const fileMB = Math.round(file.size / (1024 * 1024));
64
- const maxMB = Math.round(maxSize / (1024 * 1024));
65
- errors.push(`File ${index + 1}: Size (${fileMB}MB) exceeds limit (${maxMB}MB)`);
111
+ // Calculate isUploading and isDisabled early so they can be used in callbacks
112
+ const isUploading = useMemo(() => {
113
+ return uploadStates.size > 0 && Array.from(uploadStates.values()).some(state =>
114
+ state.progress.status === 'uploading' || state.progress.status === 'processing'
115
+ );
116
+ }, [uploadStates]);
117
+
118
+ const isDisabled = useMemo(() => {
119
+ return disabled || isUploading || isResolvingAppId || !resolvedAppId;
120
+ }, [disabled, isUploading, isResolvingAppId, resolvedAppId]);
121
+
122
+ // Generate preview URL for images
123
+ const generatePreview = useCallback((file: File): Promise<string | null> => {
124
+ return new Promise((resolve) => {
125
+ if (!file.type.startsWith('image/')) {
126
+ resolve(null);
127
+ return;
66
128
  }
129
+
130
+ const reader = new FileReader();
131
+ reader.onload = (e) => {
132
+ resolve(e.target?.result as string || null);
133
+ };
134
+ reader.onerror = () => resolve(null);
135
+ reader.readAsDataURL(file);
67
136
  });
137
+ }, []);
138
+
139
+ // Validate file
140
+ const validateFile = useCallback((file: File): string | null => {
141
+ // Check file size
142
+ if (file.size > maxSize) {
143
+ return `File "${file.name}" exceeds maximum size of ${Math.round(maxSize / 1024 / 1024)}MB`;
144
+ }
145
+
146
+ // Check file type if accept is specified
147
+ if (accept !== '*/*') {
148
+ const acceptedTypes = accept.split(',').map(type => type.trim());
149
+ const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
150
+ const fileMimeType = file.type;
68
151
 
69
- return errors;
70
- }, [maxSize]);
152
+ const isAccepted = acceptedTypes.some(accepted => {
153
+ if (accepted.startsWith('.')) {
154
+ // Extension match
155
+ return accepted === fileExtension;
156
+ } else if (accepted.includes('/*')) {
157
+ // MIME type wildcard (e.g., "image/*")
158
+ const baseType = accepted.split('/')[0];
159
+ return fileMimeType.startsWith(baseType + '/');
160
+ } else {
161
+ // Exact MIME type match
162
+ return accepted === fileMimeType;
163
+ }
164
+ });
71
165
 
72
- // Handle file selection
73
- const handleFileSelect = useCallback((files: FileList | null) => {
74
- if (!files) return;
166
+ if (!isAccepted) {
167
+ return `File "${file.name}" is not an accepted format. Accepted: ${accept}`;
168
+ }
169
+ }
170
+
171
+ return null;
172
+ }, [accept, maxSize]);
173
+
174
+ const handleFileSelect = useCallback(async (files: FileList | null) => {
175
+ if (!files || files.length === 0) return;
75
176
 
76
177
  const fileArray = Array.from(files);
77
- const errors = validateFiles(fileArray);
178
+
179
+ // Validate all files first
180
+ const validationErrors: string[] = [];
181
+ const validFiles: File[] = [];
182
+
183
+ for (const file of fileArray) {
184
+ const error = validateFile(file);
185
+ if (error) {
186
+ validationErrors.push(error);
187
+ onUploadError?.(error, file);
188
+ } else {
189
+ validFiles.push(file);
190
+ }
191
+ }
192
+
193
+ if (validFiles.length === 0) {
194
+ return;
195
+ }
196
+
197
+ // Initialize upload states
198
+ const newUploadStates = new Map<string, FileUploadState>();
199
+
200
+ for (const file of validFiles) {
201
+ const fileId = `${file.name}-${file.size}-${Date.now()}`;
202
+ const preview = showPreview ? (await generatePreview(file)) || undefined : undefined;
203
+
204
+ const progress: UploadProgress = {
205
+ loaded: 0,
206
+ total: file.size,
207
+ percentage: 0,
208
+ fileName: file.name,
209
+ status: 'idle'
210
+ };
211
+
212
+ newUploadStates.set(fileId, {
213
+ file,
214
+ progress,
215
+ preview
216
+ });
217
+ }
218
+
219
+ setUploadStates(newUploadStates);
220
+
221
+ // Upload files sequentially
222
+ for (const [fileId, uploadState] of newUploadStates.entries()) {
223
+ const { file } = uploadState;
224
+
225
+ // Update status to uploading
226
+ setUploadStates(prev => {
227
+ const updated = new Map(prev);
228
+ const state = updated.get(fileId);
229
+ if (state) {
230
+ updated.set(fileId, {
231
+ ...state,
232
+ progress: {
233
+ ...state.progress,
234
+ status: 'uploading'
235
+ }
236
+ });
237
+ }
238
+ return updated;
239
+ });
240
+
241
+ try {
242
+ // Simulate progress updates (Supabase doesn't provide real progress, so we estimate)
243
+ const progressInterval = setInterval(() => {
244
+ setUploadStates(prev => {
245
+ const updated = new Map(prev);
246
+ const state = updated.get(fileId);
247
+ if (state && state.progress.status === 'uploading') {
248
+ const estimatedProgress = Math.min(
249
+ state.progress.percentage + 10,
250
+ 90
251
+ );
252
+ const newProgress: UploadProgress = {
253
+ ...state.progress,
254
+ percentage: estimatedProgress,
255
+ loaded: Math.floor((estimatedProgress / 100) * file.size)
256
+ };
257
+ onProgress?.(newProgress);
258
+ updated.set(fileId, {
259
+ ...state,
260
+ progress: newProgress
261
+ });
262
+ }
263
+ return updated;
264
+ });
265
+ }, 200);
266
+
267
+
268
+ // Use resolved app_id
269
+ if (!resolvedAppId) {
270
+ const errorMsg = appIdError || 'App ID not available. Please provide app_id prop or set app name.';
271
+ throw new Error(errorMsg);
272
+ }
78
273
 
79
- setValidationErrors(errors);
80
- setSelectedFiles(fileArray);
81
- }, [validateFiles]);
274
+ const result = await uploadFile({
275
+ table_name,
276
+ record_id,
277
+ organisation_id,
278
+ app_id: resolvedAppId,
279
+ category,
280
+ is_public: isPublic
281
+ }, file);
82
282
 
83
- // Handle drag events
84
- const handleDrag = useCallback((e: React.DragEvent) => {
283
+ clearInterval(progressInterval);
284
+
285
+ if (result) {
286
+ // Update status to completed
287
+ setUploadStates(prev => {
288
+ const updated = new Map(prev);
289
+ const state = updated.get(fileId);
290
+ if (state) {
291
+ updated.set(fileId, {
292
+ ...state,
293
+ progress: {
294
+ ...state.progress,
295
+ status: 'completed',
296
+ percentage: 100,
297
+ loaded: file.size
298
+ },
299
+ result
300
+ });
301
+ }
302
+ return updated;
303
+ });
304
+
305
+ const finalProgress: UploadProgress = {
306
+ loaded: file.size,
307
+ total: file.size,
308
+ percentage: 100,
309
+ fileName: file.name,
310
+ status: 'completed'
311
+ };
312
+ onProgress?.(finalProgress);
313
+ onUploadSuccess?.(result);
314
+
315
+ // Remove from upload states after a delay
316
+ setTimeout(() => {
317
+ setUploadStates(prev => {
318
+ const updated = new Map(prev);
319
+ updated.delete(fileId);
320
+ return updated;
321
+ });
322
+ }, 3000); // Keep success state for 3 seconds
323
+ } else {
324
+ // Update status to error
325
+ setUploadStates(prev => {
326
+ const updated = new Map(prev);
327
+ const state = updated.get(fileId);
328
+ if (state) {
329
+ updated.set(fileId, {
330
+ ...state,
331
+ progress: {
332
+ ...state.progress,
333
+ status: 'error',
334
+ error: 'Upload failed'
335
+ }
336
+ });
337
+ }
338
+ return updated;
339
+ });
340
+
341
+ const errorProgress: UploadProgress = {
342
+ loaded: 0,
343
+ total: file.size,
344
+ percentage: 0,
345
+ fileName: file.name,
346
+ status: 'error',
347
+ error: 'Upload failed'
348
+ };
349
+ onProgress?.(errorProgress);
350
+ onUploadError?.('Upload failed', file);
351
+ }
352
+ } catch (err) {
353
+ const errorMessage = err instanceof Error ? err.message : 'Upload failed';
354
+
355
+ setUploadStates(prev => {
356
+ const updated = new Map(prev);
357
+ const state = updated.get(fileId);
358
+ if (state) {
359
+ updated.set(fileId, {
360
+ ...state,
361
+ progress: {
362
+ ...state.progress,
363
+ status: 'error',
364
+ error: errorMessage
365
+ }
366
+ });
367
+ }
368
+ return updated;
369
+ });
370
+
371
+ const errorProgress: UploadProgress = {
372
+ loaded: 0,
373
+ total: file.size,
374
+ percentage: 0,
375
+ fileName: file.name,
376
+ status: 'error',
377
+ error: errorMessage
378
+ };
379
+ onProgress?.(errorProgress);
380
+ onUploadError?.(errorMessage, file);
381
+ }
382
+ }
383
+ }, [uploadFile, table_name, record_id, organisation_id, resolvedAppId, category, isPublic, maxSize, onUploadSuccess, onUploadError, onProgress, validateFile, generatePreview, showPreview, appIdError]);
384
+
385
+ const handleDragOver = useCallback((e: React.DragEvent) => {
85
386
  e.preventDefault();
86
387
  e.stopPropagation();
87
- if (e.type === 'dragenter' || e.type === 'dragover') {
88
- setDragActive(true);
89
- } else if (e.type === 'dragleave') {
90
- setDragActive(false);
388
+ if (!isDisabled) {
389
+ setIsDragging(true);
91
390
  }
391
+ }, [isDisabled]);
392
+
393
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
394
+ e.preventDefault();
395
+ e.stopPropagation();
396
+ setIsDragging(false);
92
397
  }, []);
93
398
 
94
399
  const handleDrop = useCallback((e: React.DragEvent) => {
95
400
  e.preventDefault();
96
401
  e.stopPropagation();
97
- setDragActive(false);
98
-
99
- if (disabled) return;
100
-
402
+ setIsDragging(false);
403
+
404
+ if (isDisabled) return;
405
+
101
406
  const files = e.dataTransfer.files;
102
407
  handleFileSelect(files);
103
- }, [disabled, handleFileSelect]);
408
+ }, [isDisabled, handleFileSelect]);
104
409
 
105
- // Handle file input change
106
410
  const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
107
411
  handleFileSelect(e.target.files);
412
+ // Reset input value to allow re-uploading the same file
413
+ if (e.target) {
414
+ e.target.value = '';
415
+ }
108
416
  }, [handleFileSelect]);
109
417
 
110
- // Handle upload
111
- const handleUpload = useCallback(async () => {
112
- if (selectedFiles.length === 0 || validationErrors.length > 0) return;
113
-
114
- onUploadStart?.();
115
-
116
- for (const file of selectedFiles) {
117
- const result = await uploadWithProgress(file);
118
- onUploadComplete?.(result);
418
+ const handleClick = useCallback(() => {
419
+ if (!isDisabled && fileInputRef.current) {
420
+ fileInputRef.current.click();
119
421
  }
422
+ }, [isDisabled]);
120
423
 
121
- // Reset selection
122
- setSelectedFiles([]);
123
- setValidationErrors([]);
124
- }, [selectedFiles, validationErrors, uploadWithProgress, onUploadComplete, onUploadStart]);
125
-
126
- // Handle clear
127
- const handleClear = useCallback(() => {
128
- setSelectedFiles([]);
129
- setValidationErrors([]);
130
- }, []);
424
+ const formatFileSize = (bytes: number): string => {
425
+ if (bytes === 0) return '0 Bytes';
426
+ const k = 1024;
427
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
428
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
429
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
430
+ };
131
431
 
132
- const canUpload = selectedFiles.length > 0 && validationErrors.length === 0 && !isUploading && !disabled;
432
+ const dragClasses = isDragging ? 'border-main-500 bg-main-50' : 'border-sec-300 hover:border-sec-400';
433
+ const disabledClasses = isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-sec-50';
133
434
 
134
435
  return (
135
- <div className={`file-upload ${className}`}>
136
- {/* Drop zone */}
436
+ <div className={`space-y-4 ${className}`}>
137
437
  <div
138
- className={`file-upload__dropzone ${
139
- dragActive ? 'file-upload__dropzone--active' : ''
140
- } ${disabled ? 'file-upload__dropzone--disabled' : ''}`}
141
- onDragEnter={handleDrag}
142
- onDragLeave={handleDrag}
143
- onDragOver={handleDrag}
438
+ className={`relative border-2 border-dashed rounded-lg p-6 text-center transition-colors ${dragClasses} ${disabledClasses}`}
439
+ onDragOver={handleDragOver}
440
+ onDragLeave={handleDragLeave}
144
441
  onDrop={handleDrop}
442
+ onClick={!isDisabled ? handleClick : undefined}
145
443
  >
146
- <input
147
- type="file"
148
- accept={accept}
149
- multiple={multiple}
150
- onChange={handleFileInputChange}
151
- disabled={disabled}
152
- className="file-upload__input"
153
- />
154
-
155
444
  {children || (
156
- <div className="file-upload__content">
157
- <p className="file-upload__text">
158
- {dragActive ? 'Drop files here' : 'Drag and drop files here or click to select'}
159
- </p>
160
- <Button
161
- type="button"
162
- variant="outline"
163
- disabled={disabled}
164
- onClick={() => (document.querySelector('.file-upload__input') as HTMLInputElement)?.click()}
165
- >
166
- Select Files
167
- </Button>
445
+ <div className="space-y-2">
446
+ <input
447
+ ref={fileInputRef}
448
+ type="file"
449
+ accept={accept}
450
+ multiple={multiple}
451
+ onChange={handleFileInputChange}
452
+ className="hidden"
453
+ disabled={isDisabled}
454
+ data-testid="file-input"
455
+ />
456
+ <div className="text-sec-600">
457
+ {isResolvingAppId ? (
458
+ 'Resolving app configuration...'
459
+ ) : isDragging ? (
460
+ 'Drop files here...'
461
+ ) : (
462
+ <>
463
+ <span className="font-medium">Click to upload</span>
464
+ {' '}or drag and drop
465
+ </>
466
+ )}
467
+ </div>
468
+ <div className="text-sm text-sec-500">
469
+ {!isResolvingAppId && accept !== '*/*' && `Accepted formats: ${accept}`}
470
+ {!isResolvingAppId && maxSize && ` • Max size: ${Math.round(maxSize / 1024 / 1024)}MB`}
471
+ {!isResolvingAppId && multiple && ' • Multiple files allowed'}
472
+ </div>
473
+ </div>
474
+ )}
475
+
476
+ {isUploading && !showProgress && (
477
+ <div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center">
478
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-main-500"></div>
168
479
  </div>
169
480
  )}
170
481
  </div>
171
482
 
172
- {/* Selected files */}
173
- {selectedFiles.length > 0 && (
174
- <div className="file-upload__files">
175
- <h4 className="file-upload__files-title">Selected Files:</h4>
176
- <ul className="file-upload__files-list">
177
- {selectedFiles.map((file, index) => (
178
- <li key={index} className="file-upload__file-item">
179
- <span className="file-upload__file-name">{file.name}</span>
180
- <span className="file-upload__file-size">{formatFileSize(file.size)}</span>
181
- </li>
182
- ))}
183
- </ul>
184
- </div>
185
- )}
483
+ {/* Upload Progress List */}
484
+ {showProgress && uploadStates.size > 0 && (
485
+ <div className="space-y-2">
486
+ {Array.from(uploadStates.entries()).map(([fileId, uploadState]) => {
487
+ const { file, progress, preview, result } = uploadState;
488
+ const isError = progress.status === 'error';
489
+ const isCompleted = progress.status === 'completed';
490
+ const isUploading = progress.status === 'uploading' || progress.status === 'processing';
186
491
 
187
- {/* Validation errors */}
188
- {validationErrors.length > 0 && (
189
- <Alert variant="destructive" className="file-upload__errors">
190
- <ul>
191
- {validationErrors.map((error, index) => (
192
- <li key={index}>{error}</li>
193
- ))}
194
- </ul>
195
- </Alert>
196
- )}
492
+ return (
493
+ <div
494
+ key={fileId}
495
+ className={`flex items-center space-x-3 p-3 rounded-lg border ${
496
+ isError
497
+ ? 'bg-acc-50 border-acc-200'
498
+ : isCompleted
499
+ ? 'bg-success-50 border-success-200'
500
+ : 'bg-sec-50 border-sec-200'
501
+ }`}
502
+ >
503
+ {/* Preview/Icon */}
504
+ <div className="flex-shrink-0">
505
+ {preview ? (
506
+ <img
507
+ src={preview}
508
+ alt={file.name}
509
+ className="w-12 h-12 object-cover rounded"
510
+ />
511
+ ) : (
512
+ <div className="w-12 h-12 flex items-center justify-center bg-sec-200 rounded">
513
+ <span className="text-2xl">📄</span>
514
+ </div>
515
+ )}
516
+ </div>
197
517
 
198
- {/* Upload progress */}
199
- {isUploading && (
200
- <div className="file-upload__progress">
201
- <Progress value={uploadProgress} max={100} />
202
- <p className="file-upload__progress-text">
203
- Uploading... {uploadProgress}%
204
- </p>
518
+ {/* File Info */}
519
+ <div className="flex-1 min-w-0">
520
+ <div className="font-medium text-sec-900 truncate">
521
+ {file.name}
522
+ </div>
523
+ <div className="text-sm text-sec-500">
524
+ {formatFileSize(file.size)}
525
+ {isCompleted && result && ' • Uploaded'}
526
+ {isError && progress.error && ` • ${progress.error}`}
527
+ </div>
528
+
529
+ {/* Progress Bar */}
530
+ {showProgress && (isUploading || isError) && (
531
+ <div className="mt-2">
532
+ <div className="w-full bg-sec-200 rounded-full h-2">
533
+ <div
534
+ className={`h-2 rounded-full transition-all duration-300 ${
535
+ isError ? 'bg-acc-500' : 'bg-main-500'
536
+ }`}
537
+ style={{ width: `${progress.percentage}%` }}
538
+ />
539
+ </div>
540
+ {isUploading && (
541
+ <div className="text-xs text-sec-500 mt-1">
542
+ {progress.percentage}% • {formatFileSize(progress.loaded)} / {formatFileSize(progress.total)}
543
+ </div>
544
+ )}
545
+ </div>
546
+ )}
547
+ </div>
548
+
549
+ {/* Status Icon */}
550
+ <div className="flex-shrink-0">
551
+ {isCompleted && (
552
+ <span className="text-success-500 text-xl">✓</span>
553
+ )}
554
+ {isError && (
555
+ <span className="text-acc-500 text-xl">✕</span>
556
+ )}
557
+ {isUploading && (
558
+ <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-main-500"></div>
559
+ )}
560
+ </div>
561
+ </div>
562
+ );
563
+ })}
205
564
  </div>
206
565
  )}
207
-
208
- {/* Upload error */}
209
- {uploadError && (
210
- <Alert variant="destructive" className="file-upload__error">
211
- {uploadError}
212
- </Alert>
566
+
567
+ {appIdError && (
568
+ <div className="p-3 bg-acc-50 border border-acc-200 rounded-lg text-sm text-acc-600">
569
+ {appIdError}
570
+ </div>
213
571
  )}
214
-
215
- {/* Actions */}
216
- {selectedFiles.length > 0 && (
217
- <div className="file-upload__actions">
218
- <Button
219
- onClick={handleUpload}
220
- disabled={!canUpload}
221
- className="file-upload__upload-btn"
222
- >
223
- {isUploading ? 'Uploading...' : `Upload ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
224
- </Button>
225
- <Button
226
- variant="outline"
227
- onClick={handleClear}
228
- disabled={isUploading}
229
- className="file-upload__clear-btn"
230
- >
231
- Clear
232
- </Button>
572
+ {error && (
573
+ <div className="p-3 bg-acc-50 border border-acc-200 rounded-lg text-sm text-acc-600">
574
+ {error}
233
575
  </div>
234
576
  )}
235
577
  </div>
236
578
  );
237
579
  }
580
+
581
+