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

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

Added delete button and still fixing naming problem with build creation

  • Property mode set to 100644
File size: 20.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
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, onGetBuildComponents
21} from './forge.telefunc';
22
23import ComponentDialog from '../../components/ComponentDialog';
24import ComponentDetailsDialog from '../../components/ComponentDetailsDialog';
25import {onAddNewBuild, onGetComponentDetails} from "../+Layout.telefunc";
26import {onEditBuild} from "../dashboard/user/userDashboard.telefunc";
27
28type BuildSlot = {
29 id: string;
30 type: string;
31 label: string;
32 component: any | null;
33 required: boolean;
34};
35
36const 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
47export 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({
134 name: buildName.trim() || "New Build",
135 description: description || "Work in progress"
136 });
137 id = typeof result === 'number' ? result : (result as any)?.buildId;
138 if (!id || !Number.isInteger(id) || id <= 0) {
139 alert("Failed to create draft build.");
140 return;
141 }
142 setBuildId(id);
143 }
144
145 const full = await onGetComponentDetails({componentId: component.id}).catch(() => null);
146 const merged = full ? {...component, ...full, details: full.details} : component;
147
148 setSlots(prev => prev.map(slot =>
149 slot.id === activeSlotId ? {...slot, component: merged} : slot
150 ));
151 setBrowserOpen(false);
152
153 await onAddComponentToBuild({buildId: id, componentId: component.id});
154 } catch (e) {
155 alert("Failed to add component. Please try again.");
156 } finally {
157 setActiveSlotId(null);
158 }
159 };
160
161
162 const handleRemovePart = async (slotId: string) => {
163 const slot = slots.find(s => s.id === slotId);
164 if (!slot?.component || !buildId) return;
165
166 setSlots(prev => prev.map(s =>
167 s.id === slotId ? {...s, component: null} : s
168 ));
169
170 try {
171 await onRemoveComponentFromBuild({
172 buildId,
173 componentId: slot.component.id
174 });
175 } catch (e) {
176 console.error("Failed to remove component from server", e);
177 }
178 };
179
180 const handleAddSlot = (type: string, label: string) => {
181 const count = slots.filter(s => s.type === type).length;
182 const newSlot: BuildSlot = {
183 id: `${type}_${count + 1}`,
184 type,
185 label: `${label} ${count > 0 ? count + 1 : ''}`,
186 component: null,
187 required: false
188 };
189 setSlots(prev => [...prev, newSlot]);
190 setAnchorEl(null);
191 };
192
193 const handleDeleteSlot = (slotId: string) => {
194 const slot = slots.find(s => s.id === slotId);
195 if (slot?.component) {
196 handleRemovePart(slotId);
197 }
198 setSlots(prev => prev.filter(s => s.id !== slotId));
199 };
200
201 const handleSubmit = async () => {
202 if (!buildName.trim()) return alert("Please name your build!");
203 if (!buildId) return alert("You must add at least one component before submitting.");
204
205 setIsSubmitting(true);
206 try {
207 const result = await onEditBuild({buildId});
208
209 if (!result) throw new Error("Failed to save build");
210
211 isSubmittedRef.current = true;
212 window.location.href = "/dashboard/user";
213 } catch (e) {
214 console.error(e);
215 alert("Failed to save build.");
216 } finally {
217 setIsSubmitting(false);
218 }
219 };
220
221
222 const activeSlotType = useMemo(() => {
223 if (!activeSlotId) return null;
224 return slots.find(s => s.id === activeSlotId)?.type || null;
225 }, [slots, activeSlotId]);
226
227 return (
228 <Container maxWidth="xl" sx={{mt: 0, mb: 10}}>
229 <Paper sx={{p: 4, mb: 0, bgcolor: '#ff8201', border: '1px solid #1e1e1e', color: 'white'}}>
230 <Typography variant="h4" align="center" fontWeight="bold">Forge Your Machine</Typography>
231 <Grid container spacing={2} justifyContent="center" sx={{mt: 2}}>
232 <Grid item xs={12} md={6}>
233 <TextField
234 fullWidth
235 label="Build Name *"
236 value={buildName}
237 onChange={e => setBuildName(e.target.value)}
238 sx={{bgcolor: '#1e1e1e', borderRadius: 1, color: 'white'}}
239 />
240 </Grid>
241 </Grid>
242 </Paper>
243
244 <TableContainer component={Paper} elevation={3}>
245 <Table sx={{minWidth: 650}}>
246 <TableHead sx={{bgcolor: '#1e1e1e'}}>
247 <TableRow>
248 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Component</TableCell>
249 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Selection & Specs</TableCell>
250 <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Price</TableCell>
251 <TableCell sx={{color: 'white', fontWeight: 'bold'}} align="right">Actions</TableCell>
252 </TableRow>
253 </TableHead>
254 <TableBody>
255 {slots.map((slot) => (
256 <TableRow key={slot.id} hover>
257 <TableCell width="15%" sx={{
258 fontWeight: 'bold',
259 bgcolor: '#1e1e1e',
260 color: 'white',
261 verticalAlign: 'top',
262 pt: 3,
263 borderRight: '1px solid #333'
264 }}>
265 {slot.label}
266 {slot.required &&
267 <Chip label="Required" size="small" color="error" sx={{ml: 1, height: 20}}/>}
268 </TableCell>
269
270 <TableCell>
271 {slot.component ? (
272 <Box sx={{display: 'flex', gap: 2, alignItems: 'flex-start'}}>
273 <Avatar
274 variant="rounded"
275 src={slot.component.imgUrl || slot.component.img_url}
276 sx={{width: 60, height: 60, bgcolor: '#eee'}}
277 >
278 {slot.component.brand?.[0]}
279 </Avatar>
280 <Box>
281 <Typography
282 variant="subtitle1"
283 fontWeight="bold"
284 sx={{cursor: 'pointer', color: 'primary.main'}}
285 onClick={() => setDetailsOpen(slot.component)}
286 >
287 {slot.component.name}
288 </Typography>
289 <Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5}}>
290 {renderSpecs(slot.component, slot.type)}
291 </Box>
292 </Box>
293 </Box>
294 ) : (
295 <Button
296 variant="outlined"
297 startIcon={<AddIcon/>}
298 onClick={() => handlePickPart(slot.id)}
299 sx={{textTransform: 'none', color: '#666', borderColor: '#ccc'}}
300 >
301 Choose {slot.label}
302 </Button>
303 )}
304 </TableCell>
305
306 <TableCell width="10%" sx={{verticalAlign: 'top', pt: 3}}>
307 {slot.component ? `$${Number(slot.component.price).toFixed(2)}` : '-'}
308 </TableCell>
309
310 <TableCell align="right" width="10%" sx={{verticalAlign: 'top', pt: 2}}>
311 {slot.component && (
312 <IconButton color="error" onClick={() => handleRemovePart(slot.id)}>
313 <DeleteIcon/>
314 </IconButton>
315 )}
316 {!slot.required && (
317 <IconButton color="warning" onClick={() => handleDeleteSlot(slot.id)}>
318 <CloseIcon/>
319 </IconButton>
320 )}
321 </TableCell>
322 </TableRow>
323 ))}
324 </TableBody>
325 </Table>
326 </TableContainer>
327
328 <Box sx={{mt: 4, display: 'flex', justifyContent: 'center'}}>
329 <Button variant="outlined" startIcon={<AddIcon/>} onClick={(e) => setAnchorEl(e.currentTarget)}
330 sx={{mr: 2}}>
331 Add Optional Component
332 </Button>
333 <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
334 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary'}}>
335 Memory & Storage
336 </Typography>
337 <MenuItem onClick={() => handleAddSlot('memory', 'Memory')}>
338 <ListItemIcon><MemoryIcon/></ListItemIcon>
339 Additional Memory
340 </MenuItem>
341 <MenuItem onClick={() => handleAddSlot('storage', 'Storage')}>
342 <ListItemIcon><AlbumIcon/></ListItemIcon>
343 Additional Storage
344 </MenuItem>
345
346 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}>
347 Accessories
348 </Typography>
349 <MenuItem onClick={() => handleAddSlot('optical_drive', 'Optical Drive')}>
350 <ListItemIcon><AlbumIcon/></ListItemIcon>
351 Optical Drive
352 </MenuItem>
353 <MenuItem onClick={() => handleAddSlot('cables', 'Cable')}>
354 <ListItemIcon><CableIcon/></ListItemIcon>
355 Cables
356 </MenuItem>
357
358 <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}>
359 Expansion Cards
360 </Typography>
361 <MenuItem onClick={() => handleAddSlot('memory_card', 'Storage Card')}>
362 <ListItemIcon><MemoryIcon/></ListItemIcon>
363 Storage Card
364 </MenuItem>
365 <MenuItem onClick={() => handleAddSlot('sound_card', 'Sound Card')}>
366 <ListItemIcon><RouterIcon/></ListItemIcon>
367 Sound Card
368 </MenuItem>
369 <MenuItem onClick={() => handleAddSlot('network_card', 'Network Card')}>
370 <ListItemIcon><RouterIcon/></ListItemIcon>
371 Network Card
372 </MenuItem>
373 <MenuItem onClick={() => handleAddSlot('network_adapter', 'WiFi Adapter')}>
374 <ListItemIcon><RouterIcon/></ListItemIcon>
375 WiFi Adapter
376 </MenuItem>
377 </Menu>
378 </Box>
379
380 <Box sx={{mt: 4, p: 4, bgcolor: '#1e1e1e', textAlign: 'center', borderRadius: 2}}>
381 <Typography variant="h5" sx={{mb: 2, fontWeight: 'bold', color: 'white'}}>
382 Total: ${totalPrice.toFixed(2)}
383 </Typography>
384 <Button
385 variant="contained"
386 color="primary"
387 size="large"
388 onClick={handleSubmit}
389 disabled={isSubmitting}
390 >
391 {isSubmitting ? <CircularProgress size={24}/> : 'Submit Build For Review'}
392 </Button>
393 </Box>
394
395 <ComponentDialog
396 open={browserOpen}
397 category={activeSlotType}
398 onClose={() => {
399 setBrowserOpen(false);
400 setActiveSlotId(null);
401 }}
402 mode="forge"
403 onSelect={handleSelectComponent}
404 currentBuildId={buildId}
405 />
406
407 <ComponentDetailsDialog
408 open={!!detailsOpen}
409 component={detailsOpen}
410 onClose={() => setDetailsOpen(null)}
411 />
412 </Container>
413 );
414}
415
416function renderSpecs(c: any, type: string) {
417 if (!c) return null;
418 const data = {...c, ...(c.details || {})};
419 const chipStyle = {height: 24, fontSize: '0.75rem', bgcolor: 'rgba(0,0,0,0.05)'};
420 const specs: string[] = [];
421 const val = (k: string) => data[k] || data[k.toLowerCase()] || data[k.replace('_', '')];
422
423 switch (type) {
424 case 'cpu':
425 if (val('socket')) specs.push(val('socket'));
426 if (val('cores')) specs.push(`${val('cores')} Cores / ${val('threads')} Threads`);
427 const base = data.baseclock || data.baseClock || data.base_clock;
428 const boost = data.boostclock || data.boostClock || data.boost_clock;
429 if (base) specs.push(`Base: ${base}GHz`);
430 if (boost) specs.push(`Boost: ${boost}GHz`);
431 break;
432 case 'gpu':
433 if (val('vram')) specs.push(`${val('vram')}GB VRAM`);
434 if (val('chipset')) specs.push(val('chipset'));
435 if (val('length')) specs.push(`L: ${val('length')}mm`);
436 break;
437 case 'motherboard':
438 if (val('socket')) specs.push(val('socket'));
439 if (val('formfactor')) specs.push(val('formfactor'));
440 if (val('ramtype')) specs.push(val('ramtype'));
441 break;
442 case 'memory':
443 if (val('capacity')) specs.push(`${val('capacity')}GB`);
444 if (val('type')) specs.push(val('type'));
445 if (val('speed')) specs.push(`${val('speed')} MHz`);
446 if (val('modules')) specs.push(`${val('modules')}x`);
447 break;
448 case 'storage':
449 if (val('capacity')) specs.push(`${val('capacity')}GB`);
450 if (val('type')) specs.push(val('type'));
451 break;
452 case 'power_supply':
453 if (val('wattage')) specs.push(`Wattage: ${val('wattage')}W`);
454 if (val('type')) specs.push(val('type'));
455 break;
456 case 'case':
457 if (val('gpuMaxLength')) specs.push(`Max GPU Length: ${val('gpuMaxLength')}mm`);
458 if (val('coolerMaxHeight')) specs.push(`Max CPU Cooler Height: ${val('coolerMaxHeight')}mm`);
459 break;
460 case 'cooler':
461 if (val('type')) specs.push(`${val('type')} Cooler`);
462 if (val('height')) specs.push(`${val('height')}mm`);
463 break;
464 default:
465 if (data.brand) specs.push(data.brand);
466 }
467
468 if (specs.length === 0) {
469 if (data.brand) return <Chip label={data.brand} sx={chipStyle}/>;
470 return <Typography variant="caption" color="text.secondary">...</Typography>;
471 }
472
473 return specs.map((label, i) => <Chip key={i} label={label} sx={chipStyle}/>);
474}
Note: See TracBrowser for help on using the repository browser.