| 1 | import React, {useState, useEffect, useMemo} from 'react';
|
|---|
| 2 | import {
|
|---|
| 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';
|
|---|
| 7 | import AddIcon from '@mui/icons-material/Add';
|
|---|
| 8 | import DeleteIcon from '@mui/icons-material/Delete';
|
|---|
| 9 | import CloseIcon from "@mui/icons-material/Close";
|
|---|
| 10 | import AlbumIcon from "@mui/icons-material/Album";
|
|---|
| 11 | import CableIcon from "@mui/icons-material/Cable";
|
|---|
| 12 | import RouterIcon from "@mui/icons-material/Router";
|
|---|
| 13 | import MemoryIcon from "@mui/icons-material/Memory";
|
|---|
| 14 |
|
|---|
| 15 | import {
|
|---|
| 16 | saveBuildState,
|
|---|
| 17 | onAddComponentToBuild,
|
|---|
| 18 | onRemoveComponentFromBuild,
|
|---|
| 19 | onDeleteBuild,
|
|---|
| 20 | onGetBuildState, onGetBuildComponents
|
|---|
| 21 | } from './forge.telefunc';
|
|---|
| 22 |
|
|---|
| 23 | import ComponentDialog from '../../components/ComponentDialog';
|
|---|
| 24 | import ComponentDetailsDialog from '../../components/ComponentDetailsDialog';
|
|---|
| 25 | import {onAddNewBuild, onGetComponentDetails} from "../+Layout.telefunc";
|
|---|
| 26 | import {onEditBuild} from "../dashboard/user/userDashboard.telefunc";
|
|---|
| 27 |
|
|---|
| 28 | type BuildSlot = {
|
|---|
| 29 | id: string;
|
|---|
| 30 | type: string;
|
|---|
| 31 | label: string;
|
|---|
| 32 | component: any | null;
|
|---|
| 33 | required: boolean;
|
|---|
| 34 | };
|
|---|
| 35 |
|
|---|
| 36 | const 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 |
|
|---|
| 47 | export 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 |
|
|---|
| 224 | return (
|
|---|
| 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 | );
|
|---|
| 411 | }
|
|---|
| 412 |
|
|---|
| 413 | function 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 | }
|
|---|