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) {
144 return results.map(guide => ({ ...guide, highlightedTitle: guide.title }));
145 }
146
147 return results.map(guide => ({
148 ...guide,
149 highlightedTitle: guide.title.replace(
150 new RegExp(searchTerm, 'gi'),
151 match => `<mark>${match}</mark>`
152 )
153 }));
154 }, [results, searchTerm]);
155
156 const handleResultClick = useCallback((guide: RepairGuide) => {
157 onResultClick(guide);
158 }, [onResultClick]);
159
160 if (results.length === 0) {
161 return (
162 <div className="text-center py-8 text-gray-500">
163 No results found for "{searchTerm}"
164 </div>
165 );
166 }
167
168 return (
169 <div className="space-y-2" data-testid="search-results">
170 {highlightedResults.map(guide => (
171 <div
172 key={guide.id}
173 className="p-3 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
174 onClick={() => handleResultClick(guide)}
175 data-testid={`search-result-${guide.id}`}
176 >
177 <h4
178 className="font-medium text-gray-900 dark:text-white"
179 dangerouslySetInnerHTML={{ __html: guide.highlightedTitle || guide.title }}
180 />
181 <p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
182 {guide.description}
183 </p>
184 </div>
185 ))}
186 </div>
187 );
188 });
189
190 MemoizedSearchResults.displayName = 'MemoizedSearchResults';
191
192 // Optimized image gallery component
193 export const OptimizedImageGallery = memo(({
194 images,
195 onImageClick
196 }: {
197 images: string[];
198 onImageClick: (src: string) => void;
199 }) => {
200 const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
201
202 const handleImageLoad = useCallback((src: string) => {
203 setLoadedImages(prev => new Set(prev).add(src));
204 }, []);
205
206 const handleImageClick = useCallback((src: string) => {
207 onImageClick(src);
208 }, [onImageClick]);
209
210 return (
211 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" data-testid="image-gallery">
212 {images.map((src, index) => (
213 <div
214 key={src}
215 className="aspect-square relative overflow-hidden rounded-lg bg-gray-200 dark:bg-gray-700"
216 >
217 <img
218 src={src}
219 alt={`Gallery image ${index + 1}`}
220 className={`w-full h-full object-cover cursor-pointer transition-opacity duration-300 ${
221 loadedImages.has(src) ? 'opacity-100' : 'opacity-0'
222 }`}
223 loading="lazy"
224 onClick={() => handleImageClick(src)}
225 onLoad={() => handleImageLoad(src)}
226 onError={(e) => {
227 (e.target as HTMLImageElement).style.display = 'none';
228 }}
229 data-testid={`gallery-image-${index}`}
230 />
231 {!loadedImages.has(src) && (
232 <div className="absolute inset-0 flex items-center justify-center">
233 <div className="w-8 h-8 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
234 </div>
235 )}
236 </div>
237 ))}
238 </div>
239 );
240 });
241
242 OptimizedImageGallery.displayName = 'OptimizedImageGallery';
243
244 // Memory-efficient infinite scroll component
245 interface InfiniteScrollProps<T> {
246 items: T[];
247 loadMore: () => Promise<void>;
248 hasMore: boolean;
249 loading: boolean;
250 renderItem: (item: T, index: number) => React.ReactNode;
251 threshold?: number;
252 }
253
254 export function InfiniteScroll<T>({
255 items,
256 loadMore,
257 hasMore,
258 loading,
259 renderItem,
260 threshold = 200
261 }: InfiniteScrollProps<T>) {
262 const [loadingMore, setLoadingMore] = useState(false);
263
264 const observerRef = useRef<IntersectionObserver | null>(null);
265 const sentinelRef = useCallback((node: HTMLDivElement | null) => {
266 if (loading) return;
267
268 if (observerRef.current) observerRef.current.disconnect();
269
270 observerRef.current = new IntersectionObserver(entries => {
271 if (entries[0].isIntersecting && hasMore && !loadingMore) {
272 setLoadingMore(true);
273 loadMore().finally(() => setLoadingMore(false));
274 }
275 }, { threshold: 0.1 });
276
277 if (node) observerRef.current.observe(node);
278 }, [loading, hasMore, loadMore, loadingMore]);
279
280 useEffect(() => {
281 return () => {
282 if (observerRef.current) {
283 observerRef.current.disconnect();
284 }
285 };
286 }, []);
287
288 return (
289 <div data-testid="infinite-scroll">
290 {items.map((item, index) => (
291 <div key={index} className="infinite-scroll-item">
292 {renderItem(item, index)}
293 </div>
294 ))}
295
296 {hasMore && (
297 <div
298 ref={sentinelRef}
299 className="flex justify-center py-4"
300 style={{ minHeight: threshold }}
301 >
302 {(loading || loadingMore) && (
303 <div className="flex items-center space-x-2">
304 <div className="w-6 h-6 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
305 <span className="text-gray-600 dark:text-gray-300">Loading more...</span>
306 </div>
307 )}
308 </div>
309 )}
310 </div>
311 );
312 }