source: components/ComponentDialog.tsx@ f727252

main
Last change on this file since f727252 was f727252, checked in by Mihail <mihail2.naumov@…>, 5 months ago

Optimizations & Layout/Text changes

  • Property mode set to 100644
File size: 26.9 KB
Line 
1import React, {useEffect, useState} from 'react';
2import {
3 Dialog, DialogContent, IconButton, Box, Typography,
4 Slider, FormControl, InputLabel, Select, MenuItem, Checkbox, ListItemText,
5 Card, CardContent, CardMedia, Button, CircularProgress, AppBar, Toolbar, InputAdornment, TextField,
6 TextField as MuiTextField, DialogTitle, DialogActions, Snackbar, Alert, Drawer, Badge
7} from '@mui/material';
8import CloseIcon from '@mui/icons-material/Close';
9import AddCircleIcon from '@mui/icons-material/AddCircle';
10import FilterListIcon from '@mui/icons-material/FilterList';
11import SearchIcon from "@mui/icons-material/Search";
12
13import {onGetAllComponents, onGetAuthState, onSuggestComponent} from '../pages/+Layout.telefunc'
14import {onGetCompatibleComponents} from '../pages/forge/forge.telefunc';
15import ComponentDetailsDialog from "./ComponentDetailsDialog";
16
17const formatMoney = (amount: number) => `$${Number(amount).toFixed(2)}`;
18
19export default function ComponentDialog({open, category, onClose, mode, onSelect, currentBuildId}: any) {
20 const [components, setComponents] = useState<any[]>([]);
21 const [loading, setLoading] = useState(false);
22 const [selectedComponent, setSelectedComponent] = useState<any>(null);
23 const [priceRange, setPriceRange] = useState<number[]>([0, 2000]);
24 const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
25 const [availableBrands, setAvailableBrands] = useState<string[]>([]);
26 const [sortOrder, setSortOrder] = useState<string>('default');
27
28 const [tempSearchQuery, setTempSearchQuery] = useState("");
29 const [searchQuery, setSearchQuery] = useState("");
30
31 const [suggestOpen, setSuggestOpen] = useState(false);
32 const [suggestForm, setSuggestForm] = useState({
33 link: '',
34 description: '',
35 componentType: category || ''
36 });
37 const [suggestLoading, setSuggestLoading] = useState(false);
38 const [snackbarOpen, setSnackbarOpen] = useState(false);
39 const [snackbarMessage, setSnackbarMessage] = useState("");
40 const [userId, setUserId] = useState<number | null>(null);
41 const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
42
43 useEffect(() => {
44 if (open && category) {
45 setSuggestForm({
46 link: '',
47 description: '',
48 componentType: category
49 });
50 }
51 }, [open, category]);
52
53 useEffect(() => {
54 const timeoutId = setTimeout(() => {
55 setSearchQuery(tempSearchQuery);
56 }, 300);
57 return () => clearTimeout(timeoutId);
58 }, [tempSearchQuery]);
59
60 useEffect(() => {
61 if (open && category) {
62 setSelectedBrands([]);
63 setSortOrder('price_desc');
64 setTempSearchQuery('');
65 setSearchQuery('');
66 setPriceRange([0, 2000]);
67 }
68 }, [open, category]);
69
70
71 useEffect(() => {
72 if (open) {
73 onGetAuthState().then(userData => {
74 setUserId(userData.userId);
75 });
76 }
77 }, [open]);
78
79 useEffect(() => {
80 if (open && category) {
81 setLoading(true);
82 setSortOrder('price_desc');
83
84 const shouldUseCompatibility = mode === 'forge' && currentBuildId;
85 const fetcher = shouldUseCompatibility
86 ? onGetCompatibleComponents({
87 buildId: currentBuildId,
88 componentType: category,
89 limit: 100,
90 sort: 'price_desc'
91 })
92 : onGetAllComponents({
93 componentType: category,
94 limit: 100,
95 sort: 'price_desc'
96 });
97
98 fetcher
99 .then((data) => {
100 const comps = data || [];
101 setComponents(comps);
102
103 const brands = Array.from(new Set(comps.map((c: any) => c.brand)));
104 setAvailableBrands(brands as string[]);
105 const maxPrice = comps.length > 0 ? Math.max(...comps.map((c: any) => Number(c.price))) : 2000;
106 setPriceRange([0, Math.ceil(maxPrice)]);
107 })
108 .catch((err) => {
109 console.error('[Dialog] fetch error:', err);
110 setComponents([]);
111 })
112 .finally(() => setLoading(false));
113 }
114 }, [open, category, mode, currentBuildId]);
115
116 const handleSuggestChange = (e: React.ChangeEvent<HTMLInputElement>) => {
117 setSuggestForm({
118 ...suggestForm,
119 [e.target.name]: e.target.value
120 });
121 }
122
123 const submitSuggestion = async () => {
124 if (!userId) {
125 setSnackbarMessage("Please login to submit suggestions!");
126 setSnackbarOpen(true);
127 return;
128 }
129
130 if (!suggestForm.link || !suggestForm.description) {
131 setSnackbarMessage("Please fill in all fields!");
132 setSnackbarOpen(true);
133 return;
134 }
135
136 setSuggestLoading(true);
137 try {
138 const suggestionId = await onSuggestComponent({
139 link: suggestForm.link,
140 description: suggestForm.description,
141 componentType: suggestForm.componentType
142 });
143
144 setSnackbarMessage("Suggestion submitted! Admin will review it.");
145 setSnackbarOpen(true);
146 setSuggestOpen(false);
147 setSuggestForm({link: '', description: '', componentType: category || ''});
148 } catch (error) {
149 console.error('Suggestion error:', error);
150 setSnackbarMessage("Failed to submit suggestion. Try again.");
151 setSnackbarOpen(true);
152 } finally {
153 setSuggestLoading(false);
154 }
155 };
156
157 let processedComponents = components.filter(comp => {
158 const matchesBrand = selectedBrands.length === 0 || selectedBrands.includes(comp.brand);
159 const matchesPrice = Number(comp.price) >= priceRange[0] && Number(comp.price) <= priceRange[1];
160 const matchesSearch = searchQuery === "" ||
161 comp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
162 comp.brand.toLowerCase().includes(searchQuery.toLowerCase());
163
164 return matchesBrand && matchesPrice && matchesSearch;
165 });
166
167 if (sortOrder === 'price_asc') {
168 processedComponents.sort((a, b) => Number(a.price) - Number(b.price));
169 } else if (sortOrder === 'price_desc') {
170 processedComponents.sort((a, b) => Number(b.price) - Number(a.price));
171 }
172
173 const handleBrandChange = (event: any) => {
174 const value = event.target.value;
175 setSelectedBrands(typeof value === 'string' ? value.split(',') : value);
176 };
177
178 const activeFiltersCount = selectedBrands.length + (sortOrder !== 'default' ? 1 : 0);
179
180 const FilterContent = React.useMemo(() => (
181 <Box sx={{width: {xs: 280, md: 300}, p: 3, bgcolor: 'transparent'}}>
182 <TextField
183 fullWidth
184 size="small"
185 placeholder="Search components..."
186 value={tempSearchQuery}
187 onChange={(e) => setTempSearchQuery(e.target.value)}
188 sx={{mb: 2}}
189 InputProps={{
190 startAdornment: <InputAdornment position="start"><SearchIcon/></InputAdornment>,
191 }}
192 />
193
194 <FormControl fullWidth size="small" sx={{mb: 2}}>
195 <InputLabel>Sort By</InputLabel>
196 <Select
197 value={sortOrder}
198 label="Sort By"
199 onChange={(e) => setSortOrder(e.target.value)}
200 MenuProps={{
201 container: typeof window !== 'undefined' ? document.body : undefined,
202 disablePortal: false,
203 sx: {
204 zIndex: 1500
205 }
206 }}
207 >
208 <MenuItem value="price_asc">Price: Low to High</MenuItem>
209 <MenuItem value="price_desc">Price: High to Low</MenuItem>
210 </Select>
211 </FormControl>
212
213 <FormControl fullWidth size="small" sx={{mb: 2}}>
214 <InputLabel>Brands</InputLabel>
215 <Select
216 multiple
217 value={selectedBrands}
218 onChange={handleBrandChange}
219 label="Brands"
220 renderValue={(s) => s.join(', ')}
221 MenuProps={{
222 container: typeof window !== 'undefined' ? document.body : undefined,
223 disablePortal: false,
224 sx: {
225 zIndex: 1500
226 },
227 PaperProps: {
228 sx: {
229 maxHeight: 300
230 }
231 }
232 }}
233 >
234 {availableBrands.map((brand) => (
235 <MenuItem key={brand} value={brand}>
236 <Checkbox checked={selectedBrands.indexOf(brand) > -1}/>
237 <ListItemText primary={brand}/>
238 </MenuItem>
239 ))}
240 </Select>
241 </FormControl>
242
243 <Typography gutterBottom fontWeight="bold">Price Range</Typography>
244 <Slider value={priceRange} onChange={(_, v) => setPriceRange(v as number[])} min={0} max={2000}
245 sx={{color: '#ff8201'}}/>
246 <Box sx={{display: 'flex', justifyContent: 'space-between', mt: 1}}>
247 <Typography variant="caption">${priceRange[0]}</Typography>
248 <Typography variant="caption">${priceRange[1]}+</Typography>
249 </Box>
250
251 <Button
252 fullWidth
253 variant="contained"
254 startIcon={<AddCircleIcon/>}
255 onClick={() => setSuggestOpen(true)}
256 sx={{
257 mt: 2,
258 bgcolor: '#ff8201',
259 fontWeight: 'bold',
260 fontSize: '0.875rem',
261 '&:hover': {bgcolor: '#e67300'}
262 }}
263 >
264 Suggest Component
265 </Button>
266 </Box>
267 ), [tempSearchQuery, sortOrder, selectedBrands, availableBrands, priceRange]);
268
269 if (!open) return null;
270
271 return (
272 <>
273 <Dialog
274 open={open}
275 onClose={onClose}
276 maxWidth="xl"
277 fullWidth
278 fullScreen
279 sx={{
280 '& .MuiDialog-paper': {
281 height: {xs: '100%', md: '90vh'},
282 m: {xs: 0, md: 2}
283 }
284 }}
285 >
286 <AppBar position="relative" sx={{bgcolor: '#ff8201'}}>
287 <Toolbar>
288 <IconButton
289 edge="start"
290 color="inherit"
291 onClick={() => setMobileFiltersOpen(true)}
292 sx={{
293 display: {xs: 'flex', md: 'none'},
294 mr: 2
295 }}
296 >
297 <Badge badgeContent={activeFiltersCount} color="error">
298 <FilterListIcon/>
299 </Badge>
300 </IconButton>
301
302 <Typography
303 sx={{ml: {xs: 0, md: 2}, flex: 1, fontSize: {xs: '1rem', sm: '1.25rem'}}}
304 variant="h6"
305 component="div"
306 >
307 <Box component="span" sx={{display: {xs: 'none', sm: 'inline'}}}>
308 Browsing:
309 </Box>
310 <b> {category === 'gpu' ? 'GRAPHICS CARDS' : category === 'memory_card' ? 'STORAGE EXPANSION CARDS' : category?.toUpperCase()}</b>
311 {mode === 'forge' && currentBuildId && (
312 <Typography
313 variant="caption"
314 sx={{
315 ml: {xs: 1, sm: 2},
316 bgcolor: 'rgba(0,0,0,0.2)',
317 px: 1,
318 py: 0.5,
319 borderRadius: 1,
320 display: {xs: 'none', sm: 'inline-block'}
321 }}
322 >
323 COMPATIBILITY MODE
324 </Typography>
325 )}
326 </Typography>
327 <IconButton edge="end" color="inherit" onClick={onClose}>
328 <CloseIcon/>
329 </IconButton>
330 </Toolbar>
331 </AppBar>
332
333 <DialogContent sx={{p: 0, display: 'flex', height: '100%', overflow: 'hidden'}}>
334 <Box sx={{
335 display: {xs: 'none', md: 'block'},
336 borderRight: '1px solid #ddd',
337 overflowY: 'auto',
338 bgcolor: '#1e1e1e'
339 }}>
340 {FilterContent}
341 </Box>
342 <Box sx={{flex: 1, p: {xs: 1.5, sm: 2, md: 3}, overflowY: 'auto', bgcolor: '#121212'}}>
343 {loading ? (
344 <Box sx={{display: 'flex', justifyContent: 'center', mt: 10}}>
345 <CircularProgress sx={{color: '#ff8201'}}/>
346 </Box>
347 ) : (
348 <Box sx={{
349 display: 'grid',
350 gridTemplateColumns: {
351 xs: '1fr',
352 sm: 'repeat(2, 1fr)',
353 md: 'repeat(2, 1fr)',
354 lg: 'repeat(3, 1fr)',
355 xl: 'repeat(4, 1fr)'
356 },
357 gap: {xs: 1.5, sm: 2, md: 3},
358 width: '100%'
359 }}>
360 {processedComponents.map((comp) => (
361 <Box key={comp.id} sx={{width: '100%'}}>
362 <Card
363 elevation={3}
364 sx={{
365 width: '100%',
366 height: '100%',
367 display: 'flex',
368 flexDirection: 'column',
369 transition: 'transform 0.2s, box-shadow 0.2s',
370 '&:hover': {
371 transform: 'translateY(-4px)',
372 boxShadow: 6
373 }
374 }}
375 >
376 <Box sx={{position: 'relative', paddingTop: '75%', bgcolor: '#1e1e1e'}}>
377 <CardMedia
378 component="img"
379 image={comp.imgUrl || comp.img_url || `https://placehold.co/400x400?text=${comp.name}`}
380 alt={comp.name}
381 sx={{
382 position: 'absolute',
383 top: 0,
384 left: 0,
385 width: '100%',
386 height: '100%',
387 objectFit: 'contain',
388 p: {xs: 1, sm: 2}
389 }}
390 />
391 </Box>
392
393 <CardContent sx={{
394 flexGrow: 1,
395 display: 'flex',
396 flexDirection: 'column',
397 justifyContent: 'space-between',
398 p: {xs: 1.5, sm: 2}
399 }}>
400 <Box>
401 <Typography
402 variant="caption"
403 color="text.secondary"
404 sx={{
405 textTransform: 'uppercase',
406 letterSpacing: 0.5,
407 fontSize: {xs: '0.65rem', sm: '0.75rem'}
408 }}
409 >
410 {comp.brand}
411 </Typography>
412 <Typography
413 variant="subtitle1"
414 fontWeight="bold"
415 sx={{
416 lineHeight: 1.2,
417 mt: 0.5,
418 mb: 1,
419 overflow: 'hidden',
420 textOverflow: 'ellipsis',
421 display: '-webkit-box',
422 WebkitLineClamp: 2,
423 WebkitBoxOrient: 'vertical',
424 minHeight: '2.4em',
425 fontSize: {xs: '0.875rem', sm: '1rem'}
426 }}
427 >
428 {comp.name}
429 </Typography>
430 </Box>
431 <Typography
432 variant="h6"
433 color="primary.main"
434 fontWeight="bold"
435 sx={{fontSize: {xs: '1rem', sm: '1.25rem'}}}
436 >
437 {formatMoney(comp.price)}
438 </Typography>
439 </CardContent>
440
441 <Box sx={{
442 p: {xs: 1.5, sm: 2},
443 pt: 0,
444 display: 'flex',
445 flexDirection: 'column',
446 gap: 1
447 }}>
448 <Button
449 fullWidth
450 variant="contained"
451 onClick={() => setSelectedComponent(comp)}
452 size="small"
453 sx={{
454 bgcolor: '#ff8201',
455 fontWeight: 'bold',
456 fontSize: {xs: '0.75rem', sm: '0.875rem'},
457 '&:hover': {bgcolor: '#e67300'}
458 }}
459 >
460 Details
461 </Button>
462 {mode === 'forge' && onSelect && (
463 <Button
464 fullWidth
465 variant="contained"
466 onClick={() => onSelect(comp)}
467 size="small"
468 sx={{
469 bgcolor: '#4caf50',
470 fontWeight: 'bold',
471 fontSize: {xs: '0.75rem', sm: '0.875rem'},
472 '&:hover': {bgcolor: '#388e3c'}
473 }}
474 >
475 Add
476 </Button>
477 )}
478 </Box>
479 </Card>
480 </Box>
481 ))}
482 {processedComponents.length === 0 && (
483 <Box sx={{gridColumn: '1 / -1', width: '100%', textAlign: 'center', mt: 5}}>
484 <Typography variant="h6" color="text.secondary"
485 sx={{fontSize: {xs: '1rem', sm: '1.25rem'}}}>
486 No compatible components found.
487 </Typography>
488 {mode === 'forge' && (
489 <Typography variant="body2" color="error">
490 (Try removing incompatible parts)
491 </Typography>
492 )}
493 </Box>
494 )}
495 </Box>
496 )}
497 </Box>
498 </DialogContent>
499
500 <ComponentDetailsDialog
501 open={!!selectedComponent}
502 component={selectedComponent}
503 onClose={() => setSelectedComponent(null)}
504 />
505 </Dialog>
506
507 <Drawer
508 anchor="left"
509 open={mobileFiltersOpen && open}
510 onClose={() => setMobileFiltersOpen(false)}
511 sx={{zIndex: 1400}}
512 PaperProps={{
513 sx: {
514 bgcolor: '#1e1e1e'
515 }
516 }}
517 disableScrollLock={true}
518 >
519 <Box sx={{pt: 2, width: 280}}>
520 <Box sx={{
521 display: 'flex',
522 justifyContent: 'space-between',
523 alignItems: 'center',
524 px: 3,
525 pb: 2
526 }}>
527 <Typography variant="h6" fontWeight="bold">Filters</Typography>
528 <IconButton onClick={() => setMobileFiltersOpen(false)}>
529 <CloseIcon/>
530 </IconButton>
531 </Box>
532 {FilterContent}
533 </Box>
534 </Drawer>
535
536 <Dialog open={suggestOpen} onClose={() => setSuggestOpen(false)} maxWidth="sm" fullWidth>
537 <DialogTitle>Suggest a New Component</DialogTitle>
538 <DialogContent>
539 <MuiTextField
540 fullWidth
541 label="Product Link *"
542 name="link"
543 value={suggestForm.link}
544 onChange={handleSuggestChange}
545 placeholder="https://example.com/product"
546 sx={{mt: 1}}
547 size="small"
548 />
549 <MuiTextField
550 fullWidth
551 label="Description *"
552 name="description"
553 value={suggestForm.description}
554 onChange={handleSuggestChange}
555 multiline
556 rows={3}
557 placeholder="Why should we add this component? Any specs/details?"
558 sx={{mt: 2}}
559 size="small"
560 />
561 <Typography variant="caption" color="text.secondary" sx={{mt: 1, display: 'block'}}>
562 Category: <b>{category?.toUpperCase()}</b>
563 </Typography>
564 </DialogContent>
565 <DialogActions>
566 <Button onClick={() => setSuggestOpen(false)}>Cancel</Button>
567 <Button
568 onClick={submitSuggestion}
569 disabled={suggestLoading || !userId}
570 variant="contained"
571 startIcon={suggestLoading ? <CircularProgress size={20}/> : null}
572 >
573 {suggestLoading ? 'Submitting...' : 'Submit Suggestion'}
574 </Button>
575 </DialogActions>
576 </Dialog>
577
578 <Snackbar
579 open={snackbarOpen}
580 autoHideDuration={4000}
581 onClose={() => setSnackbarOpen(false)}
582 anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
583 >
584 <Alert onClose={() => setSnackbarOpen(false)} sx={{width: '100%'}}>
585 {snackbarMessage}
586 </Alert>
587 </Snackbar>
588 </>
589 );
590}
Note: See TracBrowser for help on using the repository browser.