|
1
|
// Memory-optimized React components with memoization |
|
2
|
import { memo, useMemo, useCallback, useState, useRef, useEffect } from 'react'; |
|
3
|
import type { RepairGuide, TroubleshootingFlow, DeviceComponent } from '@shared/schema'; |
|
4
|
|
|
5
|
// Memoized guide card for better rendering performance |
|
6
|
export const MemoizedGuideCard = memo(({ |
|
7
|
guide, |
|
8
|
onViewGuide |
|
9
|
}: { |
|
10
|
guide: RepairGuide; |
|
11
|
onViewGuide: (id: string) => void; |
|
12
|
}) => { |
|
13
|
const handleClick = useCallback(() => { |
|
14
|
onViewGuide(guide.id); |
|
15
|
}, [guide.id, onViewGuide]); |
|
16
|
|
|
17
|
const difficultyColor = useMemo(() => { |
|
18
|
switch (guide.difficulty) { |
|
19
|
case "easy": return "bg-success-50 text-success-600"; |
|
20
|
case "medium": return "bg-warning-50 text-warning-700"; |
|
21
|
case "hard": return "bg-danger-50 text-danger-700"; |
|
22
|
default: return "bg-gray-50 text-gray-600"; |
|
23
|
} |
|
24
|
}, [guide.difficulty]); |
|
25
|
|
|
26
|
const formattedViewCount = useMemo(() => { |
|
27
|
const count = guide.viewCount || 0; |
|
28
|
return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : count.toString(); |
|
29
|
}, [guide.viewCount]); |
|
30
|
|
|
31
|
return ( |
|
32
|
<div |
|
33
|
className="bg-white dark:bg-gray-800 amoled:bg-black rounded-xl shadow-material hover:shadow-material-lg transition-shadow cursor-pointer" |
|
34
|
onClick={handleClick} |
|
35
|
data-testid={`guide-card-${guide.id}`} |
|
36
|
> |
|
37
|
<img |
|
38
|
src={guide.imageUrl} |
|
39
|
alt={guide.title} |
|
40
|
className="w-full h-48 object-cover rounded-t-xl" |
|
41
|
loading="lazy" |
|
42
|
onError={(e) => { |
|
43
|
(e.target as HTMLImageElement).style.display = 'none'; |
|
44
|
}} |
|
45
|
/> |
|
46
|
<div className="p-6"> |
|
47
|
<div className="flex items-center justify-between mb-3"> |
|
48
|
<span className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${difficultyColor}`}> |
|
49
|
{guide.difficulty.toUpperCase()} |
|
50
|
</span> |
|
51
|
<span className="text-sm text-gray-500"> |
|
52
|
{formattedViewCount} views |
|
53
|
</span> |
|
54
|
</div> |
|
55
|
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white"> |
|
56
|
{guide.title} |
|
57
|
</h3> |
|
58
|
<p className="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> |
|
59
|
{guide.description} |
|
60
|
</p> |
|
61
|
</div> |
|
62
|
</div> |
|
63
|
); |
|
64
|
}); |
|
65
|
|
|
66
|
MemoizedGuideCard.displayName = 'MemoizedGuideCard'; |
|
67
|
|
|
68
|
// Virtual list component for large datasets |
|
69
|
interface VirtualListProps<T> { |
|
70
|
items: T[]; |
|
71
|
itemHeight: number; |
|
72
|
containerHeight: number; |
|
73
|
renderItem: (item: T, index: number) => React.ReactNode; |
|
74
|
overscan?: number; |
|
75
|
} |
|
76
|
|
|
77
|
export function VirtualList<T>({ |
|
78
|
items, |
|
79
|
itemHeight, |
|
80
|
containerHeight, |
|
81
|
renderItem, |
|
82
|
overscan = 5 |
|
83
|
}: VirtualListProps<T>) { |
|
84
|
const [scrollTop, setScrollTop] = useState(0); |
|
85
|
|
|
86
|
const startIndex = useMemo(() => { |
|
87
|
return Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); |
|
88
|
}, [scrollTop, itemHeight, overscan]); |
|
89
|
|
|
90
|
const endIndex = useMemo(() => { |
|
91
|
return Math.min( |
|
92
|
items.length, |
|
93
|
startIndex + Math.ceil(containerHeight / itemHeight) + overscan * 2 |
|
94
|
); |
|
95
|
}, [items.length, startIndex, containerHeight, itemHeight, overscan]); |
|
96
|
|
|
97
|
const visibleItems = useMemo(() => { |
|
98
|
return items.slice(startIndex, endIndex); |
|
99
|
}, [items, startIndex, endIndex]); |
|
100
|
|
|
101
|
const totalHeight = items.length * itemHeight; |
|
102
|
const offsetY = startIndex * itemHeight; |
|
103
|
|
|
104
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { |
|
105
|
setScrollTop(e.currentTarget.scrollTop); |
|
106
|
}, []); |
|
107
|
|
|
108
|
return ( |
|
109
|
<div |
|
110
|
className="overflow-auto" |
|
111
|
style={{ height: containerHeight }} |
|
112
|
onScroll={handleScroll} |
|
113
|
data-testid="virtual-list" |
|
114
|
> |
|
115
|
<div style={{ height: totalHeight, position: 'relative' }}> |
|
116
|
<div style={{ transform: `translateY(${offsetY}px)` }}> |
|
117
|
{visibleItems.map((item, index) => ( |
|
118
|
<div |
|
119
|
key={startIndex + index} |
|
120
|
style={{ height: itemHeight }} |
|
121
|
className="virtual-list-item" |
|
122
|
> |
|
123
|
{renderItem(item, startIndex + index)} |
|
124
|
</div> |
|
125
|
))} |
|
126
|
</div> |
|
127
|
</div> |
|
128
|
</div> |
|
129
|
); |
|
130
|
} |
|
131
|
|
|
132
|
// Memoized search results component |
|
133
|
export const MemoizedSearchResults = memo(({ |
|
134
|
results, |
|
135
|
searchTerm, |
|
136
|
onResultClick |
|
137
|
}: { |
|
138
|
results: RepairGuide[]; |
|
139
|
searchTerm: string; |
|
140
|
onResultClick: (guide: RepairGuide) => void; |
|
141
|
}) => { |
|
142
|
const highlightedResults = useMemo(() => { |
|
143
|
if (!searchTerm) return results; |
|
144
|
|
|
145
|
return results.map(guide => ({ |
|
146
|
...guide, |
|
147
|
highlightedTitle: guide.title.replace( |
|
148
|
new RegExp(searchTerm, 'gi'), |
|
149
|
match => `<mark>${match}</mark>` |
|
150
|
) |
|
151
|
})); |
|
152
|
}, [results, searchTerm]); |
|
153
|
|
|
154
|
const handleResultClick = useCallback((guide: RepairGuide) => { |
|
155
|
onResultClick(guide); |
|
156
|
}, [onResultClick]); |
|
157
|
|
|
158
|
if (results.length === 0) { |
|
159
|
return ( |
|
160
|
<div className="text-center py-8 text-gray-500"> |
|
161
|
No results found for "{searchTerm}" |
|
162
|
</div> |
|
163
|
); |
|
164
|
} |
|
165
|
|
|
166
|
return ( |
|
167
|
<div className="space-y-2" data-testid="search-results"> |
|
168
|
{highlightedResults.map(guide => ( |
|
169
|
<div |
|
170
|
key={guide.id} |
|
171
|
className="p-3 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" |
|
172
|
onClick={() => handleResultClick(guide)} |
|
173
|
data-testid={`search-result-${guide.id}`} |
|
174
|
> |
|
175
|
<h4 |
|
176
|
className="font-medium text-gray-900 dark:text-white" |
|
177
|
dangerouslySetInnerHTML={{ __html: guide.highlightedTitle || guide.title }} |
|
178
|
/> |
|
179
|
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1"> |
|
180
|
{guide.description} |
|
181
|
</p> |
|
182
|
</div> |
|
183
|
))} |
|
184
|
</div> |
|
185
|
); |
|
186
|
}); |
|
187
|
|
|
188
|
MemoizedSearchResults.displayName = 'MemoizedSearchResults'; |
|
189
|
|
|
190
|
// Optimized image gallery component |
|
191
|
export const OptimizedImageGallery = memo(({ |
|
192
|
images, |
|
193
|
onImageClick |
|
194
|
}: { |
|
195
|
images: string[]; |
|
196
|
onImageClick: (src: string) => void; |
|
197
|
}) => { |
|
198
|
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set()); |
|
199
|
|
|
200
|
const handleImageLoad = useCallback((src: string) => { |
|
201
|
setLoadedImages(prev => new Set(prev).add(src)); |
|
202
|
}, []); |
|
203
|
|
|
204
|
const handleImageClick = useCallback((src: string) => { |
|
205
|
onImageClick(src); |
|
206
|
}, [onImageClick]); |
|
207
|
|
|
208
|
return ( |
|
209
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" data-testid="image-gallery"> |
|
210
|
{images.map((src, index) => ( |
|
211
|
<div |
|
212
|
key={src} |
|
213
|
className="aspect-square relative overflow-hidden rounded-lg bg-gray-200 dark:bg-gray-700" |
|
214
|
> |
|
215
|
<img |
|
216
|
src={src} |
|
217
|
alt={`Gallery image ${index + 1}`} |
|
218
|
className={`w-full h-full object-cover cursor-pointer transition-opacity duration-300 ${ |
|
219
|
loadedImages.has(src) ? 'opacity-100' : 'opacity-0' |
|
220
|
}`} |
|
221
|
loading="lazy" |
|
222
|
onClick={() => handleImageClick(src)} |
|
223
|
onLoad={() => handleImageLoad(src)} |
|
224
|
onError={(e) => { |
|
225
|
(e.target as HTMLImageElement).style.display = 'none'; |
|
226
|
}} |
|
227
|
data-testid={`gallery-image-${index}`} |
|
228
|
/> |
|
229
|
{!loadedImages.has(src) && ( |
|
230
|
<div className="absolute inset-0 flex items-center justify-center"> |
|
231
|
<div className="w-8 h-8 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" /> |
|
232
|
</div> |
|
233
|
)} |
|
234
|
</div> |
|
235
|
))} |
|
236
|
</div> |
|
237
|
); |
|
238
|
}); |
|
239
|
|
|
240
|
OptimizedImageGallery.displayName = 'OptimizedImageGallery'; |
|
241
|
|
|
242
|
// Memory-efficient infinite scroll component |
|
243
|
interface InfiniteScrollProps<T> { |
|
244
|
items: T[]; |
|
245
|
loadMore: () => Promise<void>; |
|
246
|
hasMore: boolean; |
|
247
|
loading: boolean; |
|
248
|
renderItem: (item: T, index: number) => React.ReactNode; |
|
249
|
threshold?: number; |
|
250
|
} |
|
251
|
|
|
252
|
export function InfiniteScroll<T>({ |
|
253
|
items, |
|
254
|
loadMore, |
|
255
|
hasMore, |
|
256
|
loading, |
|
257
|
renderItem, |
|
258
|
threshold = 200 |
|
259
|
}: InfiniteScrollProps<T>) { |
|
260
|
const [loadingMore, setLoadingMore] = useState(false); |
|
261
|
|
|
262
|
const observerRef = useRef<IntersectionObserver | null>(null); |
|
263
|
const sentinelRef = useCallback((node: HTMLDivElement | null) => { |
|
264
|
if (loading) return; |
|
265
|
|
|
266
|
if (observerRef.current) observerRef.current.disconnect(); |
|
267
|
|
|
268
|
observerRef.current = new IntersectionObserver(entries => { |
|
269
|
if (entries[0].isIntersecting && hasMore && !loadingMore) { |
|
270
|
setLoadingMore(true); |
|
271
|
loadMore().finally(() => setLoadingMore(false)); |
|
272
|
} |
|
273
|
}, { threshold: 0.1 }); |
|
274
|
|
|
275
|
if (node) observerRef.current.observe(node); |
|
276
|
}, [loading, hasMore, loadMore, loadingMore]); |
|
277
|
|
|
278
|
useEffect(() => { |
|
279
|
return () => { |
|
280
|
if (observerRef.current) { |
|
281
|
observerRef.current.disconnect(); |
|
282
|
} |
|
283
|
}; |
|
284
|
}, []); |
|
285
|
|
|
286
|
return ( |
|
287
|
<div data-testid="infinite-scroll"> |
|
288
|
{items.map((item, index) => ( |
|
289
|
<div key={index} className="infinite-scroll-item"> |
|
290
|
{renderItem(item, index)} |
|
291
|
</div> |
|
292
|
))} |
|
293
|
|
|
294
|
{hasMore && ( |
|
295
|
<div |
|
296
|
ref={sentinelRef} |
|
297
|
className="flex justify-center py-4" |
|
298
|
style={{ minHeight: threshold }} |
|
299
|
> |
|
300
|
{(loading || loadingMore) && ( |
|
301
|
<div className="flex items-center space-x-2"> |
|
302
|
<div className="w-6 h-6 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" /> |
|
303
|
<span className="text-gray-600 dark:text-gray-300">Loading more...</span> |
|
304
|
</div> |
|
305
|
)} |
|
306
|
</div> |
|
307
|
)} |
|
308
|
</div> |
|
309
|
); |
|
310
|
} |