@jmruthers/pace-core 0.5.93 → 0.5.94

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 (169) hide show
  1. package/dist/{DataTable-HC5S4RKB.js → DataTable-CHX2EFO3.js} +6 -6
  2. package/dist/{PublicLoadingSpinner-n74JgA9h.d.ts → PublicLoadingSpinner-BWUD6bLU.d.ts} +24 -3
  3. package/dist/{UnifiedAuthProvider-ZM7VUC45.js → UnifiedAuthProvider-H7RI4KYD.js} +3 -3
  4. package/dist/{chunk-AZ2QJYKU.js → chunk-2KLAOD4M.js} +3 -3
  5. package/dist/{chunk-HW5BGOWB.js → chunk-2ZYHCFUO.js} +4 -4
  6. package/dist/{chunk-AAM57AEU.js → chunk-5RYPBJYL.js} +16 -19
  7. package/dist/chunk-5RYPBJYL.js.map +1 -0
  8. package/dist/{chunk-XIBSVWJW.js → chunk-7TQDRDSM.js} +5 -5
  9. package/dist/{chunk-GP3HU6WS.js → chunk-G7UUVEAP.js} +3 -3
  10. package/dist/{chunk-M52CQP5W.js → chunk-MKMKUCPF.js} +762 -12
  11. package/dist/chunk-MKMKUCPF.js.map +1 -0
  12. package/dist/{chunk-OXFOS62D.js → chunk-MVNOAHOP.js} +2 -2
  13. package/dist/{chunk-SVMPR5IV.js → chunk-O6GASC4Q.js} +874 -784
  14. package/dist/chunk-O6GASC4Q.js.map +1 -0
  15. package/dist/{chunk-AYC2P377.js → chunk-ORACUZ7H.js} +2 -2
  16. package/dist/{chunk-TZXYSZT3.js → chunk-PRM6EYO3.js} +298 -238
  17. package/dist/{chunk-TZXYSZT3.js.map → chunk-PRM6EYO3.js.map} +1 -1
  18. package/dist/{chunk-6WFM22A4.js → chunk-ZGCVJ7WW.js} +2 -2
  19. package/dist/components.d.ts +1 -1
  20. package/dist/components.js +8 -8
  21. package/dist/hooks.d.ts +94 -3
  22. package/dist/hooks.js +20 -8
  23. package/dist/hooks.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +17 -11
  26. package/dist/index.js.map +1 -1
  27. package/dist/providers.js +2 -2
  28. package/dist/rbac/index.js +7 -7
  29. package/dist/{usePublicRouteParams-BlgwXweB.d.ts → usePublicRouteParams-BwMR2uub.d.ts} +93 -1
  30. package/dist/utils.js +1 -1
  31. package/docs/api/classes/ColumnFactory.md +1 -1
  32. package/docs/api/classes/ErrorBoundary.md +1 -1
  33. package/docs/api/classes/InvalidScopeError.md +1 -1
  34. package/docs/api/classes/MissingUserContextError.md +1 -1
  35. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  36. package/docs/api/classes/PermissionDeniedError.md +1 -1
  37. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  38. package/docs/api/classes/RBACAuditManager.md +1 -1
  39. package/docs/api/classes/RBACCache.md +1 -1
  40. package/docs/api/classes/RBACEngine.md +1 -1
  41. package/docs/api/classes/RBACError.md +1 -1
  42. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  43. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  44. package/docs/api/classes/StorageUtils.md +1 -1
  45. package/docs/api/enums/FileCategory.md +1 -1
  46. package/docs/api/interfaces/AggregateConfig.md +1 -1
  47. package/docs/api/interfaces/ButtonProps.md +1 -1
  48. package/docs/api/interfaces/CardProps.md +1 -1
  49. package/docs/api/interfaces/ColorPalette.md +1 -1
  50. package/docs/api/interfaces/ColorShade.md +1 -1
  51. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  52. package/docs/api/interfaces/DataRecord.md +1 -1
  53. package/docs/api/interfaces/DataTableAction.md +1 -1
  54. package/docs/api/interfaces/DataTableColumn.md +1 -1
  55. package/docs/api/interfaces/DataTableProps.md +1 -1
  56. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  57. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  58. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  59. package/docs/api/interfaces/EventLogoProps.md +1 -1
  60. package/docs/api/interfaces/FileDisplayProps.md +26 -11
  61. package/docs/api/interfaces/FileMetadata.md +1 -1
  62. package/docs/api/interfaces/FileReference.md +1 -1
  63. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  64. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  65. package/docs/api/interfaces/FileUploadProps.md +1 -1
  66. package/docs/api/interfaces/FooterProps.md +1 -1
  67. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  68. package/docs/api/interfaces/InputProps.md +1 -1
  69. package/docs/api/interfaces/LabelProps.md +1 -1
  70. package/docs/api/interfaces/LoginFormProps.md +1 -1
  71. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  72. package/docs/api/interfaces/NavigationContextType.md +1 -1
  73. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  74. package/docs/api/interfaces/NavigationItem.md +1 -1
  75. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  76. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  77. package/docs/api/interfaces/Organisation.md +1 -1
  78. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  79. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  80. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  81. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  82. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  83. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  84. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  85. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  86. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  87. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  88. package/docs/api/interfaces/PaletteData.md +1 -1
  89. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  90. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  91. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  92. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  93. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  94. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  95. package/docs/api/interfaces/PublicPageHeaderProps.md +24 -11
  96. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  97. package/docs/api/interfaces/RBACConfig.md +1 -1
  98. package/docs/api/interfaces/RBACLogger.md +1 -1
  99. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  100. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  101. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  102. package/docs/api/interfaces/RouteConfig.md +1 -1
  103. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  104. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  105. package/docs/api/interfaces/StorageConfig.md +1 -1
  106. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  107. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  108. package/docs/api/interfaces/StorageListOptions.md +1 -1
  109. package/docs/api/interfaces/StorageListResult.md +1 -1
  110. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  111. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  112. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  113. package/docs/api/interfaces/StyleImport.md +1 -1
  114. package/docs/api/interfaces/SwitchProps.md +1 -1
  115. package/docs/api/interfaces/ToastActionElement.md +1 -1
  116. package/docs/api/interfaces/ToastProps.md +1 -1
  117. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  118. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  119. package/docs/api/interfaces/UseEventLogoOptions.md +1 -1
  120. package/docs/api/interfaces/UseEventLogoReturn.md +1 -1
  121. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  122. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  123. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  124. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  125. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  126. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  127. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +47 -0
  128. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +120 -0
  129. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  130. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  131. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  132. package/docs/api/interfaces/UserEventAccess.md +1 -1
  133. package/docs/api/interfaces/UserMenuProps.md +1 -1
  134. package/docs/api/interfaces/UserProfile.md +1 -1
  135. package/docs/api/modules.md +102 -16
  136. package/docs/implementation-guides/file-reference-system.md +15 -0
  137. package/docs/implementation-guides/file-upload-storage.md +16 -0
  138. package/package.json +1 -1
  139. package/src/components/DataTable/__tests__/DataTableCore.test.tsx +9 -7
  140. package/src/components/DataTable/components/DataTableCore.tsx +8 -1
  141. package/src/components/DataTable/components/EditableRow.tsx +62 -22
  142. package/src/components/DataTable/components/UnifiedTableBody.tsx +25 -101
  143. package/src/components/FileDisplay/FileDisplay.test.tsx +263 -39
  144. package/src/components/FileDisplay/FileDisplay.tsx +605 -83
  145. package/src/components/PublicLayout/PublicPageHeader.tsx +15 -8
  146. package/src/components/PublicLayout/__tests__/PublicPageHeader.test.tsx +71 -28
  147. package/src/components/Select/Select.test.tsx +83 -6
  148. package/src/components/Select/Select.tsx +236 -16
  149. package/src/examples/CorrectPublicPageImplementation.tsx +16 -13
  150. package/src/examples/PublicEventPage.tsx +9 -6
  151. package/src/examples/PublicPageApp.tsx +9 -6
  152. package/src/examples/PublicPageUsageExample.tsx +9 -7
  153. package/src/hooks/index.ts +4 -0
  154. package/src/hooks/public/index.ts +2 -0
  155. package/src/hooks/public/usePublicFileDisplay.ts +355 -0
  156. package/src/hooks/useFileDisplay.ts +370 -0
  157. package/src/services/AuthService.ts +19 -22
  158. package/dist/chunk-AAM57AEU.js.map +0 -1
  159. package/dist/chunk-M52CQP5W.js.map +0 -1
  160. package/dist/chunk-SVMPR5IV.js.map +0 -1
  161. /package/dist/{DataTable-HC5S4RKB.js.map → DataTable-CHX2EFO3.js.map} +0 -0
  162. /package/dist/{UnifiedAuthProvider-ZM7VUC45.js.map → UnifiedAuthProvider-H7RI4KYD.js.map} +0 -0
  163. /package/dist/{chunk-AZ2QJYKU.js.map → chunk-2KLAOD4M.js.map} +0 -0
  164. /package/dist/{chunk-HW5BGOWB.js.map → chunk-2ZYHCFUO.js.map} +0 -0
  165. /package/dist/{chunk-XIBSVWJW.js.map → chunk-7TQDRDSM.js.map} +0 -0
  166. /package/dist/{chunk-GP3HU6WS.js.map → chunk-G7UUVEAP.js.map} +0 -0
  167. /package/dist/{chunk-OXFOS62D.js.map → chunk-MVNOAHOP.js.map} +0 -0
  168. /package/dist/{chunk-AYC2P377.js.map → chunk-ORACUZ7H.js.map} +0 -0
  169. /package/dist/{chunk-6WFM22A4.js.map → chunk-ZGCVJ7WW.js.map} +0 -0
