- Timestamp:
- 01/28/26 16:43:57 (5 months ago)
- Branches:
- main
- Children:
- 1b5c18b
- Parents:
- a744c90
- Location:
- pages/forge
- Files:
-
- 3 added
- 1 edited
-
+Page.tsx (modified) (24 diffs)
-
types/buildTypes.ts (added)
-
utils/RenderSpecs.tsx (added)
-
utils/componentCalculations.ts (added)
Legend:
- Unmodified
- Added
- Removed
-
pages/forge/+Page.tsx
ra744c90 r1d8f088 5 5 Menu, MenuItem, ListItemIcon, Dialog, DialogTitle, DialogContent, DialogActions 6 6 } from '@mui/material'; 7 7 8 import AddIcon from '@mui/icons-material/Add'; 9 import RemoveIcon from '@mui/icons-material/Remove'; 8 10 import DeleteIcon from '@mui/icons-material/Delete'; 9 11 import CloseIcon from "@mui/icons-material/Close"; … … 26 28 import {onAddNewBuild, onGetComponentDetails} from "../+Layout.telefunc"; 27 29 import {onEditBuild} from "../dashboard/user/userDashboard.telefunc"; 28 29 type BuildSlot = { 30 id: string; 31 type: string; 32 label: string; 33 component: any | null; 34 required: boolean; 35 }; 36 37 const INITIAL_SLOTS: BuildSlot[] = [ 38 {id: 'cpu', type: 'cpu', label: 'CPU', component: null, required: true}, 39 {id: 'cooler', type: 'cooler', label: 'CPU Cooler', component: null, required: true}, 40 {id: 'motherboard', type: 'motherboard', label: 'Motherboard', component: null, required: true}, 41 {id: 'memory_1', type: 'memory', label: 'Memory', component: null, required: true}, 42 {id: 'gpu', type: 'gpu', label: 'Video Card', component: null, required: true}, 43 {id: 'storage_1', type: 'storage', label: 'Storage', component: null, required: true}, 44 {id: 'powersupply', type: 'power_supply', label: 'Power Supply', component: null, required: true}, 45 {id: 'case', type: 'case', label: 'Case', component: null, required: true}, 46 ]; 30 import {BuildSlot, INITIAL_SLOTS} from "./types/buildTypes"; 31 import {renderSpecs} from "./utils/RenderSpecs"; 32 import { 33 getMaxRamSlots, 34 calculateUsedRamSlots, 35 calculateUsedStorageSlots 36 } from "./utils/componentCalculations"; 37 import {Snackbar, Alert} from '@mui/material'; 47 38 48 39 export default function ForgePage() { … … 58 49 const [detailsOpen, setDetailsOpen] = useState<any>(null); 59 50 const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); 60 const [submitDialogOpen, setSubmitDialogOpen] = useState(false); // NEW61 const [tempDescription, setTempDescription] = useState(""); // NEW51 const [submitDialogOpen, setSubmitDialogOpen] = useState(false); 52 const [tempDescription, setTempDescription] = useState(""); 62 53 const isSubmittedRef = React.useRef(false); 63 54 55 const [snackbar, setSnackbar] = useState<{ 56 open: boolean; 57 message: string; 58 severity: 'error' | 'warning' | 'info' | 'success'; 59 }>({ 60 open: false, 61 message: '', 62 severity: 'info' 63 }); 64 64 65 useEffect(() => { 65 const price = slots.reduce((sum, slot) => sum + (Number(slot.component?.price) || 0), 0); 66 const price = slots.reduce((sum, slot) => { 67 const quantity = slot.component?.quantity || 1; 68 return sum + (Number(slot.component?.price) || 0) * quantity; 69 }, 0); 66 70 setTotalPrice(price); 67 71 }, [slots]); … … 70 74 if (buildId && buildName.trim()) { 71 75 const timeoutId = setTimeout(() => { 72 saveBuildState({ buildId, name: buildName.trim(), description});76 saveBuildState({buildId, name: buildName.trim(), description}); 73 77 }, 1000); 74 78 … … 105 109 const loadBuildId = Number(urlBuildId); 106 110 107 onGetBuildState({ buildId: loadBuildId})111 onGetBuildState({buildId: loadBuildId}) 108 112 .then((buildState) => { 109 113 if (buildState) { … … 113 117 } 114 118 }) 115 .catch(() => {}) 119 .catch(() => { 120 }) 116 121 .finally(() => { 117 onGetBuildComponents({ buildId: loadBuildId})122 onGetBuildComponents({buildId: loadBuildId}) 118 123 .then(async (components) => { 119 124 if (components && components.length > 0) { 120 125 const detailedComponents = await Promise.all( 121 126 components.map(async (c: any) => { 122 const full = await onGetComponentDetails({ componentId: c.id }).catch(() => null); 123 return full ? { ...c, ...full, details: full?.details } : c; 127 const full = await onGetComponentDetails({componentId: c.id}).catch(() => null); 128 return full ? { 129 ...c, 130 ...full, 131 details: full?.details, 132 quantity: c.quantity || 1 133 } : {...c, quantity: c.quantity || 1}; 124 134 }) 125 135 ); … … 130 140 setSlots(prevSlots => prevSlots.map(slot => { 131 141 const match = componentMap.get(slot.type); 132 return match ? { ...slot, component: match} : slot;142 return match ? {...slot, component: match} : slot; 133 143 })); 134 144 … … 136 146 } 137 147 }) 138 .catch(() => {}); 148 .catch(() => { 149 }); 139 150 }); 140 151 } … … 158 169 id = typeof result === 'number' ? result : (result as any)?.buildId; 159 170 if (!id || !Number.isInteger(id) || id <= 0) { 160 alert("Failed to create draft build."); 171 setSnackbar({ 172 open: true, 173 message: 'Failed to create draft build. Please try again.', 174 severity: 'error' 175 }); 161 176 return; 162 177 } … … 165 180 166 181 const full = await onGetComponentDetails({componentId: component.id}).catch(() => null); 167 const merged = full ? {...component, ...full, details: full.details} : component; 182 const merged = full ? {...component, ...full, details: full.details, quantity: 1} : { 183 ...component, 184 quantity: 1 185 }; 168 186 169 187 setSlots(prev => prev.map(slot => … … 174 192 await onAddComponentToBuild({buildId: id, componentId: component.id}); 175 193 } catch (e) { 176 alert("Failed to add component. Please try again."); 194 setSnackbar({ 195 open: true, 196 message: 'Failed to add component to build. Please try again.', 197 severity: 'error' 198 }); 177 199 } finally { 178 200 setActiveSlotId(null); … … 184 206 if (!slot?.component || !buildId) return; 185 207 208 const quantity = slot.component.quantity || 1; 209 186 210 setSlots(prev => prev.map(s => 187 211 s.id === slotId ? {...s, component: null} : s … … 189 213 190 214 try { 191 await onRemoveComponentFromBuild({ 192 buildId, 193 componentId: slot.component.id 194 }); 215 for (let i = 0; i < quantity; i++) { 216 await onRemoveComponentFromBuild({ 217 buildId, 218 componentId: slot.component.id 219 }); 220 } 195 221 } catch (e) { 196 222 console.error("Failed to remove component from server", e); 223 } 224 }; 225 226 const handleIncrementComponent = async (slotId: string) => { 227 const slot = slots.find(s => s.id === slotId); 228 if (!slot?.component || !buildId) return; 229 230 if (slot.type === 'memory') { 231 const maxSlots = getMaxRamSlots(slots); 232 const currentUsed = calculateUsedRamSlots(slots); 233 const modules = Number(slot.component.details?.modules || slot.component.modules || 1); 234 235 if (maxSlots && (currentUsed + modules > maxSlots)) { 236 setSnackbar({ 237 open: true, 238 message: `Cannot add more RAM. Motherboard has only ${maxSlots} slots and ${currentUsed} are currently used.`, 239 severity: 'error' 240 }); 241 return; 242 } 243 } 244 245 if (slot.type === 'storage') { 246 const formFactor = slot.component.details?.formFactor || slot.component.formFactor; 247 const maxSlots = 4; 248 const currentUsed = calculateUsedStorageSlots(slots, formFactor); 249 250 if (maxSlots && (currentUsed + 1 > maxSlots)) { 251 setSnackbar({ 252 open: true, 253 message: `Cannot add more ${formFactor} storage. Motherboard has only ${maxSlots} slots and ${currentUsed} are currently used.`, 254 severity: 'error' 255 }); 256 return; 257 } 258 } 259 260 try { 261 await onAddComponentToBuild({buildId, componentId: slot.component.id}); 262 263 setSlots(prev => prev.map(s => 264 s.id === slotId && s.component 265 ? {...s, component: {...s.component, quantity: (s.component.quantity || 1) + 1}} 266 : s 267 )); 268 } catch (e) { 269 setSnackbar({ 270 open: true, 271 message: 'Failed to increment component. Please try again.', 272 severity: 'error' 273 }); 274 } 275 }; 276 277 const handleDecrementComponent = async (slotId: string) => { 278 const slot = slots.find(s => s.id === slotId); 279 if (!slot?.component || !buildId) return; 280 281 const currentQuantity = slot.component.quantity || 1; 282 if (currentQuantity <= 1) return; 283 284 try { 285 await onRemoveComponentFromBuild({buildId, componentId: slot.component.id}); 286 287 setSlots(prev => prev.map(s => 288 s.id === slotId && s.component 289 ? {...s, component: {...s.component, quantity: (s.component.quantity || 1) - 1}} 290 : s 291 )); 292 } catch (e) { 293 setSnackbar({ 294 open: true, 295 message: 'Failed to decrement component. Please try again.', 296 severity: 'error' 297 }); 197 298 } 198 299 }; … … 220 321 221 322 const handleSubmit = () => { 222 if (!buildName.trim()) return alert("Please name your build!"); 223 if (!buildId) return alert("You must add at least one component before submitting."); 323 if (!buildName.trim()) { 324 setSnackbar({ 325 open: true, 326 message: 'Please give your build a name!', 327 severity: 'warning' 328 }); 329 return; 330 } 331 if (!buildId) { 332 setSnackbar({ 333 open: true, 334 message: 'Please add at least one component to your build before submitting!', 335 severity: 'warning' 336 }); 337 return; 338 } 224 339 225 340 setTempDescription(description || ""); … … 228 343 229 344 const handleSubmitConfirm = async () => { 230 if (!buildId) return;345 if (!buildId) return; 231 346 232 347 setIsSubmitting(true); … … 234 349 235 350 try { 236 await saveBuildState({ buildId, name: buildName.trim(), description: tempDescription});351 await saveBuildState({buildId, name: buildName.trim(), description: tempDescription}); 237 352 const result = await onEditBuild({buildId}); 238 353 if (!result) throw new Error("Failed to save build"); … … 242 357 } catch (e) { 243 358 console.error(e); 244 alert("Failed to save build."); 359 setSnackbar({ 360 open: true, 361 message: 'Failed to save build. Please try again.', 362 severity: 'error' 363 }); 245 364 } finally { 246 365 setIsSubmitting(false); … … 333 452 334 453 <TableCell width="10%" sx={{verticalAlign: 'top', pt: 3}}> 335 {slot.component ? `$${Number(slot.component.price).toFixed(2)}` : '-'} 454 {slot.component ? ( 455 <> 456 ${(Number(slot.component.price) * (slot.component.quantity || 1)).toFixed(2)} 457 {(slot.component.quantity || 1) > 1 && ( 458 <Typography variant="caption" display="block" color="text.secondary"> 459 ${Number(slot.component.price).toFixed(2)} each 460 </Typography> 461 )} 462 </> 463 ) : '-'} 336 464 </TableCell> 337 465 338 <TableCell align="right" width="1 0%" sx={{verticalAlign: 'top', pt: 2}}>466 <TableCell align="right" width="15%" sx={{verticalAlign: 'top', pt: 2}}> 339 467 {slot.component && ( 340 <IconButton color="error" onClick={() => handleRemovePart(slot.id)}> 341 <DeleteIcon/> 342 </IconButton> 343 )} 344 {!slot.required && ( 345 <IconButton color="warning" onClick={() => handleDeleteSlot(slot.id)}> 346 <CloseIcon/> 347 </IconButton> 468 <Box sx={{ 469 display: 'flex', 470 gap: 1, 471 justifyContent: 'flex-end', 472 alignItems: 'center' 473 }}> 474 {(slot.type === 'memory' || slot.type === 'storage') && ( 475 <> 476 <IconButton 477 size="small" 478 color="primary" 479 onClick={() => handleDecrementComponent(slot.id)} 480 disabled={(slot.component.quantity || 1) <= 1} 481 sx={{ 482 bgcolor: 'action.hover', 483 '&:disabled': {bgcolor: 'action.disabledBackground'} 484 }} 485 > 486 <RemoveIcon/> 487 </IconButton> 488 489 <Typography 490 variant="body2" 491 sx={{ 492 minWidth: '20px', 493 textAlign: 'center', 494 fontWeight: 'bold' 495 }} 496 > 497 {slot.component.quantity || 1} 498 </Typography> 499 500 <IconButton 501 size="small" 502 color="primary" 503 onClick={() => handleIncrementComponent(slot.id)} 504 sx={{bgcolor: 'action.hover'}} 505 > 506 <AddIcon/> 507 </IconButton> 508 </> 509 )} 510 511 <IconButton 512 color="error" 513 onClick={() => handleRemovePart(slot.id)} 514 > 515 <DeleteIcon/> 516 </IconButton> 517 518 {!slot.required && ( 519 <IconButton 520 color="warning" 521 onClick={() => handleDeleteSlot(slot.id)} 522 > 523 <CloseIcon/> 524 </IconButton> 525 )} 526 </Box> 348 527 )} 349 528 </TableCell> … … 360 539 </Button> 361 540 <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}> 541 {/*Removed RAM & Storage from optional components*/} 362 542 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary'}}> 363 Memory & Storage364 </Typography>365 <MenuItem onClick={() => handleAddSlot('memory', 'Memory')}>366 <ListItemIcon><MemoryIcon/></ListItemIcon>367 Additional Memory368 </MenuItem>369 <MenuItem onClick={() => handleAddSlot('storage', 'Storage')}>370 <ListItemIcon><AlbumIcon/></ListItemIcon>371 Additional Storage372 </MenuItem>373 374 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}>375 543 Accessories 376 544 </Typography> … … 440 608 441 609 <Dialog open={submitDialogOpen} onClose={() => setSubmitDialogOpen(false)} maxWidth="sm" fullWidth> 442 <DialogTitle sx={{ bgcolor: '#ff8201', color: 'white', fontWeight: 'bold'}}>610 <DialogTitle sx={{bgcolor: '#ff8201', color: 'white', fontWeight: 'bold'}}> 443 611 Build Description 444 612 </DialogTitle> 445 <DialogContent sx={{ p: 3}}>446 <Typography variant="body1" sx={{ mb: 2, color: 'text.secondary'}}>613 <DialogContent sx={{p: 3}}> 614 <Typography variant="body1" sx={{mb: 2, color: 'text.secondary'}}> 447 615 Add some notes about your build (optional): 448 616 </Typography> … … 451 619 multiline 452 620 rows={4} 453 placeholder="e.g. Gaming build for 1440p, quiet operation, future-proof..."621 placeholder="e.g. Workstation monster, great for crunching numbers!" 454 622 value={tempDescription} 455 623 onChange={(e) => setTempDescription(e.target.value)} … … 457 625 /> 458 626 </DialogContent> 459 <DialogActions sx={{ p: 3, pt: 0}}>627 <DialogActions sx={{p: 3, pt: 0}}> 460 628 <Button onClick={() => setSubmitDialogOpen(false)}> 461 629 Cancel … … 469 637 {isSubmitting ? ( 470 638 <> 471 <CircularProgress size={20} sx={{ mr: 1 }}/>639 <CircularProgress size={20} sx={{mr: 1}}/> 472 640 Submitting... 473 641 </> … … 478 646 </DialogActions> 479 647 </Dialog> 648 <Snackbar 649 open={snackbar.open} 650 autoHideDuration={4000} 651 onClose={() => setSnackbar(prev => ({...prev, open: false}))} 652 anchorOrigin={{vertical: 'bottom', horizontal: 'center'}} 653 > 654 <Alert 655 onClose={() => setSnackbar(prev => ({...prev, open: false}))} 656 severity={snackbar.severity} 657 variant="filled" 658 sx={{width: '100%'}} 659 > 660 {snackbar.message} 661 </Alert> 662 </Snackbar> 480 663 </Container> 481 664 ); 482 665 } 483 484 function renderSpecs(c: any, type: string) {485 if (!c) return null;486 const data = {...c, ...(c.details || {})};487 const chipStyle = {height: 24, fontSize: '0.75rem', bgcolor: 'rgba(0,0,0,0.05)'};488 const specs: string[] = [];489 const val = (k: string) => data[k] || data[k.toLowerCase()] || data[k.replace('_', '')];490 491 switch (type) {492 case 'cpu':493 if (val('socket')) specs.push(val('socket'));494 if (val('cores')) specs.push(`${val('cores')} Cores / ${val('threads')} Threads`);495 const base = data.baseclock || data.baseClock || data.base_clock;496 const boost = data.boostclock || data.boostClock || data.boost_clock;497 if (base) specs.push(`Base: ${base}GHz`);498 if (boost) specs.push(`Boost: ${boost}GHz`);499 break;500 case 'gpu':501 if (val('vram')) specs.push(`${val('vram')}GB VRAM`);502 if (val('chipset')) specs.push(val('chipset'));503 if (val('length')) specs.push(`L: ${val('length')}mm`);504 break;505 case 'motherboard':506 if (val('socket')) specs.push(val('socket'));507 if (val('formfactor')) specs.push(val('formfactor'));508 if (val('ramtype')) specs.push(val('ramtype'));509 break;510 case 'memory':511 if (val('capacity')) specs.push(`${val('capacity')}GB`);512 if (val('type')) specs.push(val('type'));513 if (val('speed')) specs.push(`${val('speed')} MHz`);514 if (val('modules')) specs.push(`${val('modules')}x`);515 break;516 case 'storage':517 if (val('capacity')) specs.push(`${val('capacity')}GB`);518 if (val('type')) specs.push(val('type'));519 break;520 case 'power_supply':521 if (val('wattage')) specs.push(`Wattage: ${val('wattage')}W`);522 if (val('type')) specs.push(val('type'));523 break;524 case 'case':525 if (val('gpuMaxLength')) specs.push(`Max GPU Length: ${val('gpuMaxLength')}mm`);526 if (val('coolerMaxHeight')) specs.push(`Max CPU Cooler Height: ${val('coolerMaxHeight')}mm`);527 break;528 case 'cooler':529 if (val('type')) specs.push(`${val('type')} Cooler`);530 if (val('height')) specs.push(`${val('height')}mm`);531 break;532 default:533 if (data.brand) specs.push(data.brand);534 }535 536 if (specs.length === 0) {537 if (data.brand) return <Chip label={data.brand} sx={chipStyle}/>;538 return <Typography variant="caption" color="text.secondary">...</Typography>;539 }540 541 return specs.map((label, i) => <Chip key={i} label={label} sx={chipStyle}/>);542 }
Note:
See TracChangeset
for help on using the changeset viewer.
