source: components/ComponentDialog.tsx@ a744c90

main
Last change on this file since a744c90 was 87b79bc, checked in by Mihail <mihail2.naumov@…>, 6 months ago

Fixed suggests component and also added hamburger menu for small screens for the navbar.

  • Property mode set to 100644
File size: 21.4 KB
RevLine 
[6ce6739]1import React, {useEffect, useState} from 'react';
[a932c9e]2import {
[8a7f936]3 Dialog, DialogContent, IconButton, Box, Typography, Chip,
[a932c9e]4 Slider, FormControl, InputLabel, Select, MenuItem, Checkbox, ListItemText,
[8a7f936]5 Card, CardContent, CardMedia, Button, CircularProgress, AppBar, Toolbar, InputAdornment, TextField,
6 TextField as MuiTextField, DialogTitle, DialogActions, Snackbar, Alert
[a932c9e]7} from '@mui/material';
8import CloseIcon from '@mui/icons-material/Close';
[8a7f936]9import AddCircleIcon from '@mui/icons-material/AddCircle';
[a932c9e]10
[8a7f936]11import {onGetAllComponents, onGetAuthState, onSuggestComponent} from '../pages/+Layout.telefunc'
12import {onGetCompatibleComponents} from '../pages/forge/forge.telefunc';
[a932c9e]13import ComponentDetailsDialog from "./ComponentDetailsDialog";
[8a7f936]14import SearchIcon from "@mui/icons-material/Search";
[a932c9e]15
[8a7f936]16const formatMoney = (amount: number) => `$${Number(amount).toFixed(2)}`;
[a932c9e]17
[8a7f936]18export default function ComponentDialog({open, category, onClose, mode, onSelect, currentBuildId}: any) {
[a932c9e]19 const [components, setComponents] = useState<any[]>([]);
20 const [loading, setLoading] = useState(false);
21 const [selectedComponent, setSelectedComponent] = useState<any>(null);
22 const [priceRange, setPriceRange] = useState<number[]>([0, 2000]);
23 const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
24 const [availableBrands, setAvailableBrands] = useState<string[]>([]);
25 const [sortOrder, setSortOrder] = useState<string>('default');
26
[8a7f936]27 const [tempSearchQuery, setTempSearchQuery] = useState("");
28 const [searchQuery, setSearchQuery] = useState("");
29
30 const [suggestOpen, setSuggestOpen] = useState(false);
31 const [suggestForm, setSuggestForm] = useState({
32 link: '',
33 description: '',
34 componentType: category || ''
35 });
36 const [suggestLoading, setSuggestLoading] = useState(false);
37 const [snackbarOpen, setSnackbarOpen] = useState(false);
38 const [snackbarMessage, setSnackbarMessage] = useState("");
39 const [userId, setUserId] = useState<number | null>(null);
40
[87b79bc]41 useEffect(() => {
42 if (open && category) {
43 setSuggestForm({
44 link: '',
45 description: '',
46 componentType: category
47 });
48 }
49 }, [open, category]);
50
[8a7f936]51 useEffect(() => {
52 const timeoutId = setTimeout(() => {
53 setSearchQuery(tempSearchQuery);
54 }, 300);
55 return () => clearTimeout(timeoutId);
56 }, [tempSearchQuery]);
57
58 useEffect(() => {
59 if (open) {
60 onGetAuthState().then(userData => {
61 setUserId(userData.userId);
62 });
63 }
64 }, [open]);
65
[a932c9e]66 useEffect(() => {
67 if (open && category) {
68 setLoading(true);
69 setSortOrder('price_desc');
70
[8a7f936]71 const shouldUseCompatibility = mode === 'forge' && currentBuildId;
72 const fetcher = shouldUseCompatibility
73 ? onGetCompatibleComponents({
74 buildId: currentBuildId,
75 componentType: category,
76 limit: 100,
77 sort: 'price_desc'
78 })
79 : onGetAllComponents({
80 componentType: category,
81 limit: 100,
82 sort: 'price_desc'
83 });
84
85 fetcher
[a932c9e]86 .then((data) => {
[8a7f936]87 const comps = data || [];
88 setComponents(comps);
[a932c9e]89
[8a7f936]90 const brands = Array.from(new Set(comps.map((c: any) => c.brand)));
[a932c9e]91 setAvailableBrands(brands as string[]);
[8a7f936]92 const maxPrice = comps.length > 0 ? Math.max(...comps.map((c: any) => Number(c.price))) : 2000;
[a932c9e]93 setPriceRange([0, Math.ceil(maxPrice)]);
94 })
[8a7f936]95 .catch((err) => {
96 console.error('[Dialog] fetch error:', err);
97 setComponents([]);
98 })
[a932c9e]99 .finally(() => setLoading(false));
100 }
[8a7f936]101 }, [open, category, mode, currentBuildId]);
102
103 const handleSuggestChange = (e: React.ChangeEvent<HTMLInputElement>) => {
104 setSuggestForm({
105 ...suggestForm,
106 [e.target.name]: e.target.value
107 });
[87b79bc]108 }
109
110
[8a7f936]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 };
[a932c9e]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];
[8a7f936]149 const matchesSearch = searchQuery === "" ||
150 comp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
151 comp.brand.toLowerCase().includes(searchQuery.toLowerCase());
152
153 return matchesBrand && matchesPrice && matchesSearch;
[a932c9e]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 if (!open) return null;
168
169 return (
[8a7f936]170 <>
171 <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth sx={{height: '90vh'}}>
172 <AppBar position="relative" sx={{bgcolor: '#ff8201'}}>
173 <Toolbar>
174 <Typography sx={{ml: 2, flex: 1, textTransform: 'capitalize'}} variant="h6" component="div">
175 Browsing: <b>{category === 'gpu' ? 'Graphics Cards' : category?.toUpperCase()}</b>
176 {mode === 'forge' && currentBuildId && (
177 <Typography variant="caption"
178 sx={{ml: 2, bgcolor: 'rgba(0,0,0,0.2)', px: 1, borderRadius: 1}}>
179 COMPATIBILITY MODE ON
180 </Typography>
181 )}
182 </Typography>
183 <IconButton edge="start" color="inherit" onClick={onClose}>
184 <CloseIcon/>
185 </IconButton>
186 </Toolbar>
187 </AppBar>
[a932c9e]188
[8a7f936]189 <DialogContent sx={{p: 0, display: 'flex', height: '100%'}}>
190 <Box sx={{
191 width: 300,
192 borderRight: '1px solid #ddd',
193 p: 3,
194 bgcolor: '#1e1e1e',
195 display: {xs: 'none', md: 'block'},
196 overflowY: 'auto'
197 }}>
198 <TextField
199 fullWidth
200 size="small"
201 placeholder="Search components..."
202 value={tempSearchQuery}
203 onChange={(e) => setTempSearchQuery(e.target.value)}
204 sx={{mb: 2}}
205 InputProps={{
206 startAdornment: <InputAdornment position="start"><SearchIcon/></InputAdornment>,
207 }}
208 />
[a932c9e]209
[8a7f936]210
211 <FormControl fullWidth size="small" sx={{mb: 2}}>
212 <InputLabel>Sort By</InputLabel>
213 <Select value={sortOrder} label="Sort By" onChange={(e) => setSortOrder(e.target.value)}>
214 <MenuItem value="price_asc">Price: Low to High</MenuItem>
215 <MenuItem value="price_desc">Price: High to Low</MenuItem>
216 </Select>
217 </FormControl>
218
219 <FormControl fullWidth size="small" sx={{mb: 2}}>
220 <InputLabel>Brands</InputLabel>
221 <Select multiple value={selectedBrands} onChange={handleBrandChange} label="Brands"
222 renderValue={(s) => s.join(', ')}>
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>
[a932c9e]238 </Box>
[8a7f936]239 {/*TO BE FINISHED*/}
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>
[a932c9e]256
[8a7f936]257 <Box sx={{flex: 1, p: 3, overflowY: 'auto', bgcolor: '#121212'}}>
258 {loading ? (
259 <Box sx={{display: 'flex', justifyContent: 'center', mt: 10}}>
260 <CircularProgress sx={{color: '#ff8201'}}/>
261 </Box>
262 ) : (
263 <Box sx={{
264 display: 'grid',
265 gridTemplateColumns: {
266 xs: '1fr',
267 sm: 'repeat(2, 1fr)',
268 md: 'repeat(3, 1fr)',
269 lg: 'repeat(4, 1fr)',
270 xl: 'repeat(5, 1fr)'
271 },
272 gap: 3,
273 width: '100%'
274 }}>
275 {processedComponents.map((comp) => (
276 <Box key={comp.id} sx={{width: '100%'}}>
277 <Card
278 elevation={3}
279 sx={{
280 width: '100%',
281 height: '100%',
[6ce6739]282 display: 'flex',
[8a7f936]283 flexDirection: 'column',
284 transition: 'transform 0.2s, box-shadow 0.2s',
285 '&:hover': {
286 transform: 'translateY(-4px)',
287 boxShadow: 6
288 }
289 }}
290 >
291 <Box sx={{position: 'relative', paddingTop: '75%', bgcolor: '#1e1e1e'}}>
292 <CardMedia
293 component="img"
294 image={comp.imgUrl || comp.img_url || `https://placehold.co/400x400?text=${comp.name}`}
295 alt={comp.name}
296 sx={{
297 position: 'absolute',
298 top: 0,
299 left: 0,
300 width: '100%',
301 height: '100%',
302 objectFit: 'contain',
303 p: 2
304 }}
305 />
306 </Box>
307
308 <CardContent sx={{
309 flexGrow: 1,
310 display: 'flex',
311 flexDirection: 'column',
312 justifyContent: 'space-between'
[6ce6739]313 }}>
[8a7f936]314 <Box>
315 <Typography variant="caption" color="text.secondary"
316 sx={{textTransform: 'uppercase', letterSpacing: 0.5}}>
317 {comp.brand}
318 </Typography>
319 <Typography variant="subtitle1" fontWeight="bold" sx={{
320 lineHeight: 1.2,
321 mt: 0.5,
322 mb: 1,
323 overflow: 'hidden',
324 textOverflow: 'ellipsis',
325 display: '-webkit-box',
326 WebkitLineClamp: 2,
327 WebkitBoxOrient: 'vertical',
328 minHeight: '2.4em'
329 }}>
330 {comp.name}
331 </Typography>
332 </Box>
333 <Typography variant="h6" color="primary.main" fontWeight="bold">
[a932c9e]334 {formatMoney(comp.price)}
335 </Typography>
[8a7f936]336 </CardContent>
337
338 <Box sx={{p: 2, pt: 0, display: 'flex', flexDirection: 'column', gap: 1}}>
339 <Button
340 fullWidth
341 variant="contained"
342 onClick={() => setSelectedComponent(comp)}
343 sx={{
344 bgcolor: '#ff8201',
345 fontWeight: 'bold',
346 '&:hover': {bgcolor: '#e67300'}
347 }}
348 >
349 Details
350 </Button>
351 {mode === 'forge' && onSelect && (
352 <Button
353 fullWidth
354 variant="contained"
355 onClick={() => onSelect(comp)}
356 sx={{
357 bgcolor: '#4caf50',
358 fontWeight: 'bold',
359 '&:hover': {bgcolor: '#388e3c'}
360 }}
361 >
362 Add
363 </Button>
364 )}
[a932c9e]365 </Box>
[8a7f936]366 </Card>
367 </Box>
368 ))}
369 {processedComponents.length === 0 && (
370 <Box sx={{gridColumn: '1 / -1', width: '100%', textAlign: 'center', mt: 5}}>
371 <Typography variant="h6" color="text.secondary">
372 No compatible components found.
373 </Typography>
374 {mode === 'forge' && (
375 <Typography variant="body2" color="error">
376 (Try removing incompatible parts)
377 </Typography>
378 )}
379 </Box>
380 )}
381 </Box>
382 )}
383 </Box>
384 </DialogContent>
385
386 <ComponentDetailsDialog
387 open={!!selectedComponent}
388 component={selectedComponent}
389 onClose={() => setSelectedComponent(null)}
390 />
391 </Dialog>
392
393 <Dialog open={suggestOpen} onClose={() => setSuggestOpen(false)} maxWidth="sm" fullWidth>
394 <DialogTitle>Suggest a New Component</DialogTitle>
395 <DialogContent>
396 <MuiTextField
397 fullWidth
398 label="Product Link *"
399 name="link"
400 value={suggestForm.link}
401 onChange={handleSuggestChange}
402 placeholder="https://example.com/product"
403 sx={{mt: 1}}
404 size="small"
405 />
406 <MuiTextField
407 fullWidth
408 label="Description *"
409 name="description"
410 value={suggestForm.description}
411 onChange={handleSuggestChange}
412 multiline
413 rows={3}
414 placeholder="Why should we add this component? Any specs/details?"
415 sx={{mt: 2}}
416 size="small"
417 />
418 <Typography variant="caption" color="text.secondary" sx={{mt: 1, display: 'block'}}>
419 Category: <b>{category?.toUpperCase()}</b>
420 </Typography>
421 </DialogContent>
422 <DialogActions>
423 <Button onClick={() => setSuggestOpen(false)}>Cancel</Button>
424 <Button
425 onClick={submitSuggestion}
426 disabled={suggestLoading || !userId}
427 variant="contained"
428 startIcon={suggestLoading ? <CircularProgress size={20}/> : null}
429 >
430 {suggestLoading ? 'Submitting...' : 'Submit Suggestion'}
431 </Button>
432 </DialogActions>
433 </Dialog>
434
435 <Snackbar
436 open={snackbarOpen}
437 autoHideDuration={4000}
438 onClose={() => setSnackbarOpen(false)}
439 anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
440 >
441 <Alert onClose={() => setSnackbarOpen(false)} sx={{width: '100%'}}>
442 {snackbarMessage}
443 </Alert>
444 </Snackbar>
445 </>
[a932c9e]446 );
447}
Note: See TracBrowser for help on using the repository browser.