@@ -1,54 +1,79 @@
1
- import React, { useState, useEffect, useCallback, useRef } from 'react';
1
+ import React, { useState, useEffect, useCallback, useRef, useContext } from 'react';
2
2
  import { SupabaseClient } from '@supabase/supabase-js';
3
3
  import { FileReference, FileCategory } from '../../types/file-reference';
4
- import { useFileReferenceForRecord } from '../../hooks/useFileReference';
4
+ import { useFileReferenceForRecord, useFileReference } from '../../hooks/useFileReference';
5
+ import { usePublicFileDisplay } from '../../hooks/public/usePublicFileDisplay';
6
+ import { useFileDisplay } from '../../hooks/useFileDisplay';
5
7
  import { getPublicUrl, getSignedUrl } from '../../utils/storage/helpers';
8
+ import { PublicPageContext, useIsPublicPage } from '../PublicLayout/PublicPageProvider';
9
+ import { useUnifiedAuth } from '../../providers/services/UnifiedAuthProvider';
6
10
 
7
11
  export interface FileDisplayProps {
8
- supabase: SupabaseClient;
12
+ /** Supabase client instance. Optional when used in PublicPageProvider or UnifiedAuthProvider context */
13
+ supabase?: SupabaseClient;
9
14
  table_name: string;
10
15
  record_id: string;
11
16
  organisation_id: string;
12
17
  category?: FileCategory;
18
+ /** Display only a single file instead of all files. Uses first file (prefers images) from all files, without category filtering */
19
+ displayOnly?: boolean;
13
20
  showUpload?: boolean;
14
21
  showDelete?: boolean;
15
22
  className?: string;
16
23
  children?: React.ReactNode;
17
24
  }
