Changeset 8a7f936 for pages/forge/+Page.tsx
- Timestamp:
- 12/29/25 00:37:49 (6 months ago)
- Branches:
- main
- Children:
- 5af32f0
- Parents:
- ae5d054
- File:
-
- 1 edited
-
pages/forge/+Page.tsx (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
pages/forge/+Page.tsx
rae5d054 r8a7f936 1 export default function ForgePage(){ 1 import React, {useState, useEffect, useMemo} from 'react'; 2 import { 3 Container, Paper, Typography, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, 4 Button, IconButton, Avatar, TextField, Grid, Chip, CircularProgress, 5 Menu, MenuItem, ListItemIcon 6 } from '@mui/material'; 7 import AddIcon from '@mui/icons-material/Add'; 8 import DeleteIcon from '@mui/icons-material/Delete'; 9 import CloseIcon from "@mui/icons-material/Close"; 10 import AlbumIcon from "@mui/icons-material/Album"; 11 import CableIcon from "@mui/icons-material/Cable"; 12 import RouterIcon from "@mui/icons-material/Router"; 13 import MemoryIcon from "@mui/icons-material/Memory"; 14 15 import { 16 saveBuildState, 17 onAddComponentToBuild, 18 onRemoveComponentFromBuild, 19 onDeleteBuild, 20 onGetBuildState, onGetBuildComponents 21 } from './forge.telefunc'; 22 23 import ComponentDialog from '../../components/ComponentDialog'; 24 import ComponentDetailsDialog from '../../components/ComponentDetailsDialog'; 25 import {onAddNewBuild, onGetComponentDetails} from "../+Layout.telefunc"; 26 import {onEditBuild} from "../dashboard/user/userDashboard.telefunc"; 27 28 type BuildSlot = { 29 id: string; 30 type: string; 31 label: string; 32 component: any | null; 33 required: boolean; 34 }; 35 36 const INITIAL_SLOTS: BuildSlot[] = [ 37 {id: 'cpu', type: 'cpu', label: 'CPU', component: null, required: true}, 38 {id: 'cooler', type: 'cooler', label: 'CPU Cooler', component: null, required: true}, 39 {id: 'motherboard', type: 'motherboard', label: 'Motherboard', component: null, required: true}, 40 {id: 'memory_1', type: 'memory', label: 'Memory', component: null, required: true}, 41 {id: 'gpu', type: 'gpu', label: 'Video Card', component: null, required: true}, 42 {id: 'storage_1', type: 'storage', label: 'Storage', component: null, required: true}, 43 {id: 'powersupply', type: 'power_supply', label: 'Power Supply', component: null, required: true}, 44 {id: 'case', type: 'case', label: 'Case', component: null, required: true}, 45 ]; 46 47 export default function ForgePage() { 48 const [slots, setSlots] = useState<BuildSlot[]>(INITIAL_SLOTS); 49 const [buildId, setBuildId] = useState<number | null>(null); 50 const [buildName, setBuildName] = useState(""); 51 const [description, setDescription] = useState(""); 52 const [totalPrice, setTotalPrice] = useState(0); 53 const [isSubmitting, setIsSubmitting] = useState(false); 54 55 const [browserOpen, setBrowserOpen] = useState(false); 56 const [activeSlotId, setActiveSlotId] = useState<string | null>(null); 57 const [detailsOpen, setDetailsOpen] = useState<any>(null); 58 const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); 59 const isSubmittedRef = React.useRef(false); 60 61 useEffect(() => { 62 const price = slots.reduce((sum, slot) => sum + (Number(slot.component?.price) || 0), 0); 63 setTotalPrice(price); 64 }, [slots]); 65 66 useEffect(() => { 67 if (!buildId) return; 68 69 const handleBeforeUnload = () => { 70 if (!isSubmittedRef.current) { 71 onDeleteBuild({buildId}).catch(() => { 72 }); 73 } 74 }; 75 76 window.addEventListener('beforeunload', handleBeforeUnload); 77 78 return () => { 79 window.removeEventListener('beforeunload', handleBeforeUnload); 80 if (!isSubmittedRef.current) { 81 onDeleteBuild({buildId}).catch(() => { 82 }); 83 } 84 }; 85 }, [buildId]); 86 87 useEffect(() => { 88 const urlParams = new URLSearchParams(window.location.search); 89 const urlBuildId = urlParams.get('buildId'); 90 91 if (urlBuildId && Number.isInteger(Number(urlBuildId)) && Number(urlBuildId) > 0) { 92 const loadBuildId = Number(urlBuildId); 93 onGetBuildComponents({ buildId: loadBuildId }) 94 .then(async (components) => { 95 if (components && components.length > 0) { 96 setBuildId(loadBuildId); 97 setBuildName("Cloned Build"); 98 setDescription(""); 99 100 const detailedComponents = await Promise.all( 101 components.map(async (c: any) => { 102 const full = await onGetComponentDetails({ componentId: c.id }).catch(() => null); 103 return full ? { ...c, ...full, details: full?.details } : c; 104 }) 105 ); 106 107 const componentMap = new Map(); 108 detailedComponents.forEach((c: any) => componentMap.set(c.type, c)); 109 110 setSlots(prevSlots => prevSlots.map(slot => { 111 const match = componentMap.get(slot.type); 112 return match ? { ...slot, component: match } : slot; 113 })); 114 115 window.history.replaceState({}, document.title, "/forge"); 116 } 117 }) 118 .catch(() => {}); 119 } 120 }, []); 121 122 const handlePickPart = (slotId: string) => { 123 setActiveSlotId(slotId); 124 setTimeout(() => setBrowserOpen(true), 0); 125 }; 126 127 const handleSelectComponent = async (component: any) => { 128 if (!activeSlotId) return; 129 130 try { 131 let id = buildId; 132 if (!id) { 133 const result = await onAddNewBuild({name: "Draft Build", description: "Work in progress"}); 134 id = typeof result === 'number' ? result : (result as any)?.buildId; 135 if (!id || !Number.isInteger(id) || id <= 0) { 136 alert("Failed to create draft build."); 137 return; 138 } 139 setBuildId(id); 140 } 141 142 const full = await onGetComponentDetails({componentId: component.id}).catch(() => null); 143 const merged = full ? {...component, ...full, details: full.details} : component; 144 145 setSlots(prev => prev.map(slot => 146 slot.id === activeSlotId ? {...slot, component: merged} : slot 147 )); 148 setBrowserOpen(false); 149 150 await onAddComponentToBuild({buildId: id, componentId: component.id}); 151 } catch (e) { 152 // console.error("Failed to add component to server build", e); 153 alert("Failed to add component. Please try again."); 154 } finally { 155 setActiveSlotId(null); 156 } 157 }; 158 159 const handleRemovePart = async (slotId: string) => { 160 const slot = slots.find(s => s.id === slotId); 161 if (!slot?.component || !buildId) return; 162 163 setSlots(prev => prev.map(s => 164 s.id === slotId ? {...s, component: null} : s 165 )); 166 167 try { 168 await onRemoveComponentFromBuild({ 169 buildId, 170 componentId: slot.component.id 171 }); 172 } catch (e) { 173 console.error("Failed to remove component from server", e); 174 } 175 }; 176 177 const handleAddSlot = (type: string, label: string) => { 178 const count = slots.filter(s => s.type === type).length; 179 const newSlot: BuildSlot = { 180 id: `${type}_${count + 1}`, 181 type, 182 label: `${label} ${count > 0 ? count + 1 : ''}`, 183 component: null, 184 required: false 185 }; 186 setSlots(prev => [...prev, newSlot]); 187 setAnchorEl(null); 188 }; 189 190 const handleDeleteSlot = (slotId: string) => { 191 const slot = slots.find(s => s.id === slotId); 192 if (slot?.component) { 193 handleRemovePart(slotId); 194 } 195 setSlots(prev => prev.filter(s => s.id !== slotId)); 196 }; 197 198 const handleSubmit = async () => { 199 if (!buildName.trim()) return alert("Please name your build!"); 200 if (!buildId) return alert("You must add at least one component before submitting."); 201 202 setIsSubmitting(true); 203 try { 204 const result = await onEditBuild({buildId}); 205 206 if (!result) throw new Error("Failed to save build"); 207 208 isSubmittedRef.current = true; 209 window.location.href = "/dashboard/user"; 210 } catch (e) { 211 console.error(e); 212 alert("Failed to save build."); 213 } finally { 214 setIsSubmitting(false); 215 } 216 }; 217 218 219 const activeSlotType = useMemo(() => { 220 if (!activeSlotId) return null; 221 return slots.find(s => s.id === activeSlotId)?.type || null; 222 }, [slots, activeSlotId]); 223 2 224 return ( 3 <h1>Test Forge Page</h1> 4 ) 225 <Container maxWidth="xl" sx={{mt: 0, mb: 10}}> 226 <Paper sx={{p: 4, mb: 0, bgcolor: '#ff8201', border: '1px solid #1e1e1e', color: 'white'}}> 227 <Typography variant="h4" align="center" fontWeight="bold">Forge Your Machine</Typography> 228 <Grid container spacing={2} justifyContent="center" sx={{mt: 2}}> 229 <Grid item xs={12} md={6}> 230 <TextField 231 fullWidth 232 label="Build Name *" 233 value={buildName} 234 onChange={e => setBuildName(e.target.value)} 235 sx={{bgcolor: '#1e1e1e', borderRadius: 1, color: 'white'}} 236 /> 237 </Grid> 238 </Grid> 239 </Paper> 240 241 <TableContainer component={Paper} elevation={3}> 242 <Table sx={{minWidth: 650}}> 243 <TableHead sx={{bgcolor: '#1e1e1e'}}> 244 <TableRow> 245 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Component</TableCell> 246 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Selection & Specs</TableCell> 247 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Price</TableCell> 248 <TableCell sx={{color: 'white', fontWeight: 'bold'}} align="right">Actions</TableCell> 249 </TableRow> 250 </TableHead> 251 <TableBody> 252 {slots.map((slot) => ( 253 <TableRow key={slot.id} hover> 254 <TableCell width="15%" sx={{ 255 fontWeight: 'bold', 256 bgcolor: '#1e1e1e', 257 color: 'white', 258 verticalAlign: 'top', 259 pt: 3, 260 borderRight: '1px solid #333' 261 }}> 262 {slot.label} 263 {slot.required && 264 <Chip label="Required" size="small" color="error" sx={{ml: 1, height: 20}}/>} 265 </TableCell> 266 267 <TableCell> 268 {slot.component ? ( 269 <Box sx={{display: 'flex', gap: 2, alignItems: 'flex-start'}}> 270 <Avatar 271 variant="rounded" 272 src={slot.component.imgUrl || slot.component.img_url} 273 sx={{width: 60, height: 60, bgcolor: '#eee'}} 274 > 275 {slot.component.brand?.[0]} 276 </Avatar> 277 <Box> 278 <Typography 279 variant="subtitle1" 280 fontWeight="bold" 281 sx={{cursor: 'pointer', color: 'primary.main'}} 282 onClick={() => setDetailsOpen(slot.component)} 283 > 284 {slot.component.name} 285 </Typography> 286 <Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5}}> 287 {renderSpecs(slot.component, slot.type)} 288 </Box> 289 </Box> 290 </Box> 291 ) : ( 292 <Button 293 variant="outlined" 294 startIcon={<AddIcon/>} 295 onClick={() => handlePickPart(slot.id)} 296 sx={{textTransform: 'none', color: '#666', borderColor: '#ccc'}} 297 > 298 Choose {slot.label} 299 </Button> 300 )} 301 </TableCell> 302 303 <TableCell width="10%" sx={{verticalAlign: 'top', pt: 3}}> 304 {slot.component ? `$${Number(slot.component.price).toFixed(2)}` : '-'} 305 </TableCell> 306 307 <TableCell align="right" width="10%" sx={{verticalAlign: 'top', pt: 2}}> 308 {slot.component && ( 309 <IconButton color="error" onClick={() => handleRemovePart(slot.id)}> 310 <DeleteIcon/> 311 </IconButton> 312 )} 313 {!slot.required && ( 314 <IconButton color="warning" onClick={() => handleDeleteSlot(slot.id)}> 315 <CloseIcon/> 316 </IconButton> 317 )} 318 </TableCell> 319 </TableRow> 320 ))} 321 </TableBody> 322 </Table> 323 </TableContainer> 324 325 <Box sx={{mt: 4, display: 'flex', justifyContent: 'center'}}> 326 <Button variant="outlined" startIcon={<AddIcon/>} onClick={(e) => setAnchorEl(e.currentTarget)} 327 sx={{mr: 2}}> 328 Add Optional Component 329 </Button> 330 <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}> 331 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary'}}> 332 Memory & Storage 333 </Typography> 334 <MenuItem onClick={() => handleAddSlot('memory', 'Memory')}> 335 <ListItemIcon><MemoryIcon/></ListItemIcon> 336 Additional Memory 337 </MenuItem> 338 <MenuItem onClick={() => handleAddSlot('storage', 'Storage')}> 339 <ListItemIcon><AlbumIcon/></ListItemIcon> 340 Additional Storage 341 </MenuItem> 342 343 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}> 344 Accessories 345 </Typography> 346 <MenuItem onClick={() => handleAddSlot('optical_drive', 'Optical Drive')}> 347 <ListItemIcon><AlbumIcon/></ListItemIcon> 348 Optical Drive 349 </MenuItem> 350 <MenuItem onClick={() => handleAddSlot('cables', 'Cable')}> 351 <ListItemIcon><CableIcon/></ListItemIcon> 352 Cables 353 </MenuItem> 354 355 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}> 356 Expansion Cards 357 </Typography> 358 <MenuItem onClick={() => handleAddSlot('memory_card', 'Storage Card')}> 359 <ListItemIcon><MemoryIcon/></ListItemIcon> 360 Storage Card 361 </MenuItem> 362 <MenuItem onClick={() => handleAddSlot('sound_card', 'Sound Card')}> 363 <ListItemIcon><RouterIcon/></ListItemIcon> 364 Sound Card 365 </MenuItem> 366 <MenuItem onClick={() => handleAddSlot('network_card', 'Network Card')}> 367 <ListItemIcon><RouterIcon/></ListItemIcon> 368 Network Card 369 </MenuItem> 370 <MenuItem onClick={() => handleAddSlot('network_adapter', 'WiFi Adapter')}> 371 <ListItemIcon><RouterIcon/></ListItemIcon> 372 WiFi Adapter 373 </MenuItem> 374 </Menu> 375 </Box> 376 377 <Box sx={{mt: 4, p: 4, bgcolor: '#1e1e1e', textAlign: 'center', borderRadius: 2}}> 378 <Typography variant="h5" sx={{mb: 2, fontWeight: 'bold', color: 'white'}}> 379 Total: ${totalPrice.toFixed(2)} 380 </Typography> 381 <Button 382 variant="contained" 383 color="primary" 384 size="large" 385 onClick={handleSubmit} 386 disabled={isSubmitting} 387 > 388 {isSubmitting ? <CircularProgress size={24}/> : 'Submit Build For Review'} 389 </Button> 390 </Box> 391 392 <ComponentDialog 393 open={browserOpen} 394 category={activeSlotType} 395 onClose={() => { 396 setBrowserOpen(false); 397 setActiveSlotId(null); 398 }} 399 mode="forge" 400 onSelect={handleSelectComponent} 401 currentBuildId={buildId} 402 /> 403 404 <ComponentDetailsDialog 405 open={!!detailsOpen} 406 component={detailsOpen} 407 onClose={() => setDetailsOpen(null)} 408 /> 409 </Container> 410 ); 5 411 } 412 413 function renderSpecs(c: any, type: string) { 414 if (!c) return null; 415 const data = {...c, ...(c.details || {})}; 416 const chipStyle = {height: 24, fontSize: '0.75rem', bgcolor: 'rgba(0,0,0,0.05)'}; 417 const specs: string[] = []; 418 const val = (k: string) => data[k] || data[k.toLowerCase()] || data[k.replace('_', '')]; 419 420 switch (type) { 421 case 'cpu': 422 if (val('socket')) specs.push(val('socket')); 423 if (val('cores')) specs.push(`${val('cores')} Cores / ${val('threads')} Threads`); 424 const base = data.baseclock || data.baseClock || data.base_clock; 425 const boost = data.boostclock || data.boostClock || data.boost_clock; 426 if (base) specs.push(`Base: ${base}GHz`); 427 if (boost) specs.push(`Boost: ${boost}GHz`); 428 break; 429 case 'gpu': 430 if (val('vram')) specs.push(`${val('vram')}GB VRAM`); 431 if (val('chipset')) specs.push(val('chipset')); 432 if (val('length')) specs.push(`L: ${val('length')}mm`); 433 break; 434 case 'motherboard': 435 if (val('socket')) specs.push(val('socket')); 436 if (val('formfactor')) specs.push(val('formfactor')); 437 if (val('ramtype')) specs.push(val('ramtype')); 438 break; 439 case 'memory': 440 if (val('capacity')) specs.push(`${val('capacity')}GB`); 441 if (val('type')) specs.push(val('type')); 442 if (val('speed')) specs.push(`${val('speed')} MHz`); 443 if (val('modules')) specs.push(`${val('modules')}x`); 444 break; 445 case 'storage': 446 if (val('capacity')) specs.push(`${val('capacity')}GB`); 447 if (val('type')) specs.push(val('type')); 448 break; 449 case 'power_supply': 450 if (val('wattage')) specs.push(`Wattage: ${val('wattage')}W`); 451 if (val('type')) specs.push(val('type')); 452 break; 453 case 'case': 454 if (val('gpuMaxLength')) specs.push(`Max GPU Length: ${val('gpuMaxLength')}mm`); 455 if (val('coolerMaxHeight')) specs.push(`Max CPU Cooler Height: ${val('coolerMaxHeight')}mm`); 456 break; 457 case 'cooler': 458 if (val('type')) specs.push(`${val('type')} Cooler`); 459 if (val('height')) specs.push(`${val('height')}mm`); 460 break; 461 default: 462 if (data.brand) specs.push(data.brand); 463 } 464 465 if (specs.length === 0) { 466 if (data.brand) return <Chip label={data.brand} sx={chipStyle}/>; 467 return <Typography variant="caption" color="text.secondary">...</Typography>; 468 } 469 470 return specs.map((label, i) => <Chip key={i} label={label} sx={chipStyle}/>); 471 }
Note:
See TracChangeset
for help on using the changeset viewer.
