source: components/ComponentDialog.tsx@ d07d68c

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

Fixed clone auth errors and component names in the component dialogs.

  • Property mode set to 100644
File size: 27.5 KB
RevLine 
[6ce6739]1import React, {useEffect, useState} from 'react';
[a932c9e]2import {
[1b5c18b]3 Dialog, DialogContent, IconButton, Box, Typography,
[a932c9e]4 Slider, FormControl, InputLabel, Select, MenuItem, Checkbox, ListItemText,
[8a7f936]5 Card, CardContent, CardMedia, Button, CircularProgress, AppBar, Toolbar, InputAdornment, TextField,
[1b5c18b]6 TextField as MuiTextField, DialogTitle, DialogActions, Snackbar, Alert, Drawer, Badge
[a932c9e]7} from '@mui/material';
8import CloseIcon from '@mui/icons-material/Close';
[8a7f936]9import AddCircleIcon from '@mui/icons-material/AddCircle';
[1b5c18b]10import FilterListIcon from '@mui/icons-material/FilterList';
11import SearchIcon from "@mui/icons-material/Search";
[a932c9e]12
[8a7f936]13import {onGetAllComponents, onGetAuthState, onSuggestComponent} from '../pages/+Layout.telefunc'
14import {onGetCompatibleComponents} from '../pages/forge/forge.telefunc';
[a932c9e]15import ComponentDetailsDialog from "./ComponentDetailsDialog";
16
[8a7f936]17const formatMoney = (amount: number) => `$${Number(amount).toFixed(2)}`;
[a932c9e]18
[8a7f936]19export default function ComponentDialog({open, category, onClose, mode, onSelect, currentBuildId}: any) {
[a932c9e]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
[8a7f936]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);
[1b5c18b]41 const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
[8a7f936]42
[87b79bc]43 useEffect(() => {
44 if (open && category) {
45 setSuggestForm({
46 link: '',
47 description: '',
48 componentType: category
49 });
50 }
51 }, [open, category]);
52
[8a7f936]53 useEffect(() => {
54 const timeoutId = setTimeout(() => {
55 setSearchQuery(tempSearchQuery);
56 }, 300);
57 return () => clearTimeout(timeoutId);
58 }, [tempSearchQuery]);
59
[f727252]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
[8a7f936]71 useEffect(() => {
72 if (open) {
73 onGetAuthState().then(userData => {
74 setUserId(userData.userId);
75 });
76 }
77 }, [open]);
78
[a932c9e]79 useEffect(() => {
80 if (open && category) {
81 setLoading(true);
82 setSortOrder('price_desc');
83
[8a7f936]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
[a932c9e]99 .then((data) => {
[8a7f936]100 const comps = data || [];
101 setComponents(comps);
[a932c9e]102
[8a7f936]103 const brands = Array.from(new Set(comps.map((c: any) => c.brand)));
[a932c9e]104 setAvailableBrands(brands as string[]);
[8a7f936]105 const maxPrice = comps.length > 0 ? Math.max(...comps.map((c: any) => Number(c.price))) : 2000;
[a932c9e]106 setPriceRange([0, Math.ceil(maxPrice)]);
107 })
[8a7f936]108 .catch((err) => {
109 console.error('[Dialog] fetch error:', err);
110 setComponents([]);
111 })
[a932c9e]112 .finally(() => setLoading(false));
113 }
[8a7f936]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 });
[87b79bc]121 }
122
[8a7f936]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 };
[a932c9e]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];
[8a7f936]160 const matchesSearch = searchQuery === "" ||
161 comp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
162 comp.brand.toLowerCase().includes(searchQuery.toLowerCase());
163
164 return matchesBrand && matchesPrice && matchesSearch;
[a932c9e]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
[1b5c18b]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
[a932c9e]269 if (!open) return null;
270
271 return (
[8a7f936]272 <>
[1b5c18b]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 >
[8a7f936]286 <AppBar position="relative" sx={{bgcolor: '#ff8201'}}>
287 <Toolbar>
[1b5c18b]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>
[d07d68c]310 <b> {
311 category === 'gpu' ? 'GRAPHICS CARDS'
312 : category === 'memory_card' ? 'STORAGE EXPANSION CARDS'
313 : category === 'power_supply' ? 'POWER SUPPLIES'
314 : category === 'network_card' ? 'NETWORK CARDS'
315 : category === 'network_adapters' ? 'NETWORK ADAPTERS'
316 : category === 'sound_card' ? 'SOUND CARDS'
317 : category === 'optical_drive' ? 'OPTICAL DRIVES'
318 : category?.toUpperCase()}</b>
[8a7f936]319 {mode === 'forge' && currentBuildId && (
[1b5c18b]320 <Typography
321 variant="caption"
322 sx={{
323 ml: {xs: 1, sm: 2},
324 bgcolor: 'rgba(0,0,0,0.2)',
325 px: 1,
326 py: 0.5,
327 borderRadius: 1,
328 display: {xs: 'none', sm: 'inline-block'}
329 }}
330 >
331 COMPATIBILITY MODE
[8a7f936]332 </Typography>
333 )}
334 </Typography>
[1b5c18b]335 <IconButton edge="end" color="inherit" onClick={onClose}>
[8a7f936]336 <CloseIcon/>
337 </IconButton>
338 </Toolbar>
339 </AppBar>
[a932c9e]340
[1b5c18b]341 <DialogContent sx={{p: 0, display: 'flex', height: '100%', overflow: 'hidden'}}>
[8a7f936]342 <Box sx={{
343 display: {xs: 'none', md: 'block'},
[1b5c18b]344 borderRight: '1px solid #ddd',
345 overflowY: 'auto',
346 bgcolor: '#1e1e1e'
[8a7f936]347 }}>
[1b5c18b]348 {FilterContent}
[8a7f936]349 </Box>
[1b5c18b]350 <Box sx={{flex: 1, p: {xs: 1.5, sm: 2, md: 3}, overflowY: 'auto', bgcolor: '#121212'}}>
[8a7f936]351 {loading ? (
352 <Box sx={{display: 'flex', justifyContent: 'center', mt: 10}}>
353 <CircularProgress sx={{color: '#ff8201'}}/>
354 </Box>
355 ) : (
356 <Box sx={{
357 display: 'grid',
358 gridTemplateColumns: {
359 xs: '1fr',
360 sm: 'repeat(2, 1fr)',
[1b5c18b]361 md: 'repeat(2, 1fr)',
362 lg: 'repeat(3, 1fr)',
363 xl: 'repeat(4, 1fr)'
[8a7f936]364 },
[1b5c18b]365 gap: {xs: 1.5, sm: 2, md: 3},
[8a7f936]366 width: '100%'
367 }}>
368 {processedComponents.map((comp) => (
369 <Box key={comp.id} sx={{width: '100%'}}>
370 <Card
371 elevation={3}
372 sx={{
373 width: '100%',
374 height: '100%',
[6ce6739]375 display: 'flex',
[8a7f936]376 flexDirection: 'column',
377 transition: 'transform 0.2s, box-shadow 0.2s',
378 '&:hover': {
379 transform: 'translateY(-4px)',
380 boxShadow: 6
381 }
382 }}
383 >
384 <Box sx={{position: 'relative', paddingTop: '75%', bgcolor: '#1e1e1e'}}>
385 <CardMedia
386 component="img"
387 image={comp.imgUrl || comp.img_url || `https://placehold.co/400x400?text=${comp.name}`}
388 alt={comp.name}
389 sx={{
390 position: 'absolute',
391 top: 0,
392 left: 0,
393 width: '100%',
394 height: '100%',
395 objectFit: 'contain',
[1b5c18b]396 p: {xs: 1, sm: 2}
[8a7f936]397 }}
398 />
399 </Box>
400
401 <CardContent sx={{
402 flexGrow: 1,
403 display: 'flex',
404 flexDirection: 'column',
[1b5c18b]405 justifyContent: 'space-between',
406 p: {xs: 1.5, sm: 2}
[6ce6739]407 }}>
[8a7f936]408 <Box>
[1b5c18b]409 <Typography
410 variant="caption"
411 color="text.secondary"
412 sx={{
413 textTransform: 'uppercase',
414 letterSpacing: 0.5,
415 fontSize: {xs: '0.65rem', sm: '0.75rem'}
416 }}
417 >
[8a7f936]418 {comp.brand}
419 </Typography>
[1b5c18b]420 <Typography
421 variant="subtitle1"
422 fontWeight="bold"
423 sx={{
424 lineHeight: 1.2,
425 mt: 0.5,
426 mb: 1,
427 overflow: 'hidden',
428 textOverflow: 'ellipsis',
429 display: '-webkit-box',
430 WebkitLineClamp: 2,
431 WebkitBoxOrient: 'vertical',
432 minHeight: '2.4em',
433 fontSize: {xs: '0.875rem', sm: '1rem'}
434 }}
435 >
[8a7f936]436 {comp.name}
437 </Typography>
438 </Box>
[1b5c18b]439 <Typography
440 variant="h6"
441 color="primary.main"
442 fontWeight="bold"
443 sx={{fontSize: {xs: '1rem', sm: '1.25rem'}}}
444 >
[a932c9e]445 {formatMoney(comp.price)}
446 </Typography>
[8a7f936]447 </CardContent>
448
[1b5c18b]449 <Box sx={{
450 p: {xs: 1.5, sm: 2},
451 pt: 0,
452 display: 'flex',
453 flexDirection: 'column',
454 gap: 1
455 }}>
[8a7f936]456 <Button
457 fullWidth
458 variant="contained"
459 onClick={() => setSelectedComponent(comp)}
[1b5c18b]460 size="small"
[8a7f936]461 sx={{
462 bgcolor: '#ff8201',
463 fontWeight: 'bold',
[1b5c18b]464 fontSize: {xs: '0.75rem', sm: '0.875rem'},
[8a7f936]465 '&:hover': {bgcolor: '#e67300'}
466 }}
467 >
468 Details
469 </Button>
470 {mode === 'forge' && onSelect && (
471 <Button
472 fullWidth
473 variant="contained"
474 onClick={() => onSelect(comp)}
[1b5c18b]475 size="small"
[8a7f936]476 sx={{
477 bgcolor: '#4caf50',
478 fontWeight: 'bold',
[1b5c18b]479 fontSize: {xs: '0.75rem', sm: '0.875rem'},
[8a7f936]480 '&:hover': {bgcolor: '#388e3c'}
481 }}
482 >
483 Add
484 </Button>
485 )}
[a932c9e]486 </Box>
[8a7f936]487 </Card>
488 </Box>
489 ))}
490 {processedComponents.length === 0 && (
491 <Box sx={{gridColumn: '1 / -1', width: '100%', textAlign: 'center', mt: 5}}>
[1b5c18b]492 <Typography variant="h6" color="text.secondary"
493 sx={{fontSize: {xs: '1rem', sm: '1.25rem'}}}>
[8a7f936]494 No compatible components found.
495 </Typography>
496 {mode === 'forge' && (
497 <Typography variant="body2" color="error">
498 (Try removing incompatible parts)
499 </Typography>
500 )}
501 </Box>
502 )}
503 </Box>
504 )}
505 </Box>
506 </DialogContent>
507
508 <ComponentDetailsDialog
509 open={!!selectedComponent}
510 component={selectedComponent}
511 onClose={() => setSelectedComponent(null)}
512 />
513 </Dialog>
514
[1b5c18b]515 <Drawer
516 anchor="left"
517 open={mobileFiltersOpen && open}
518 onClose={() => setMobileFiltersOpen(false)}
519 sx={{zIndex: 1400}}
520 PaperProps={{
521 sx: {
522 bgcolor: '#1e1e1e'
523 }
524 }}
525 disableScrollLock={true}
526 >
527 <Box sx={{pt: 2, width: 280}}>
528 <Box sx={{
529 display: 'flex',
530 justifyContent: 'space-between',
531 alignItems: 'center',
532 px: 3,
533 pb: 2
534 }}>
535 <Typography variant="h6" fontWeight="bold">Filters</Typography>
536 <IconButton onClick={() => setMobileFiltersOpen(false)}>
537 <CloseIcon/>
538 </IconButton>
539 </Box>
540 {FilterContent}
541 </Box>
542 </Drawer>
543
[8a7f936]544 <Dialog open={suggestOpen} onClose={() => setSuggestOpen(false)} maxWidth="sm" fullWidth>
545 <DialogTitle>Suggest a New Component</DialogTitle>
546 <DialogContent>
547 <MuiTextField
548 fullWidth
549 label="Product Link *"
550 name="link"
551 value={suggestForm.link}
552 onChange={handleSuggestChange}
553 placeholder="https://example.com/product"
554 sx={{mt: 1}}
555 size="small"
556 />
557 <MuiTextField
558 fullWidth
559 label="Description *"
560 name="description"
561 value={suggestForm.description}
562 onChange={handleSuggestChange}
563 multiline
564 rows={3}
565 placeholder="Why should we add this component? Any specs/details?"
566 sx={{mt: 2}}
567 size="small"
568 />
569 <Typography variant="caption" color="text.secondary" sx={{mt: 1, display: 'block'}}>
570 Category: <b>{category?.toUpperCase()}</b>
571 </Typography>
572 </DialogContent>
573 <DialogActions>
574 <Button onClick={() => setSuggestOpen(false)}>Cancel</Button>
575 <Button
576 onClick={submitSuggestion}
577 disabled={suggestLoading || !userId}
578 variant="contained"
579 startIcon={suggestLoading ? <CircularProgress size={20}/> : null}
580 >
581 {suggestLoading ? 'Submitting...' : 'Submit Suggestion'}
582 </Button>
583 </DialogActions>
584 </Dialog>
585
586 <Snackbar
587 open={snackbarOpen}
588 autoHideDuration={4000}
589 onClose={() => setSnackbarOpen(false)}
590 anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
591 >
592 <Alert onClose={() => setSnackbarOpen(false)} sx={{width: '100%'}}>
593 {snackbarMessage}
594 </Alert>
595 </Snackbar>
596 </>
[a932c9e]597 );
[1b5c18b]598}
Note: See TracBrowser for help on using the repository browser.