source: pages/forge/+Page.tsx@ 3a9c59c

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

Fixed auth issues in forger, component dialog name disappearance and other issues in forge page.

  • Property mode set to 100644
File size: 29.9 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, onGetAuthState, 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 // fix: changed text, new catch and removed setActiveSlotId(null), so the component name doesn't disappear.
160 const handleSelectComponent = async (component: any) => {
161 if (!activeSlotId) return;
162
163 try {
164 let id = buildId;
165 if (!id) {
166 let result;
167 try {
168 result = await onAddNewBuild({
169 name: buildName.trim() || "New Build",
170 description: description || "Work in progress"
171 });
172 } catch {
173 setSnackbar({
174 open: true,
175 message: 'Please log in first!',
176 severity: 'warning'
177 });
178 return;
179 }
180
181 id = typeof result === 'number' ? result : (result as any)?.buildId;
182 if (!id || !Number.isInteger(id) || id <= 0) {
183 setSnackbar({
184 open: true,
185 message: 'Please log in first!',
186 severity: 'warning'
187 });
188 return;
189 }
190 setBuildId(id);
191 }
192
193 const full = await onGetComponentDetails({componentId: component.id}).catch(() => null);
194 const merged = full ? {...component, ...full, details: full.details, quantity: 1} : {
195 ...component,
196 quantity: 1
197 };
198
199 setSlots(prev => prev.map(slot =>
200 slot.id === activeSlotId ? {...slot, component: merged} : slot
201 ));
202 setBrowserOpen(false);
203
204 await onAddComponentToBuild({buildId: id, componentId: component.id});
205 } catch (e) {
206 setSnackbar({
207 open: true,
208 message: 'Failed to add component to build. Please try again.',
209 severity: 'error'
210 });
211 } finally {
212 // setActiveSlotId(null);
213 }
214 };
215 const handleRemovePart = async (slotId: string) => {
216 const slot = slots.find(s => s.id === slotId);
217 if (!slot?.component || !buildId) return;
218
219 const quantity = slot.component.quantity || 1;
220
221 setSlots(prev => prev.map(s =>
222 s.id === slotId ? {...s, component: null} : s
223 ));
224
225 try {
226 for (let i = 0; i < quantity; i++) {
227 await onRemoveComponentFromBuild({
228 buildId,
229 componentId: slot.component.id
230 });
231 }
232 } catch (e) {
233 console.error("Failed to remove component from server", e);
234 }
235 };
236
237 const handleIncrementComponent = async (slotId: string) => {
238 const slot = slots.find(s => s.id === slotId);
239 if (!slot?.component || !buildId) return;
240
241 if (slot.type === 'memory') {
242 const maxSlots = getMaxRamSlots(slots);
243 const currentUsed = calculateUsedRamSlots(slots);
244 const modules = Number(slot.component.details?.modules || slot.component.modules || 1);
245
246 if (maxSlots && (currentUsed + modules > maxSlots)) {
247 setSnackbar({
248 open: true,
249 message: `Cannot add more RAM. Motherboard has only ${maxSlots} slots and ${currentUsed} are currently used.`,
250 severity: 'error'
251 });
252 return;
253 }
254 }
255
256 if (slot.type === 'storage') {
257 const formFactor = slot.component.details?.formFactor || slot.component.formFactor;
258 const maxSlots = 4;
259 const currentUsed = calculateUsedStorageSlots(slots, formFactor);
260
261 if (maxSlots && (currentUsed + 1 > maxSlots)) {
262 setSnackbar({
263 open: true,
264 message: `Cannot add more ${formFactor} storage. Motherboard has only ${maxSlots} slots and ${currentUsed} are currently used.`,
265 severity: 'error'
266 });
267 return;
268 }
269 }
270
271 try {
272 await onAddComponentToBuild({buildId, componentId: slot.component.id});
273
274 setSlots(prev => prev.map(s =>
275 s.id === slotId && s.component
276 ? {...s, component: {...s.component, quantity: (s.component.quantity || 1) + 1}}
277 : s
278 ));
279 } catch (e) {
280 setSnackbar({
281 open: true,
282 message: 'Failed to increment component. Please try again.',
283 severity: 'error'
284 });
285 }
286 };
287
288 const handleDecrementComponent = async (slotId: string) => {
289 const slot = slots.find(s => s.id === slotId);
290 if (!slot?.component || !buildId) return;
291
292 const currentQuantity = slot.component.quantity || 1;
293 if (currentQuantity <= 1) return;
294
295 try {
296 await onRemoveComponentFromBuild({buildId, componentId: slot.component.id});
297
298 setSlots(prev => prev.map(s =>
299 s.id === slotId && s.component
300 ? {...s, component: {...s.component, quantity: (s.component.quantity || 1) - 1}}
301 : s
302 ));
303 } catch (e) {
304 setSnackbar({
305 open: true,
306 message: 'Failed to decrement component. Please try again.',
307 severity: 'error'
308 });
309 }
310 };
311
312 const handleAddSlot = (type: string, label: string) => {
313 const count = slots.filter(s => s.type === type).length;
314 const newSlot: BuildSlot = {
315 id: `${type}_${count + 1}`,
316 type,
317 label: `${label} ${count > 0 ? count + 1 : ''}`,
318 component: null,
319 required: false
320 };
321 setSlots(prev => [...prev, newSlot]);
322 setAnchorEl(null);
323 };
324
325 const handleDeleteSlot = (slotId: string) => {
326 const slot = slots.find(s => s.id === slotId);
327 if (slot?.component) {
328 handleRemovePart(slotId);
329 }
330 setSlots(prev => prev.filter(s => s.id !== slotId));
331 };
332
333
334 const [isLoggedIn, setIsLoggedIn] = useState(false);
335 useEffect(() => {
336 onGetAuthState().then(userData => {
337 setIsLoggedIn(!!userData?.userId);
338 });
339 }, []);
340
341
342 const handleSubmit = () => {
343 if (!isLoggedIn) {
344 window.location.href = '/auth/login'
345 return;
346 }
347
348 if (!buildName.trim()) {
349 setSnackbar({
350 open: true,
351 message: 'Please give your build a name!',
352 severity: 'warning'
353 });
354 return;
355 }
356
357 if (!buildId) {
358 setSnackbar({
359 open: true,
360 message: 'Please add at least one component to your build before submitting!',
361 severity: 'warning'
362 });
363 return;
364 }
365
366 setTempDescription(description || "");
367 setSubmitDialogOpen(true);
368 };
369
370 const handleSubmitConfirm = async () => {
371 if (!buildId) return;
372
373 setIsSubmitting(true);
374 setSubmitDialogOpen(false);
375
376 try {
377 await saveBuildState({buildId, name: buildName.trim(), description: tempDescription});
378 const result = await onEditBuild({buildId});
379 if (!result) throw new Error("Failed to save build");
380
381 isSubmittedRef.current = true;
382 window.location.href = "/dashboard/user";
383 } catch (e) {
384 console.error(e);
385 setSnackbar({
386 open: true,
387 message: 'Failed to save build. Please try again.',
388 severity: 'error'
389 });
390 } finally {
391 setIsSubmitting(false);
392 }
393 };
394
395 const activeSlotType = useMemo(() => {
396 if (!activeSlotId) return null;
397 return slots.find(s => s.id === activeSlotId)?.type || null;
398 }, [slots, activeSlotId]);
399
400 return (
401 <Container maxWidth="xl" sx={{mt: 0, mb: 10}}>
402 <Paper sx={{p: 4, mb: 0, bgcolor: '#ff8201', border: '1px solid #1e1e1e', color: 'white'}}>
403 <Typography variant="h4" align="center" fontWeight="bold">Forge Your Machine</Typography>
404 <Grid container spacing={2} justifyContent="center" sx={{mt: 2}}>
405 <Grid item xs={12} md={6}>
406 <TextField
407 fullWidth
408 label="Build Name *"
409 value={buildName}
410 onChange={e => setBuildName(e.target.value)}
411 sx={{bgcolor: '#1e1e1e', borderRadius: 1, color: 'white'}}
412 />
413 </Grid>
414 </Grid>
415 </Paper>
416
417 <TableContainer component={Paper} elevation={3}>
418 <Table sx={{minWidth: 650}}>
419 <TableHead sx={{bgcolor: '#1e1e1e'}}>
420 <TableRow>
421 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Component</TableCell>
422 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Selection & Specs</TableCell>
423 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Price</TableCell>
424 <TableCell sx={{color: 'white', fontWeight: 'bold'}} align="right">Actions</TableCell>
425 </TableRow>
426 </TableHead>
427 <TableBody>
428 {slots.map((slot) => (
429 <TableRow key={slot.id} hover>
430 <TableCell width="15%" sx={{
431 fontWeight: 'bold',
432 bgcolor: '#1e1e1e',
433 color: 'white',
434 verticalAlign: 'top',
435 pt: 3,
436 borderRight: '1px solid #333'
437 }}>
438 {slot.label}
439 {slot.required &&
440 <Chip label="Required" size="small" color="error" sx={{ml: 1, height: 20}}/>}
441 </TableCell>
442
443 <TableCell>
444 {slot.component ? (
445 <Box sx={{display: 'flex', gap: 2, alignItems: 'flex-start'}}>
446 <Avatar
447 variant="rounded"
448 src={slot.component.imgUrl || slot.component.img_url}
449 sx={{width: 60, height: 60, bgcolor: '#eee'}}
450 >
451 {slot.component.brand?.[0]}
452 </Avatar>
453 <Box>
454 <Typography
455 variant="subtitle1"
456 fontWeight="bold"
457 sx={{cursor: 'pointer', color: 'primary.main'}}
458 onClick={() => setDetailsOpen(slot.component)}
459 >
460 {slot.component.name}
461 </Typography>
462 <Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5}}>
463 {renderSpecs(slot.component, slot.type)}
464 </Box>
465 </Box>
466 </Box>
467 ) : (
468 <Button
469 variant="outlined"
470 startIcon={<AddIcon/>}
471 onClick={() => handlePickPart(slot.id)}
472 sx={{textTransform: 'none', color: '#666', borderColor: '#ccc'}}
473 >
474 Choose {slot.label}
475 </Button>
476 )}
477 </TableCell>
478
479 <TableCell width="10%" sx={{verticalAlign: 'top', pt: 3}}>
480 {slot.component ? (
481 <>
482 ${(Number(slot.component.price) * (slot.component.quantity || 1)).toFixed(2)}
483 {(slot.component.quantity || 1) > 1 && (
484 <Typography variant="caption" display="block" color="text.secondary">
485 ${Number(slot.component.price).toFixed(2)} each
486 </Typography>
487 )}
488 </>
489 ) : '-'}
490 </TableCell>
491
492 <TableCell align="right" width="15%" sx={{verticalAlign: 'top', pt: 2}}>
493 {slot.component && (
494 <Box sx={{
495 display: 'flex',
496 gap: 1,
497 justifyContent: 'flex-end',
498 alignItems: 'center'
499 }}>
500 {(slot.type === 'memory' || slot.type === 'storage') && (
501 <>
502 <IconButton
503 size="small"
504 color="primary"
505 onClick={() => handleDecrementComponent(slot.id)}
506 disabled={(slot.component.quantity || 1) <= 1}
507 sx={{
508 bgcolor: 'action.hover',
509 '&:disabled': {bgcolor: 'action.disabledBackground'}
510 }}
511 >
512 <RemoveIcon/>
513 </IconButton>
514
515 <Typography
516 variant="body2"
517 sx={{
518 minWidth: '20px',
519 textAlign: 'center',
520 fontWeight: 'bold'
521 }}
522 >
523 {slot.component.quantity || 1}
524 </Typography>
525
526 <IconButton
527 size="small"
528 color="primary"
529 onClick={() => handleIncrementComponent(slot.id)}
530 sx={{bgcolor: 'action.hover'}}
531 >
532 <AddIcon/>
533 </IconButton>
534 </>
535 )}
536
537 <IconButton
538 color="error"
539 onClick={() => handleRemovePart(slot.id)}
540 >
541 <DeleteIcon/>
542 </IconButton>
543
544 {!slot.required && (
545 <IconButton
546 color="warning"
547 onClick={() => handleDeleteSlot(slot.id)}
548 >
549 <CloseIcon/>
550 </IconButton>
551 )}
552 </Box>
553 )}
554 </TableCell>
555 </TableRow>
556 ))}
557 </TableBody>
558 </Table>
559 </TableContainer>
560
561 <Box sx={{mt: 4, display: 'flex', justifyContent: 'center'}}>
562 <Button variant="outlined" startIcon={<AddIcon/>} onClick={(e) => setAnchorEl(e.currentTarget)}
563 sx={{mr: 2}}>
564 Add Optional Component
565 </Button>
566 <Menu
567 anchorEl={anchorEl}
568 open={Boolean(anchorEl)}
569 onClose={() => setAnchorEl(null)}
570 anchorReference="none"
571 sx={{
572 display: 'flex',
573 alignItems: 'center',
574 justifyContent: 'center',
575 }}
576 slotProps={{
577 paper: {
578 sx: {
579 position: 'absolute',
580 top: '30%',
581 // left: '50%',
582 // transform: 'translate(50%, +50%)',
583 }
584 }
585 }}
586 >
587 {/*Removed RAM & Storage from optional components*/}
588 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary'}}>
589 Accessories
590 </Typography>
591 <MenuItem onClick={() => handleAddSlot('optical_drive', 'Optical Drive')}>
592 <ListItemIcon><AlbumIcon/></ListItemIcon>
593 Optical Drive
594 </MenuItem>
595 <MenuItem onClick={() => handleAddSlot('cables', 'Cable')}>
596 <ListItemIcon><CableIcon/></ListItemIcon>
597 Cables
598 </MenuItem>
599
600 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}>
601 Expansion Cards
602 </Typography>
603 <MenuItem onClick={() => handleAddSlot('memory_card', 'Storage Card')}>
604 <ListItemIcon><MemoryIcon/></ListItemIcon>
605 Storage Card
606 </MenuItem>
607 <MenuItem onClick={() => handleAddSlot('sound_card', 'Sound Card')}>
608 <ListItemIcon><RouterIcon/></ListItemIcon>
609 Sound Card
610 </MenuItem>
611 <MenuItem onClick={() => handleAddSlot('network_card', 'Network Card')}>
612 <ListItemIcon><RouterIcon/></ListItemIcon>
613 Network Card
614 </MenuItem>
615 <MenuItem onClick={() => handleAddSlot('network_adapter', 'WiFi Adapter')}>
616 <ListItemIcon><RouterIcon/></ListItemIcon>
617 WiFi Adapter
618 </MenuItem>
619 </Menu>
620 </Box>
621
622 <Box sx={{mt: 4, p: 4, bgcolor: '#1e1e1e', textAlign: 'center', borderRadius: 2}}>
623 <Typography variant="h5" sx={{mb: 2, fontWeight: 'bold', color: 'white'}}>
624 Total: ${totalPrice.toFixed(2)}
625 </Typography>
626 <Button
627 variant="contained"
628 color="primary"
629 size="large"
630 onClick={handleSubmit}
631 disabled={isSubmitting}
632 >
633 {isSubmitting ? <CircularProgress size={24}/> : 'Submit Build For Review'}
634 </Button>
635 </Box>
636
637 <ComponentDialog
638 open={browserOpen}
639 category={activeSlotType}
640 onClose={() => {
641 setBrowserOpen(false);
642 setActiveSlotId(null);
643 }}
644 mode="forge"
645 onSelect={handleSelectComponent}
646 currentBuildId={buildId}
647 />
648
649 <ComponentDetailsDialog
650 open={!!detailsOpen}
651 component={detailsOpen}
652 onClose={() => setDetailsOpen(null)}
653 />
654
655 <Dialog open={submitDialogOpen} onClose={() => setSubmitDialogOpen(false)} maxWidth="sm" fullWidth>
656 <DialogTitle sx={{bgcolor: '#ff8201', color: 'white', fontWeight: 'bold'}}>
657 Build Description
658 </DialogTitle>
659 <DialogContent sx={{p: 3}}>
660 <Typography variant="body1" sx={{mb: 2, color: 'text.secondary'}}>
661 Add some notes about your build (optional):
662 </Typography>
663 <TextField
664 fullWidth
665 multiline
666 rows={4}
667 placeholder="e.g. Workstation monster, great for crunching numbers!"
668 value={tempDescription}
669 onChange={(e) => setTempDescription(e.target.value)}
670 variant="outlined"
671 />
672 </DialogContent>
673 <DialogActions sx={{p: 3, pt: 0}}>
674 <Button onClick={() => setSubmitDialogOpen(false)}>
675 Cancel
676 </Button>
677 <Button
678 variant="contained"
679 color="primary"
680 onClick={handleSubmitConfirm}
681 disabled={isSubmitting}
682 >
683 {isSubmitting ? (
684 <>
685 <CircularProgress size={20} sx={{mr: 1}}/>
686 Submitting...
687 </>
688 ) : (
689 'Submit Build'
690 )}
691 </Button>
692 </DialogActions>
693 </Dialog>
694 <Snackbar
695 open={snackbar.open}
696 autoHideDuration={4000}
697 onClose={() => setSnackbar(prev => ({...prev, open: false}))}
698 anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
699 >
700 <Alert
701 onClose={() => setSnackbar(prev => ({...prev, open: false}))}
702 severity={snackbar.severity}
703 variant="filled"
704 sx={{width: '100%'}}
705 >
706 {snackbar.message}
707 </Alert>
708 </Snackbar>
709 </Container>
710 );
711}
Note: See TracBrowser for help on using the repository browser.