Changeset 8a7f936 for components/ComponentDialog.tsx
- Timestamp:
- 12/29/25 00:37:49 (6 months ago)
- Branches:
- main
- Children:
- 5af32f0
- Parents:
- ae5d054
- File:
-
- 1 edited
-
components/ComponentDialog.tsx (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
components/ComponentDialog.tsx
rae5d054 r8a7f936 1 1 import React, {useEffect, useState} from 'react'; 2 2 import { 3 Dialog, DialogContent, IconButton, Grid, Box, Typography,3 Dialog, DialogContent, IconButton, Box, Typography, Chip, 4 4 Slider, FormControl, InputLabel, Select, MenuItem, Checkbox, ListItemText, 5 Card, CardContent, CardMedia, Button, CircularProgress, Divider, AppBar, Toolbar 5 Card, CardContent, CardMedia, Button, CircularProgress, AppBar, Toolbar, InputAdornment, TextField, 6 TextField as MuiTextField, DialogTitle, DialogActions, Snackbar, Alert 6 7 } from '@mui/material'; 7 8 import CloseIcon from '@mui/icons-material/Close'; 8 import FilterListIcon from '@mui/icons-material/FilterList';9 import SortIcon from '@mui/icons-material/Sort'; 10 11 import {onGet AllComponents} from '../pages/+Layout.telefunc';9 import AddCircleIcon from '@mui/icons-material/AddCircle'; 10 11 import {onGetAllComponents, onGetAuthState, onSuggestComponent} from '../pages/+Layout.telefunc' 12 import {onGetCompatibleComponents} from '../pages/forge/forge.telefunc'; 12 13 import ComponentDetailsDialog from "./ComponentDetailsDialog"; 13 14 const formatMoney = (amount: number) => `$${amount}`; 15 16 export default function ComponentBrowserDialog({open, category, onClose}: any) { 14 import SearchIcon from "@mui/icons-material/Search"; 15 16 const formatMoney = (amount: number) => `$${Number(amount).toFixed(2)}`; 17 18 export default function ComponentDialog({open, category, onClose, mode, onSelect, currentBuildId}: any) { 17 19 const [components, setComponents] = useState<any[]>([]); 18 20 const [loading, setLoading] = useState(false); 19 20 21 const [selectedComponent, setSelectedComponent] = useState<any>(null); 21 22 const [priceRange, setPriceRange] = useState<number[]>([0, 2000]); 22 23 const [selectedBrands, setSelectedBrands] = useState<string[]>([]); 23 24 const [availableBrands, setAvailableBrands] = useState<string[]>([]); 24 25 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]); 26 56 27 57 useEffect(() => { … … 30 60 setSortOrder('price_desc'); 31 61 32 onGetAllComponents({componentType: category}) 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 33 77 .then((data) => { 34 setComponents(data); 35 36 const brands = Array.from(new Set(data.map((c: any) => c.brand))); 78 const comps = data || []; 79 setComponents(comps); 80 81 const brands = Array.from(new Set(comps.map((c: any) => c.brand))); 37 82 setAvailableBrands(brands as string[]); 38 39 const maxPrice = data.length > 0 ? Math.max(...data.map((c: any) => Number(c.price))) : 2000; 83 const maxPrice = comps.length > 0 ? Math.max(...comps.map((c: any) => Number(c.price))) : 2000; 40 84 setPriceRange([0, Math.ceil(maxPrice)]); 41 85 }) 42 .catch(console.error) 86 .catch((err) => { 87 console.error('[Dialog] fetch error:', err); 88 setComponents([]); 89 }) 43 90 .finally(() => setLoading(false)); 44 91 } 45 }, [open, category]); 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 }; 46 134 47 135 let processedComponents = components.filter(comp => { 48 136 const matchesBrand = selectedBrands.length === 0 || selectedBrands.includes(comp.brand); 49 137 const matchesPrice = Number(comp.price) >= priceRange[0] && Number(comp.price) <= priceRange[1]; 50 return matchesBrand && matchesPrice; 138 const matchesSearch = searchQuery === "" || 139 comp.name.toLowerCase().includes(searchQuery.toLowerCase()) || 140 comp.brand.toLowerCase().includes(searchQuery.toLowerCase()); 141 142 return matchesBrand && matchesPrice && matchesSearch; 51 143 }); 52 144 … … 65 157 66 158 return ( 67 <Dialog 68 open={open} 69 onClose={onClose} 70 maxWidth="xl" 71 fullWidth 72 sx={{height: '90vh'}} 73 > 74 <AppBar position="relative" sx={{bgcolor: '#ff8201'}}> 75 <Toolbar> 76 <Typography sx={{ml: 2, flex: 1, textTransform: 'capitalize'}} variant="h6" component="div"> 77 Browsing: <b>{category === 'gpu' ? 'Graphics Cards' : category?.toUpperCase()}</b> 78 </Typography> 79 <IconButton edge="start" color="inherit" onClick={onClose}> 80 <CloseIcon/> 81 </IconButton> 82 </Toolbar> 83 </AppBar> 84 85 <DialogContent sx={{p: 0, display: 'flex', height: '100%'}}> 86 <Box sx={{ 87 width: 300, 88 borderRight: '1px solid #ddd', 89 p: 3, 90 bgcolor: '#1e1e1e', 91 display: {xs: 'none', md: 'block'} 92 }}> 93 <Box sx={{display: 'flex', alignItems: 'center', mb: 2}}> 94 <SortIcon sx={{mr: 1}}/> 95 <Typography variant="h6">Sort By</Typography> 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> 96 244 </Box> 97 <FormControl fullWidth size="small" sx={{mb: 4}}> 98 <InputLabel>Price</InputLabel> 99 <Select 100 value={sortOrder} 101 label="Price" 102 onChange={(e) => setSortOrder(e.target.value)}> 103 {/*<MenuItem value="default">Featured</MenuItem>*/} 104 <MenuItem value="price_asc">Price: Low to High</MenuItem> 105 <MenuItem value="price_desc">Price: High to Low</MenuItem> 106 </Select> 107 </FormControl> 108 109 <Divider sx={{mb: 3}}/> 110 111 <Box sx={{display: 'flex', alignItems: 'center', mb: 2}}> 112 <FilterListIcon sx={{mr: 1}}/> 113 <Typography variant="h6">Filters</Typography> 114 </Box> 115 116 <Typography gutterBottom fontWeight="bold">Price Range</Typography> 117 <Slider 118 value={priceRange} 119 onChange={(_, newValue) => setPriceRange(newValue as number[])} 120 valueLabelDisplay="auto" 121 min={0} 122 max={components.length > 0 ? Math.max(...components.map(c => Number(c.price))) : 2000} 123 sx={{color: '#ff8201', mb: 2}} 124 /> 125 <Box sx={{display: 'flex', justifyContent: 'space-between', mb: 2}}> 126 <Typography variant="caption">{formatMoney(priceRange[0])}</Typography> 127 <Typography variant="caption">{formatMoney(priceRange[1])}</Typography> 128 </Box> 129 130 <FormControl fullWidth size="small"> 131 <InputLabel>Brands</InputLabel> 132 <Select 133 multiple 134 value={selectedBrands} 135 onChange={handleBrandChange} 136 renderValue={(selected) => selected.join(', ')} 137 label="Brands" 138 > 139 {availableBrands.map((brand) => ( 140 <MenuItem key={brand} value={brand}> 141 <Checkbox checked={selectedBrands.indexOf(brand) > -1}/> 142 <ListItemText primary={brand}/> 143 </MenuItem> 144 ))} 145 </Select> 146 </FormControl> 147 </Box> 148 149 <Box sx={{flex: 1, p: 3, overflowY: 'auto'}}> 150 {loading ? ( 151 <Box sx={{display: 'flex', justifyContent: 'center', mt: 10}}> 152 <CircularProgress sx={{color: '#ff8201'}}/> 153 </Box> 154 ) : ( 155 <Grid container spacing={1}> 156 {processedComponents.map((comp) => ( 157 <Grid item xs={12} sm={6} md={4} lg={3} key={comp.id}> 158 <Card elevation={1} sx={{ 159 height: '100%', 160 display: 'flex', 161 flexDirection: 'column', 162 maxWidth: 220, 163 width: 220, 164 overflow: 'hidden', 165 whiteSpace: 'nowrap', 166 textOverflow: 'ellipsis' 167 }}> 168 <CardMedia 169 component="img" 170 height="140" 171 // image={`https://placehold.co/400x400?text=${encodeURIComponent(comp.name)}`} 172 image={comp.imgUrl} 173 alt={comp.name} 174 sx={{p: 2, objectFit: 'contain'}} 175 /> 176 <CardContent sx={{flexGrow: 1}}> 177 <Typography variant="caption" color="text.secondary" 178 sx={{textTransform: 'uppercase'}}> 179 {comp.brand} 180 </Typography> 181 <Typography variant="subtitle1" fontWeight="bold" sx={{ 182 lineHeight: 1.2, 183 mb: 1, 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' 184 302 }}> 185 {comp.name} 186 </Typography> 187 188 <Box sx={{ 189 display: 'flex', 190 justifyContent: 'space-between', 191 alignItems: 'center', 192 mt: 2 193 }}> 194 <Typography variant="h6" color="primary.main"> 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"> 195 323 {formatMoney(comp.price)} 196 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 )} 197 354 </Box> 198 </CardContent> 199 <Box sx={{p: 2, pt: 0}}> 200 <Button 201 fullWidth 202 variant="outlined" 203 onClick={() => setSelectedComponent(comp)} 204 sx={{ 205 backgroundColor: '#ff8201', 206 color: 'white', 207 borderColor: '#ff8201', 208 onHover: {backgroundColor: '#e67300', borderColor: '#e67300'} 209 }} 210 > 211 Details 212 </Button> 213 </Box> 214 </Card> 215 </Grid> 216 ))} 217 {processedComponents.length === 0 && ( 218 <Box sx={{width: '100%', textAlign: 'center', mt: 5}}> 219 <Typography variant="h6" color="text.secondary">No components match.</Typography> 220 </Box> 221 )} 222 </Grid> 223 )} 224 </Box> 225 </DialogContent> 226 <ComponentDetailsDialog 227 open={!!selectedComponent} 228 component={selectedComponent} 229 onClose={() => setSelectedComponent(null)} 230 /> 231 </Dialog> 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 </> 232 435 ); 233 436 }
Note:
See TracChangeset
for help on using the changeset viewer.
