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 }