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

main
Last change on this file since a744c90 was 958bc89, checked in by Mihail <mihail2.naumov@…>, 6 months ago

Added add component button, fixed several things

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