Ignore:
Timestamp:
12/29/25 00:37:49 (6 months ago)
Author:
Mihail <mihail2.naumov@…>
Branches:
main
Children:
5af32f0
Parents:
ae5d054
Message:

Added Forge Page, added suggest component, fixed styling

File:
1 edited

Legend:

Unmodified
Added
Removed
  • pages/forge/+Page.tsx

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