18
25
 
19
- export function FileDisplay({
26
+ // Shared rendering logic for file display
27
+ interface FileDisplayContentProps {
28
+ isLoading: boolean;
29
+ error: string | Error | null;
30
+ fileUrl: string | null;
31
+ fileReference: FileReference | null;
32
+ fileReferences: FileReference[];
33
+ fileUrls: Map<string, string>;
34
+ fileCount: number;
35
+ category: FileCategory | undefined;
36
+ displayOnly: boolean;
37
+ showDelete: boolean;
38
+ className: string;
39
+ children?: React.ReactNode;
40
+ onDelete?: () => Promise<void>;
41
+ clearError?: () => void;
42
+ supabase?: SupabaseClient;
43
+ organisation_id: string;
44
+ loadingUrls?: Set<string>;
45
+ }
46
+
47
+ function FileDisplayContent({
48
+ isLoading,
49
+ error,
50
+ fileUrl,
51
+ fileReference,
52
+ fileReferences,
53
+ fileUrls,
54
+ fileCount,
55
+ category,
56
+ displayOnly,
57
+ showDelete,
58
+ className,
59
+ children,
60
+ onDelete,
61
+ clearError,
20
62
  supabase,
21
- table_name,
22
- record_id,
23
63
  organisation_id,
24
- category,
25
- showUpload = false,
26
- showDelete = false,
27
- className = '',
28
- children
29
- }: FileDisplayProps) {
30
- const {
31
- isLoading,
32
- error,
33
- fileUrl,
34
- fileReference,
35
- fileReferences,
36
- fileCount,
37
- loadFileReference,
38
- loadFileUrl,
39
- loadFileReferences,
40
- loadFileCount,
41
- deleteFile,
42
- clearError
43
- } = useFileReferenceForRecord(supabase, table_name, record_id, organisation_id);
44
-
64
+ loadingUrls = new Set()
65
+ }: FileDisplayContentProps) {
45
66
  const [imageError, setImageError] = useState(false);
46
- const [fileUrls, setFileUrls] = useState<Map<string, string>>(new Map());
47
- const [loadingUrls, setLoadingUrls] = useState<Set<string>>(new Set());
67
+ const [internalFileUrls, setInternalFileUrls] = useState<Map<string, string>>(new Map(fileUrls));
48
68
  const loadedFilesRef = useRef<Set<string>>(new Set());
49
- const loadingUrlsRef = useRef<Set<string>>(new Set());
69
+ const loadingUrlsRef = useRef<Set<string>>(new Set(loadingUrls));
50
70
  const fileReferencesRef = useRef<FileReference[]>([]);
51
71
 
72
+ // Sync fileUrls prop with internal state
73
+ useEffect(() => {
74
+ setInternalFileUrls(new Map(fileUrls));
75
+ }, [fileUrls]);
76
+
52
77
  // Track file references to detect when they change
53
78
  useEffect(() => {
54
79
  const currentIds = fileReferences.map(f => f.id).join(',');
@@ -58,31 +83,13 @@ export function FileDisplay({
58
83
  fileReferencesRef.current = fileReferences;
59
84
  // Reset loaded files ref when file references change
60
85
  loadedFilesRef.current.clear();
61
- setFileUrls(new Map());
62
- setLoadingUrls(new Set());
86
+ setInternalFileUrls(new Map());
63
87
  }
64
88
  }, [fileReferences]);
65
89
 
66
- // Load file data on mount
67
- useEffect(() => {
68
- loadFileCount();
69
- if (category) {
70
- loadFileReference();
71
- } else {
72
- loadFileReferences();
73
- }
74
- }, [loadFileCount, loadFileReference, loadFileReferences, category]);
75
-
76
- // Load file URL when file reference is available
90
+ // Fetch URLs for all file references (for multiple files view when not using context hooks)
77
91
  useEffect(() => {
78
- if (fileReference) {
79
- loadFileUrl();
80
- }
81
- }, [fileReference, loadFileUrl]);
82
-
83
- // Fetch URLs for all file references (for multiple files view)
84
- useEffect(() => {
85
- if (category || fileReferences.length === 0) return;
92
+ if (!supabase || category || fileReferences.length === 0) return;
86
93
 
87
94
  const loadFileUrls = async () => {
88
95
  // Find files that need URLs loaded
@@ -93,14 +100,7 @@ export function FileDisplay({
93
100
  if (urlsToLoad.length === 0) return;
94
101
 
95
102
  // Mark files as loading (update both state and ref)
96
- setLoadingUrls(prev => {
97
- const updated = new Set(prev);
98
- urlsToLoad.forEach(fileRef => {
99
- updated.add(fileRef.id);
100
- loadingUrlsRef.current.add(fileRef.id);
101
- });
102
- return updated;
103
- });
103
+ loadingUrlsRef.current = new Set([...loadingUrlsRef.current, ...urlsToLoad.map(f => f.id)]);
104
104
 
105
105
  // Load URLs for files that need them
106
106
  for (const fileRef of urlsToLoad) {
@@ -121,7 +121,7 @@ export function FileDisplay({
121
121
  }
122
122
 
123
123
  if (url) {
124
- setFileUrls(prev => {
124
+ setInternalFileUrls(prev => {
125
125
  const updated = new Map(prev);
126
126
  updated.set(fileRef.id, url!);
127
127
  return updated;
@@ -131,12 +131,7 @@ export function FileDisplay({
131
131
  } catch (error) {
132
132
  console.error(`Failed to load URL for file ${fileRef.id}:`, error);
133
133
  } finally {
134
- setLoadingUrls(prev => {
135
- const updated = new Set(prev);
136
- updated.delete(fileRef.id);
137
- loadingUrlsRef.current.delete(fileRef.id);
138
- return updated;
139
- });
134
+ loadingUrlsRef.current.delete(fileRef.id);
140
135
  }
141
136
  }
142
137
  };
@@ -146,10 +141,10 @@ export function FileDisplay({
146
141
 
147
142
  const handleDelete = async () => {
148
143
  if (window.confirm('Are you sure you want to delete this file?')) {
149
- const success = await deleteFile(true);
150
- if (success) {
151
- setImageError(false);
144
+ if (onDelete) {
145
+ await onDelete();
152
146
  }
147
+ setImageError(false);
153
148
  }
154
149
  };
155
150
 
@@ -188,14 +183,16 @@ export function FileDisplay({
188
183
  return (
189
184
  <div className={`p-4 bg-acc-50 border border-acc-200 rounded-lg ${className}`}>
190
185
  <div className="text-acc-600">
191
- Error loading file: {error}
186
+ Error loading file: {error instanceof Error ? error.message : String(error)}
192
187
  </div>
193
- <button
194
- onClick={clearError}
195
- className="mt-2 text-sm text-acc-700 hover:text-acc-800 underline"
196
- >
197
- Try again
198
- </button>
188
+ {clearError && (
189
+ <button
190
+ onClick={clearError}
191
+ className="mt-2 text-sm text-acc-700 hover:text-acc-800 underline"
192
+ >
193
+ Try again
194
+ </button>
195
+ )}
199
196
  </div>
200
197
  );
201
198
  }
@@ -209,10 +206,23 @@ export function FileDisplay({
209
206
  );
210
207
  }
211
208
 
212
- // Single file display (when category is specified)
213
- if (category && fileReference) {
209
+ // Single file display (when category or displayOnly is specified)
210
+ if ((category || displayOnly) && fileReference) {
214
211
  const isImage = fileReference.file_metadata.fileType?.startsWith('image/');
215
212
 
213
+ // Simplified image-only display when displayOnly is true and it's an image
214
+ if (displayOnly && isImage && fileUrl && !imageError && !showDelete) {
215
+ return (
216
+ <img
217
+ src={fileUrl}
218
+ alt={fileReference.file_metadata.fileName || 'File'}
219
+ className={className || "max-w-full h-auto"}
220
+ onError={handleImageError}
221
+ />
222
+ );
223
+ }
224
+
225
+ // Standard single file display with wrapper
216
226
  return (
217
227
  <div className={`space-y-2 ${className}`}>
218
228
  {isImage && fileUrl && !imageError ? (
@@ -220,7 +230,7 @@ export function FileDisplay({
220
230
  <img
221
231
  src={fileUrl}
222
232
  alt={fileReference.file_metadata.fileName || 'File'}
223
- className="max-w-full h-auto rounded-lg border border-sec-200"
233
+ className="max-w-full h-auto"
224
234
  onError={handleImageError}
225
235
  />
226
236
  {showDelete && (
@@ -270,8 +280,8 @@ export function FileDisplay({
270
280
  <div className={`space-y-2 ${className}`}>
271
281
  {fileReferences.map((fileRef) => {
272
282
  const isImage = fileRef.file_metadata.fileType?.startsWith('image/');
273
- const fileUrl = fileUrls.get(fileRef.id) || null;
274
- const isLoadingUrl = loadingUrls.has(fileRef.id);
283
+ const fileUrl = internalFileUrls.get(fileRef.id) || null;
284
+ const isLoadingUrl = loadingUrlsRef.current.has(fileRef.id);
275
285
  const canDownload = !isImage && fileUrl;
276
286
 
277
287
  return (
@@ -313,9 +323,14 @@ export function FileDisplay({
313
323
 
314
324
  </a>
315
325
  )}
316
- {showDelete && (
326
+ {showDelete && onDelete && (
317
327
  <button
318
- onClick={() => deleteFile(true)}
328
+ onClick={() => {
329
+ // For multiple files, we'd need file-specific delete
330
+ // For now, call onDelete which may handle the first file
331
+ // TODO: Implement file-specific delete functionality
332
+ onDelete();
333
+ }}
319
334
  className="text-acc-500 hover:text-acc-700 p-1"
320
335
  title="Delete file"
321
336
  >
@@ -331,4 +346,511 @@ export function FileDisplay({
331
346
  );
332
347
  }
333
348
 
349
+ /**
350
+ * Internal component for backwards compatibility (when supabase is explicitly provided)
351
+ */
352
+ function FileDisplayBackwardsCompat({
353
+ supabase,
354
+ table_name,
355
+ record_id,
356
+ organisation_id,
357
+ category,
358
+ displayOnly = false,
359
+ showUpload = false,
360
+ showDelete = false,
361
+ className = '',
362
+ children
363
+ }: Required<Pick<FileDisplayProps, 'supabase'>> & Omit<FileDisplayProps, 'supabase'>) {
364
+ const {
365
+ isLoading: isLoadingForRecord,
366
+ error: errorForRecord,
367
+ fileUrl,
368
+ fileReference,
369
+ fileReferences,
370
+ fileCount,
371
+ loadFileReference,
372
+ loadFileUrl,
373
+ loadFileReferences,
374
+ loadFileCount,
375
+ deleteFile,
376
+ clearError
377
+ } = useFileReferenceForRecord(supabase, table_name, record_id, organisation_id);
378
+
379
+ // Use useFileReference to get getFilesByCategory when category is provided
380
+ const {
381
+ getFilesByCategory,
382
+ isLoading: isLoadingCategory,
383
+ error: errorCategory
384
+ } = useFileReference(supabase);
385
+
386
+ const [categoryFileReference, setCategoryFileReference] = useState<FileReference | null>(null);
387
+ const [categoryFileUrl, setCategoryFileUrl] = useState<string | null>(null);
388
+ const [categoryFileReferences, setCategoryFileReferences] = useState<FileReference[]>([]);
389
+ const [displayOnlyFileReference, setDisplayOnlyFileReference] = useState<FileReference | null>(null);
390
+ const [displayOnlyFileUrl, setDisplayOnlyFileUrl] = useState<string | null>(null);
391
+
392
+ // Load files by category when category is provided
393
+ useEffect(() => {
394
+ if (category) {
395
+ const loadCategoryFiles = async () => {
396
+ try {
397
+ const files = await getFilesByCategory(table_name, record_id, category, organisation_id);
398
+ setCategoryFileReferences(files);
399
+
400
+ if (files.length > 0) {
401
+ const firstFile = files[0];
402
+ setCategoryFileReference(firstFile);
403
+
404
+ // Generate URL for the file
405
+ let url: string | null = null;
406
+ if (firstFile.is_public) {
407
+ url = getPublicUrl(supabase, firstFile.file_path, true);
408
+ } else {
409
+ const signedUrlResult = await getSignedUrl(supabase, firstFile.file_path, {
410
+ appName: 'file-reference',
411
+ orgId: organisation_id,
412
+ expiresIn: 3600
413
+ });
414
+ url = signedUrlResult?.url || null;
415
+ }
416
+ setCategoryFileUrl(url);
417
+ } else {
418
+ setCategoryFileReference(null);
419
+ setCategoryFileUrl(null);
420
+ }
421
+ } catch (err) {
422
+ console.error('[FileDisplayBackwardsCompat] Error loading files by category:', err);
423
+ }
424
+ };
425
+
426
+ loadCategoryFiles();
427
+ } else {
428
+ // Clear category files when no category
429
+ setCategoryFileReference(null);
430
+ setCategoryFileUrl(null);
431
+ setCategoryFileReferences([]);
432
+ }
433
+ }, [category, table_name, record_id, organisation_id, supabase, getFilesByCategory]);
434
+
435
+ // Load file data on mount (when no category)
436
+ useEffect(() => {
437
+ if (!category) {
438
+ loadFileCount();
439
+ loadFileReferences();
440
+ }
441
+ }, [loadFileCount, loadFileReferences, category]);
442
+
443
+ // Load file URL when file reference is available (when no category)
444
+ useEffect(() => {
445
+ if (!category && fileReference) {
446
+ loadFileUrl();
447
+ }
448
+ }, [category, fileReference, loadFileUrl]);
449
+
450
+ // Handle displayOnly mode: select first file (prefer images) from all files
451
+ useEffect(() => {
452
+ if (displayOnly && !category && fileReferences.length > 0) {
453
+ const loadDisplayOnlyFile = async () => {
454
+ try {
455
+ // Prefer image files
456
+ const imageFiles = fileReferences.filter(f =>
457
+ f.file_metadata.fileType?.startsWith('image/')
458
+ );
459
+ const targetFile = imageFiles.length > 0 ? imageFiles[0] : fileReferences[0];
460
+
461
+ setDisplayOnlyFileReference(targetFile);
462
+
463
+ // Generate URL for the file
464
+ let url: string | null = null;
465
+ if (targetFile.is_public) {
466
+ url = getPublicUrl(supabase, targetFile.file_path, true);
467
+ } else {
468
+ const signedUrlResult = await getSignedUrl(supabase, targetFile.file_path, {
469
+ appName: 'file-reference',
470
+ orgId: organisation_id,
471
+ expiresIn: 3600
472
+ });
473
+ url = signedUrlResult?.url || null;
474
+ }
475
+ setDisplayOnlyFileUrl(url);
476
+ } catch (err) {
477
+ console.error('[FileDisplayBackwardsCompat] Error loading displayOnly file:', err);
478
+ }
479
+ };
480
+
481
+ loadDisplayOnlyFile();
482
+ } else if (!displayOnly || category) {
483
+ // Clear displayOnly files when not in displayOnly mode or when category is specified
484
+ setDisplayOnlyFileReference(null);
485
+ setDisplayOnlyFileUrl(null);
486
+ }
487
+ }, [displayOnly, category, fileReferences, supabase, organisation_id]);
488
+
489
+ const handleDelete = async () => {
490
+ if (window.confirm('Are you sure you want to delete this file?')) {
491
+ await deleteFile(true);
492
+ }
493
+ };
494
+
495
+ // Determine final file reference and URL based on mode
496
+ // Priority: category > displayOnly > default (all files)
497
+ let finalFileReference: FileReference | null = null;
498
+ let finalFileUrl: string | null = null;
499
+ let finalFileReferences: FileReference[] = [];
500
+ let finalFileCount = 0;
501
+ let finalIsLoading = false;
502
+ let finalError: string | Error | null = null;
503
+
504
+ if (category) {
505
+ // Category mode: use category-filtered files
506
+ finalFileReference = categoryFileReference;
507
+ finalFileUrl = categoryFileUrl;
508
+ finalFileReferences = categoryFileReferences;
509
+ finalFileCount = categoryFileReferences.length;
510
+ finalIsLoading = isLoadingCategory;
511
+ finalError = errorCategory;
512
+ } else if (displayOnly) {
513
+ // DisplayOnly mode: use first file from all files (prefers images)
514
+ finalFileReference = displayOnlyFileReference;
515
+ finalFileUrl = displayOnlyFileUrl;
516
+ finalFileReferences = displayOnlyFileReference ? [displayOnlyFileReference] : [];
517
+ finalFileCount = displayOnlyFileReference ? 1 : 0;
518
+ finalIsLoading = isLoadingForRecord;
519
+ finalError = errorForRecord;
520
+ } else {
521
+ // Default mode: show all files
522
+ finalFileReference = fileReference;
523
+ finalFileUrl = fileUrl;
524
+ finalFileReferences = fileReferences;
525
+ finalFileCount = fileCount;
526
+ finalIsLoading = isLoadingForRecord;
527
+ finalError = errorForRecord;
528
+ }
529
+
530
+ return (
531
+ <FileDisplayContent
532
+ isLoading={finalIsLoading}
533
+ error={finalError}
534
+ fileUrl={finalFileUrl}
535
+ fileReference={finalFileReference}
536
+ fileReferences={finalFileReferences}
537
+ fileUrls={new Map()} // Will be populated by FileDisplayContent's useEffect
538
+ fileCount={finalFileCount}
539
+ category={category}
540
+ displayOnly={displayOnly}
541
+ showDelete={showDelete}
542
+ className={className}
543
+ children={children}
544
+ onDelete={handleDelete}
545
+ clearError={clearError}
546
+ supabase={supabase}
547
+ organisation_id={organisation_id}
548
+ loadingUrls={new Set()}
549
+ />
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Internal component for public page context
555
+ * Uses PublicPageContext to get Supabase client
556
+ */
557
+ function FileDisplayPublic({
558
+ table_name,
559
+ record_id,
560
+ organisation_id,
561
+ category,
562
+ displayOnly = false,
563
+ showUpload = false,
564
+ showDelete = false,
565
+ className = '',
566
+ children
567
+ }: Omit<FileDisplayProps, 'supabase'>) {
568
+ const publicPageContext = useContext(PublicPageContext);
569
+ const supabase = publicPageContext?.supabase ?? null;
570
+
571
+ if (!supabase) {
572
+ return (
573
+ <div className={`text-sec-500 text-center p-4 ${className}`}>
574
+ Supabase client not available in public context
575
+ </div>
576
+ );
577
+ }
578
+
579
+ const {
580
+ fileUrl,
581
+ fileReference,
582
+ fileReferences,
583
+ fileUrls,
584
+ fileCount,
585
+ isLoading,
586
+ error,
587
+ refetch
588
+ } = usePublicFileDisplay(
589
+ table_name,
590
+ record_id,
591
+ organisation_id,
592
+ category,
593
+ { supabase }
594
+ );
334
595
 
596
+ // Note: Public context doesn't support delete operations
597
+ const handleDelete = async () => {
598
+ console.warn('[FileDisplay] Delete operation not supported in public context');
599
+ };
600
+
601
+ // Handle displayOnly mode: select first file (prefer images) from all files
602
+ let finalFileReference = fileReference;
603
+ let finalFileUrl = fileUrl;
604
+ let finalFileReferences = fileReferences;
605
+ let finalFileCount = fileCount;
606
+
607
+ if (displayOnly && !category && fileReferences.length > 0) {
608
+ // Prefer image files
609
+ const imageFiles = fileReferences.filter(f =>
610
+ f.file_metadata.fileType?.startsWith('image/')
611
+ );
612
+ const targetFile = imageFiles.length > 0 ? imageFiles[0] : fileReferences[0];
613
+ finalFileReference = targetFile;
614
+ finalFileReferences = [targetFile];
615
+ finalFileCount = 1;
616
+
617
+ // Get URL for target file from fileUrls map
618
+ finalFileUrl = fileUrls.get(targetFile.id) || null;
619
+ }
620
+
621
+ return (
622
+ <FileDisplayContent
623
+ isLoading={isLoading}
624
+ error={error}
625
+ fileUrl={finalFileUrl}
626
+ fileReference={finalFileReference}
627
+ fileReferences={finalFileReferences}
628
+ fileUrls={fileUrls}
629
+ fileCount={finalFileCount}
630
+ category={category}
631
+ displayOnly={displayOnly}
632
+ showDelete={false} // Never show delete in public context
633
+ className={className}
634
+ children={children}
635
+ onDelete={showDelete ? handleDelete : undefined}
636
+ supabase={supabase}
637
+ organisation_id={organisation_id}
638
+ />
639
+ );
640
+ }
641
+
642
+ /**
643
+ * Internal component for authenticated page context
644
+ * Uses UnifiedAuthProvider to get Supabase client
645
+ */
646
+ function FileDisplayAuthenticated({
647
+ table_name,
648
+ record_id,
649
+ organisation_id,
650
+ category,
651
+ displayOnly = false,
652
+ showUpload = false,
653
+ showDelete = false,
654
+ className = '',
655
+ children
656
+ }: Omit<FileDisplayProps, 'supabase'>) {
657
+ const { supabase } = useUnifiedAuth();
658
+
659
+ if (!supabase) {
660
+ return (
661
+ <div className={`text-sec-500 text-center p-4 ${className}`}>
662
+ Supabase client not available in authenticated context
663
+ </div>
664
+ );
665
+ }
666
+
667
+ const {
668
+ fileUrl,
669
+ fileReference,
670
+ fileReferences,
671
+ fileUrls,
672
+ fileCount,
673
+ isLoading,
674
+ error,
675
+ refetch
676
+ } = useFileDisplay(
677
+ table_name,
678
+ record_id,
679
+ organisation_id,
680
+ category,
681
+ { supabase }
682
+ );
683
+
684
+ // State for displayOnly mode URL generation
685
+ const [displayOnlyFileReference, setDisplayOnlyFileReference] = useState<FileReference | null>(null);
686
+ const [displayOnlyFileUrl, setDisplayOnlyFileUrl] = useState<string | null>(null);
687
+
688
+ // Handle displayOnly mode: select first file (prefer images) from all files and generate URL on-demand
689
+ useEffect(() => {
690
+ if (displayOnly && !category && fileReferences.length > 0) {
691
+ const loadDisplayOnlyFile = async () => {
692
+ try {
693
+ // Prefer image files
694
+ const imageFiles = fileReferences.filter(f =>
695
+ f.file_metadata.fileType?.startsWith('image/')
696
+ );
697
+ const targetFile = imageFiles.length > 0 ? imageFiles[0] : fileReferences[0];
698
+
699
+ setDisplayOnlyFileReference(targetFile);
700
+
701
+ // Check if URL exists in fileUrls Map first
702
+ const existingUrl = fileUrls.get(targetFile.id);
703
+ if (existingUrl) {
704
+ setDisplayOnlyFileUrl(existingUrl);
705
+ return;
706
+ }
707
+
708
+ // Generate URL for the file if not in Map
709
+ let url: string | null = null;
710
+ if (targetFile.is_public) {
711
+ url = getPublicUrl(supabase, targetFile.file_path, true);
712
+ } else {
713
+ const signedUrlResult = await getSignedUrl(supabase, targetFile.file_path, {
714
+ appName: 'file-reference',
715
+ orgId: organisation_id,
716
+ expiresIn: 3600
717
+ });
718
+ url = signedUrlResult?.url || null;
719
+ }
720
+ setDisplayOnlyFileUrl(url);
721
+ } catch (err) {
722
+ console.error('[FileDisplayAuthenticated] Error loading displayOnly file:', err);
723
+ }
724
+ };
725
+
726
+ loadDisplayOnlyFile();
727
+ } else if (!displayOnly || category) {
728
+ // Clear displayOnly files when not in displayOnly mode or when category is specified
729
+ setDisplayOnlyFileReference(null);
730
+ setDisplayOnlyFileUrl(null);
731
+ }
732
+ }, [displayOnly, category, fileReferences, fileUrls, supabase, organisation_id]);
733
+
734
+ // Note: Delete would need to be implemented via FileReferenceService
735
+ // For now, we'll show a warning
736
+ const handleDelete = async () => {
737
+ console.warn('[FileDisplay] Delete operation needs to be implemented via FileReferenceService');
738
+ };
739
+
740
+ // Determine final file reference and URL based on mode
741
+ let finalFileReference = fileReference;
742
+ let finalFileUrl = fileUrl;
743
+ let finalFileReferences = fileReferences;
744
+ let finalFileCount = fileCount;
745
+
746
+ if (displayOnly && !category) {
747
+ // DisplayOnly mode: use state-managed file reference and URL
748
+ finalFileReference = displayOnlyFileReference;
749
+ finalFileUrl = displayOnlyFileUrl;
750
+ finalFileReferences = displayOnlyFileReference ? [displayOnlyFileReference] : [];
751
+ finalFileCount = displayOnlyFileReference ? 1 : 0;
752
+ }
753
+
754
+ return (
755
+ <FileDisplayContent
756
+ isLoading={isLoading}
757
+ error={error}
758
+ fileUrl={finalFileUrl}
759
+ fileReference={finalFileReference}
760
+ fileReferences={finalFileReferences}
761
+ fileUrls={fileUrls}
762
+ fileCount={finalFileCount}
763
+ category={category}
764
+ displayOnly={displayOnly}
765
+ showDelete={showDelete}
766
+ className={className}
767
+ children={children}
768
+ onDelete={showDelete ? handleDelete : undefined}
769
+ supabase={supabase}
770
+ organisation_id={organisation_id}
771
+ />
772
+ );
773
+ }
774
+
775
+ /**
776
+ * Component for displaying file references with context-awareness
777
+ *
778
+ * This component is context-aware and automatically detects whether it's being used
779
+ * in a public or authenticated context. It fetches and displays files from storage.
780
+ *
781
+ * When `supabase` prop is provided, it uses the explicit client (backwards compatible).
782
+ * When `supabase` prop is not provided, it automatically detects context and uses:
783
+ * - PublicPageProvider context for public pages
784
+ * - UnifiedAuthProvider context for authenticated pages
785
+ *
786
+ * @param props - File display configuration
787
+ * @param props.displayOnly - Display only a single file instead of all files. Uses first file (prefers images) from all files, without category filtering. When true with an image, renders a simplified image-only display without metadata or wrapper divs.
788
+ * @param props.category - Optional category filter. When specified, only displays files matching this category and uses single file display variant.
789
+ * @returns React element with file display
790
+ */
791
+ export function FileDisplay({
792
+ supabase,
793
+ table_name,
794
+ record_id,
795
+ organisation_id,
796
+ category,
797
+ displayOnly = false,
798
+ showUpload = false,
799
+ showDelete = false,
800
+ className = '',
801
+ children
802
+ }: FileDisplayProps) {
803
+ // If supabase is explicitly provided, use backwards compatible mode
804
+ if (supabase) {
805
+ return (
806
+ <FileDisplayBackwardsCompat
807
+ supabase={supabase}
808
+ table_name={table_name}
809
+ record_id={record_id}
810
+ organisation_id={organisation_id}
811
+ category={category}
812
+ displayOnly={displayOnly}
813
+ showUpload={showUpload}
814
+ showDelete={showDelete}
815
+ className={className}
816
+ children={children}
817
+ />
818
+ );
819
+ }
820
+
821
+ // Check which context we're in and route to the appropriate component
822
+ const isPublicPage = useIsPublicPage();
823
+
824
+ // If we're in a public page context, use the public component
825
+ if (isPublicPage) {
826
+ return (
827
+ <FileDisplayPublic
828
+ table_name={table_name}
829
+ record_id={record_id}
830
+ organisation_id={organisation_id}
831
+ category={category}
832
+ displayOnly={displayOnly}
833
+ showUpload={showUpload}
834
+ showDelete={showDelete}
835
+ className={className}
836
+ children={children}
837
+ />
838
+ );
839
+ }
840
+
841
+ // Otherwise, try to use the authenticated component
842
+ // It will show an error if not in UnifiedAuthProvider
843
+ return (
844
+ <FileDisplayAuthenticated
845
+ table_name={table_name}
846
+ record_id={record_id}
847
+ organisation_id={organisation_id}
848
+ category={category}
849
+ displayOnly={displayOnly}
850
+ showUpload={showUpload}
851
+ showDelete={showDelete}
852
+ className={className}
853
+ children={children}
854
+ />
855
+ );
856
+ }