source: pages/forge/+Page.tsx@ 1b5c18b

main
Last change on this file since 1b5c18b was 1b5c18b, checked in by Mihail <mihail2.naumov@…>, 5 months ago

Fixed specs displaying in the forge page and some scaling issues.

  • Property mode set to 100644
File size: 29.2 KB
Line 
1import React, {useState, useEffect, useMemo} from 'react';
2import {
3 Container, Paper, Typography, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
4 Button, IconButton, Avatar, TextField, Grid, Chip, CircularProgress,
5 Menu, MenuItem, ListItemIcon, Dialog, DialogTitle, DialogContent, DialogActions
6} from '@mui/material';
7
8import AddIcon from '@mui/icons-material/Add';
9import RemoveIcon from '@mui/icons-material/Remove';
10import DeleteIcon from '@mui/icons-material/Delete';
11import CloseIcon from "@mui/icons-material/Close";
12import AlbumIcon from "@mui/icons-material/Album";
13import CableIcon from "@mui/icons-material/Cable";
14import RouterIcon from "@mui/icons-material/Router";
15import MemoryIcon from "@mui/icons-material/Memory";
16
17import {
18 saveBuildState,
19 onAddComponentToBuild,
20 onRemoveComponentFromBuild,
21 onDeleteBuild,
22 onGetBuildState,
23 onGetBuildComponents
24} from './forge.telefunc';
25
26import ComponentDialog from '../../components/ComponentDialog';
27import ComponentDetailsDialog from '../../components/ComponentDetailsDialog';
28import {onAddNewBuild, onGetComponentDetails} from "../+Layout.telefunc";
29import {onEditBuild} from "../dashboard/user/userDashboard.telefunc";
30import {BuildSlot, INITIAL_SLOTS} from "./types/buildTypes";
31import {renderSpecs} from "./utils/RenderSpecs";
32import {
33 getMaxRamSlots,
34 calculateUsedRamSlots,
35 calculateUsedStorageSlots
36} from "./utils/componentCalculations";
37import {Snackbar, Alert} from '@mui/material';
38
39export default function ForgePage() {
40 const [slots, setSlots] = useState<BuildSlot[]>(INITIAL_SLOTS);
41 const [buildId, setBuildId] = useState<number | null>(null);
42 const [buildName, setBuildName] = useState("");
43 const [description, setDescription] = useState("");
44 const [totalPrice, setTotalPrice] = useState(0);
45 const [isSubmitting, setIsSubmitting] = useState(false);
46
47 const [browserOpen, setBrowserOpen] = useState(false);
48 const [activeSlotId, setActiveSlotId] = useState<string | null>(null);
49 const [detailsOpen, setDetailsOpen] = useState<any>(null);
50 const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
51 const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
52 const [tempDescription, setTempDescription] = useState("");
53 const isSubmittedRef = React.useRef(false);
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
65 useEffect(() => {
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);
70 setTotalPrice(price);
71 }, [slots]);
72
73 useEffect(() => {
74 if (buildId && buildName.trim()) {
75 const timeoutId = setTimeout(() => {
76 saveBuildState({buildId, name: buildName.trim(), description});
77 }, 1000);
78
79 return () => clearTimeout(timeoutId);
80 }
81 }, [buildId, buildName, description]);
82
83 useEffect(() => {
84 if (!buildId) return;
85
86 const handleBeforeUnload = () => {
87 if (!isSubmittedRef.current) {
88 onDeleteBuild({buildId}).catch(() => {
89 });
90 }
91 };
92
93 window.addEventListener('beforeunload', handleBeforeUnload);
94
95 return () => {
96 window.removeEventListener('beforeunload', handleBeforeUnload);
97 if (!isSubmittedRef.current) {
98 onDeleteBuild({buildId}).catch(() => {
99 });
100 }
101 };
102 }, [buildId]);
103
104 useEffect(() => {
105 const urlParams = new URLSearchParams(window.location.search);
106 const urlBuildId = urlParams.get('buildId');
107
108 if (urlBuildId && Number.isInteger(Number(urlBuildId)) && Number(urlBuildId) > 0) {
109 const loadBuildId = Number(urlBuildId);
110
111 onGetBuildState({buildId: loadBuildId})
112 .then((buildState) => {
113 if (buildState) {
114 setBuildId(loadBuildId);
115 setBuildName(buildState.build.name);
116 setDescription(buildState.build.description || "");
117 }
118 })
119 .catch(() => {
120 })
121 .finally(() => {
122 onGetBuildComponents({buildId: loadBuildId})
123 .then(async (components) => {
124 if (components && components.length > 0) {
125 const detailedComponents = await Promise.all(
126 components.map(async (c: any) => {
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};
134 })
135 );
136
137 const componentMap = new Map();
138 detailedComponents.forEach((c: any) => componentMap.set(c.type, c));
139
140 setSlots(prevSlots => prevSlots.map(slot => {
141 const match = componentMap.get(slot.type);
142 return match ? {...slot, component: match} : slot;
143 }));
144
145 window.history.replaceState({}, document.title, "/forge");
146 }
147 })
148 .catch(() => {
149 });
150 });
151 }
152 }, []);
153
154 const handlePickPart = (slotId: string) => {
155 setActiveSlotId(slotId);
156 setTimeout(() => setBrowserOpen(true), 0);
157 };
158
159 const handleSelectComponent = async (component: any) => {
160 if (!activeSlotId) return;
161
162 try {
163 let id = buildId;
164 if (!id) {
165 const result = await onAddNewBuild({
166 name: buildName.trim() || "New Build",
167 description: description || "Work in progress"
168 });
169 id = typeof result === 'number' ? result : (result as any)?.buildId;
170 if (!id || !Number.isInteger(id) || id <= 0) {
171 setSnackbar({
172 open: true,
173 message: 'Failed to create draft build. Please try again.',
174 severity: 'error'
175 });
176 return;
177 }
178 setBuildId(id);
179 }
180
181 const full = await onGetComponentDetails({componentId: component.id}).catch(() => null);
182 const merged = full ? {...component, ...full, details: full.details, quantity: 1} : {
183 ...component,
184 quantity: 1
185 };
186
187 setSlots(prev => prev.map(slot =>
188 slot.id === activeSlotId ? {...slot, component: merged} : slot
189 ));
190 setBrowserOpen(false);
191
192 await onAddComponentToBuild({buildId: id, componentId: component.id});
193 } catch (e) {
194 setSnackbar({
195 open: true,
196 message: 'Failed to add component to build. Please try again.',
197 severity: 'error'
198 });
199 } finally {
200 setActiveSlotId(null);
201 }
202 };
203
204 const handleRemovePart = async (slotId: string) => {
205 const slot = slots.find(s => s.id === slotId);
206 if (!slot?.component || !buildId) return;
207
208 const quantity = slot.component.quantity || 1;
209
210 setSlots(prev => prev.map(s =>
211 s.id === slotId ? {...s, component: null} : s
212 ));
213
214 try {
215 for (let i = 0; i < quantity; i++) {
216 await onRemoveComponentFromBuild({
217 buildId,
218 componentId: slot.component.id
219 });
220 }
221 } catch (e) {
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 });
298 }
299 };
300
301 const handleAddSlot = (type: string, label: string) => {
302 const count = slots.filter(s => s.type === type).length;
303 const newSlot: BuildSlot = {
304 id: `${type}_${count + 1}`,
305 type,
306 label: `${label} ${count > 0 ? count + 1 : ''}`,
307 component: null,
308 required: false
309 };
310 setSlots(prev => [...prev, newSlot]);
311 setAnchorEl(null);
312 };
313
314 const handleDeleteSlot = (slotId: string) => {
315 const slot = slots.find(s => s.id === slotId);
316 if (slot?.component) {
317 handleRemovePart(slotId);
318 }
319 setSlots(prev => prev.filter(s => s.id !== slotId));
320 };
321
322 const handleSubmit = () => {
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 }
339
340 setTempDescription(description || "");
341 setSubmitDialogOpen(true);
342 };
343
344 const handleSubmitConfirm = async () => {
345 if (!buildId) return;
346
347 setIsSubmitting(true);
348 setSubmitDialogOpen(false);
349
350 try {
351 await saveBuildState({buildId, name: buildName.trim(), description: tempDescription});
352 const result = await onEditBuild({buildId});
353 if (!result) throw new Error("Failed to save build");
354
355 isSubmittedRef.current = true;
356 window.location.href = "/dashboard/user";
357 } catch (e) {
358 console.error(e);
359 setSnackbar({
360 open: true,
361 message: 'Failed to save build. Please try again.',
362 severity: 'error'
363 });
364 } finally {
365 setIsSubmitting(false);
366 }
367 };
368
369 const activeSlotType = useMemo(() => {
370 if (!activeSlotId) return null;
371 return slots.find(s => s.id === activeSlotId)?.type || null;
372 }, [slots, activeSlotId]);
373
374 return (
375 <Container maxWidth="xl" sx={{mt: 0, mb: 10}}>
376 <Paper sx={{p: 4, mb: 0, bgcolor: '#ff8201', border: '1px solid #1e1e1e', color: 'white'}}>
377 <Typography variant="h4" align="center" fontWeight="bold">Forge Your Machine</Typography>
378 <Grid container spacing={2} justifyContent="center" sx={{mt: 2}}>
379 <Grid item xs={12} md={6}>
380 <TextField
381 fullWidth
382 label="Build Name *"
383 value={buildName}
384 onChange={e => setBuildName(e.target.value)}
385 sx={{bgcolor: '#1e1e1e', borderRadius: 1, color: 'white'}}
386 />
387 </Grid>
388 </Grid>
389 </Paper>
390
391 <TableContainer component={Paper} elevation={3}>
392 <Table sx={{minWidth: 650}}>
393 <TableHead sx={{bgcolor: '#1e1e1e'}}>
394 <TableRow>
395 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Component</TableCell>
396 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Selection & Specs</TableCell>
397 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Price</TableCell>
398 <TableCell sx={{color: 'white', fontWeight: 'bold'}} align="right">Actions</TableCell>
399 </TableRow>
400 </TableHead>
401 <TableBody>
402 {slots.map((slot) => (
403 <TableRow key={slot.id} hover>
404 <TableCell width="15%" sx={{
405 fontWeight: 'bold',
406 bgcolor: '#1e1e1e',
407 color: 'white',
408 verticalAlign: 'top',
409 pt: 3,
410 borderRight: '1px solid #333'
411 }}>
412 {slot.label}
413 {slot.required &&
414 <Chip label="Required" size="small" color="error" sx={{ml: 1, height: 20}}/>}
415 </TableCell>
416
417 <TableCell>
418 {slot.component ? (
419 <Box sx={{display: 'flex', gap: 2, alignItems: 'flex-start'}}>
420 <Avatar
421 variant="rounded"
422 src={slot.component.imgUrl || slot.component.img_url}
423 sx={{width: 60, height: 60, bgcolor: '#eee'}}
424 >
425 {slot.component.brand?.[0]}
426 </Avatar>
427 <Box>
428 <Typography
429 variant="subtitle1"
430 fontWeight="bold"
431 sx={{cursor: 'pointer', color: 'primary.main'}}
432 onClick={() => setDetailsOpen(slot.component)}
433 >
434 {slot.component.name}
435 </Typography>
436 <Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5}}>
437 {renderSpecs(slot.component, slot.type)}
438 </Box>
439 </Box>
440 </Box>
441 ) : (
442 <Button
443 variant="outlined"
444 startIcon={<AddIcon/>}
445 onClick={() => handlePickPart(slot.id)}
446 sx={{textTransform: 'none', color: '#666', borderColor: '#ccc'}}
447 >
448 Choose {slot.label}
449 </Button>
450 )}
451 </TableCell>
452
453 <TableCell width="10%" sx={{verticalAlign: 'top', pt: 3}}>
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 ) : '-'}
464 </TableCell>
465
466 <TableCell align="right" width="15%" sx={{verticalAlign: 'top', pt: 2}}>
467 {slot.component && (
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>
527 )}
528 </TableCell>
529 </TableRow>
530 ))}
531 </TableBody>
532 </Table>
533 </TableContainer>
534
535 <Box sx={{mt: 4, display: 'flex', justifyContent: 'center'}}>
536 <Button variant="outlined" startIcon={<AddIcon/>} onClick={(e) => setAnchorEl(e.currentTarget)}
537 sx={{mr: 2}}>
538 Add Optional Component
539 </Button>
540 <Menu
541 anchorEl={anchorEl}
542 open={Boolean(anchorEl)}
543 onClose={() => setAnchorEl(null)}
544 anchorReference="none"
545 sx={{
546 display: 'flex',
547 alignItems: 'center',
548 justifyContent: 'center',
549 }}
550 slotProps={{
551 paper: {
552 sx: {
553 position: 'absolute',
554 top: '30%',
555 // left: '50%',
556 // transform: 'translate(50%, +50%)',
557 }
558 }
559 }}
560 >
561 {/*Removed RAM & Storage from optional components*/}
562 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary'}}>
563 Accessories
564 </Typography>
565 <MenuItem onClick={() => handleAddSlot('optical_drive', 'Optical Drive')}>
566 <ListItemIcon><AlbumIcon/></ListItemIcon>
567 Optical Drive
568 </MenuItem>
569 <MenuItem onClick={() => handleAddSlot('cables', 'Cable')}>
570 <ListItemIcon><CableIcon/></ListItemIcon>
571 Cables
572 </MenuItem>
573
574 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}>
575 Expansion Cards
576 </Typography>
577 <MenuItem onClick={() => handleAddSlot('memory_card', 'Storage Card')}>
578 <ListItemIcon><MemoryIcon/></ListItemIcon>
579 Storage Card
580 </MenuItem>
581 <MenuItem onClick={() => handleAddSlot('sound_card', 'Sound Card')}>
582 <ListItemIcon><RouterIcon/></ListItemIcon>
583 Sound Card
584 </MenuItem>
585 <MenuItem onClick={() => handleAddSlot('network_card', 'Network Card')}>
586 <ListItemIcon><RouterIcon/></ListItemIcon>
587 Network Card
588 </MenuItem>
589 <MenuItem onClick={() => handleAddSlot('network_adapter', 'WiFi Adapter')}>
590 <ListItemIcon><RouterIcon/></ListItemIcon>
591 WiFi Adapter
592 </MenuItem>
593 </Menu>
594 </Box>
595
596 <Box sx={{mt: 4, p: 4, bgcolor: '#1e1e1e', textAlign: 'center', borderRadius: 2}}>
597 <Typography variant="h5" sx={{mb: 2, fontWeight: 'bold', color: 'white'}}>
598 Total: ${totalPrice.toFixed(2)}
599 </Typography>
600 <Button
601 variant="contained"
602 color="primary"
603 size="large"
604 onClick={handleSubmit}
605 disabled={isSubmitting}
606 >
607 {isSubmitting ? <CircularProgress size={24}/> : 'Submit Build For Review'}
608 </Button>
609 </Box>
610
611 <ComponentDialog
612 open={browserOpen}
613 category={activeSlotType}
614 onClose={() => {
615 setBrowserOpen(false);
616 setActiveSlotId(null);
617 }}
618 mode="forge"
619 onSelect={handleSelectComponent}
620 currentBuildId={buildId}
621 />
622
623 <ComponentDetailsDialog
624 open={!!detailsOpen}
625 component={detailsOpen}
626 onClose={() => setDetailsOpen(null)}
627 />
628
629 <Dialog open={submitDialogOpen} onClose={() => setSubmitDialogOpen(false)} maxWidth="sm" fullWidth>
630 <DialogTitle sx={{bgcolor: '#ff8201', color: 'white', fontWeight: 'bold'}}>
631 Build Description
632 </DialogTitle>
633 <DialogContent sx={{p: 3}}>
634 <Typography variant="body1" sx={{mb: 2, color: 'text.secondary'}}>
635 Add some notes about your build (optional):
636 </Typography>
637 <TextField
638 fullWidth
639 multiline
640 rows={4}
641 placeholder="e.g. Workstation monster, great for crunching numbers!"
642 value={tempDescription}
643 onChange={(e) => setTempDescription(e.target.value)}
644 variant="outlined"
645 />
646 </DialogContent>
647 <DialogActions sx={{p: 3, pt: 0}}>
648 <Button onClick={() => setSubmitDialogOpen(false)}>
649 Cancel
650 </Button>
651 <Button
652 variant="contained"
653 color="primary"
654 onClick={handleSubmitConfirm}
655 disabled={isSubmitting}
656 >
657 {isSubmitting ? (
658 <>
659 <CircularProgress size={20} sx={{mr: 1}}/>
660 Submitting...
661 </>
662 ) : (
663 'Submit Build'
664 )}
665 </Button>
666 </DialogActions>
667 </Dialog>
668 <Snackbar
669 open={snackbar.open}
670 autoHideDuration={4000}
671 onClose={() => setSnackbar(prev => ({...prev, open: false}))}
672 anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
673 >
674 <Alert
675 onClose={() => setSnackbar(prev => ({...prev, open: false}))}
676 severity={snackbar.severity}
677 variant="filled"
678 sx={{width: '100%'}}
679 >
680 {snackbar.message}
681 </Alert>
682 </Snackbar>
683 </Container>
684 );
685}
Note: See TracBrowser for help on using the repository browser.