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

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

Fixed total build price error and other small errors.

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