source: components/ComponentDialog.tsx@ ad211d1

main
Last change on this file since ad211d1 was 8a7f936, checked in by Mihail <mihail2.naumov@…>, 6 months ago

Added Forge Page, added suggest component, fixed styling

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