source: components/ComponentDialog.tsx@ 1b5c18b

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

Fixed specs displaying in the forge page and some scaling issues.

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