source: pages/forge/+Page.tsx@ d07d68c

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

Fixed clone auth errors and component names in the component dialogs.

  • Property mode set to 100644
File size: 30.1 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 <Box sx={{
494 display: 'flex',
495 gap: 1,
496 justifyContent: 'flex-end',
497 alignItems: 'center'
498 }}>
499 {slot.component && (
500 <>
501 {(slot.type === 'memory' || slot.type === 'storage') && (
502 <>
503 <IconButton
504 size="small"
505 color="primary"
506 onClick={() => handleDecrementComponent(slot.id)}
507 disabled={(slot.component.quantity || 1) <= 1}
508 sx={{
509 bgcolor: 'action.hover',
510 '&:disabled': {bgcolor: 'action.disabledBackground'}
511 }}
512 >
513 <RemoveIcon/>
514 </IconButton>
515
516 <Typography
517 variant="body2"
518 sx={{
519 minWidth: '20px',
520 textAlign: 'center',
521 fontWeight: 'bold'
522 }}
523 >
524 {slot.component.quantity || 1}
525 </Typography>
526
527 <IconButton
528 size="small"
529 color="primary"
530 onClick={() => handleIncrementComponent(slot.id)}
531 sx={{bgcolor: 'action.hover'}}
532 >
533 <AddIcon/>
534 </IconButton>
535 </>
536 )}
537
538 <IconButton
539 color="error"
540 onClick={() => handleRemovePart(slot.id)}
541 >
542 <DeleteIcon/>
543 </IconButton>
544 </>
545 )}
546
547 {!slot.required && (
548 <IconButton
549 color="warning"
550 onClick={() => handleDeleteSlot(slot.id)}
551 >
552 <CloseIcon/>
553 </IconButton>
554 )}
555 </Box>
556 </TableCell>
557 </TableRow>
558 ))}
559 </TableBody>
560 </Table>
561 </TableContainer>
562
563 <Box sx={{mt: 4, display: 'flex', justifyContent: 'center'}}>
564 <Button variant="outlined" startIcon={<AddIcon/>} onClick={(e) => setAnchorEl(e.currentTarget)}
565 sx={{mr: 2}}>
566 Add Optional Component
567 </Button>
568 <Menu
569 anchorEl={anchorEl}
570 open={Boolean(anchorEl)}
571 onClose={() => setAnchorEl(null)}
572 anchorReference="none"
573 sx={{
574 display: 'flex',
575 alignItems: 'center',
576 justifyContent: 'center',
577 }}
578 slotProps={{
579 paper: {
580 sx: {
581 position: 'absolute',
582 top: '30%',
583 // left: '50%',
584 // transform: 'translate(50%, +50%)',
585 }
586 }
587 }}
588 >
589 {/*Removed RAM & Storage from optional components*/}
590 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary'}}>
591 Accessories
592 </Typography>
593 <MenuItem onClick={() => handleAddSlot('optical_drive', 'Optical Drive')}>
594 <ListItemIcon><AlbumIcon/></ListItemIcon>
595 Optical Drive
596 </MenuItem>
597 <MenuItem onClick={() => handleAddSlot('cables', 'Cable')}>
598 <ListItemIcon><CableIcon/></ListItemIcon>
599 Cables
600 </MenuItem>
601
602 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}>
603 Expansion Cards
604 </Typography>
605 <MenuItem onClick={() => handleAddSlot('memory_card', 'Storage Card')}>
606 <ListItemIcon><MemoryIcon/></ListItemIcon>
607 Storage Card
608 </MenuItem>
609 <MenuItem onClick={() => handleAddSlot('sound_card', 'Sound Card')}>
610 <ListItemIcon><RouterIcon/></ListItemIcon>
611 Sound Card
612 </MenuItem>
613 <MenuItem onClick={() => handleAddSlot('network_card', 'Network Card')}>
614 <ListItemIcon><RouterIcon/></ListItemIcon>
615 Network Card
616 </MenuItem>
617 <MenuItem onClick={() => handleAddSlot('network_adapter', 'WiFi Adapter')}>
618 <ListItemIcon><RouterIcon/></ListItemIcon>
619 WiFi Adapter
620 </MenuItem>
621 </Menu>
622 </Box>
623
624 <Box sx={{mt: 4, p: 4, bgcolor: '#1e1e1e', textAlign: 'center', borderRadius: 2}}>
625 <Typography variant="h5" sx={{mb: 2, fontWeight: 'bold', color: 'white'}}>
626 Total: ${totalPrice.toFixed(2)}
627 </Typography>
628 <Button
629 variant="contained"
630 color="primary"
631 size="large"
632 onClick={handleSubmit}
633 disabled={isSubmitting}
634 >
635 {isSubmitting ? <CircularProgress size={24}/> : 'Save Build'}
636 </Button>
637 </Box>
638
639 <ComponentDialog
640 open={browserOpen}
641 category={activeSlotType}
642 onClose={() => {
643 setBrowserOpen(false);
644 setActiveSlotId(null);
645 }}
646 mode="forge"
647 onSelect={handleSelectComponent}
648 currentBuildId={buildId}
649 />
650
651 <ComponentDetailsDialog
652 open={!!detailsOpen}
653 component={detailsOpen}
654 onClose={() => setDetailsOpen(null)}
655 />
656
657 <Dialog open={submitDialogOpen} onClose={() => setSubmitDialogOpen(false)} maxWidth="sm" fullWidth>
658 <DialogTitle sx={{bgcolor: '#ff8201', color: 'white', fontWeight: 'bold'}}>
659 Build Description
660 </DialogTitle>
661 <DialogContent sx={{p: 3}}>
662 <Typography variant="body1" sx={{mb: 2, color: 'text.secondary'}}>
663 Add some notes about your build (optional):
664 </Typography>
665 <TextField
666 fullWidth
667 multiline
668 rows={4}
669 placeholder="e.g. Workstation monster, great for crunching numbers!"
670 value={tempDescription}
671 onChange={(e) => setTempDescription(e.target.value)}
672 variant="outlined"
673 />
674 </DialogContent>
675 <DialogActions sx={{p: 3, pt: 0}}>
676 <Button onClick={() => setSubmitDialogOpen(false)}>
677 Cancel
678 </Button>
679 <Button
680 variant="contained"
681 color="primary"
682 onClick={handleSubmitConfirm}
683 disabled={isSubmitting}
684 >
685 {isSubmitting ? (
686 <>
687 <CircularProgress size={20} sx={{mr: 1}}/>
688 Submitting...
689 </>
690 ) : (
691 'Submit Build'
692 )}
693 </Button>
694 </DialogActions>
695 </Dialog>
696 <Snackbar
697 open={snackbar.open}
698 autoHideDuration={4000}
699 onClose={() => setSnackbar(prev => ({...prev, open: false}))}
700 anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
701 >
702 <Alert
703 onClose={() => setSnackbar(prev => ({...prev, open: false}))}
704 severity={snackbar.severity}
705 variant="filled"
706 sx={{width: '100%'}}
707 >
708 {snackbar.message}
709 </Alert>
710 </Snackbar>
711 </Container>
712 );
713}
Note: See TracBrowser for help on using the repository browser.