Changeset 8a7f936 for components
- Timestamp:
- 12/29/25 00:37:49 (6 months ago)
- Branches:
- main
- Children:
- 5af32f0
- Parents:
- ae5d054
- Location:
- components
- Files:
-
- 4 edited
-
BuildCard.tsx (modified) (1 diff)
-
BuildDetailsDialog.tsx (modified) (4 diffs)
-
ComponentDetailsDialog.tsx (modified) (3 diffs)
-
ComponentDialog.tsx (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
components/BuildCard.tsx
rae5d054 r8a7f936 1 import React from 'react';2 import { Card, CardMedia, CardContent, Typography, Box, Chip} from '@mui/material';1 import React, {useEffect, useState} from 'react'; 2 import {Card, CardContent, CardMedia, Typography, Box, Chip} from '@mui/material'; 3 3 import StarIcon from '@mui/icons-material/Star'; 4 // import PersonIcon from '@mui/icons-material/Person';4 import {onGetBuildDetails} from "../pages/+Layout.telefunc"; 5 5 6 export default function BuildCard({ build, onClick }: { build: any, onClick: () => void }) { 7 const formattedPrice = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(build.total_price || 0); 6 export default function BuildCard({build, onClick}: { build: any, onClick?: () => void }) { 7 const [caseImage, setCaseImage] = useState<string>("https://placehold.co/600x400?text=PC+Build"); 8 const [imageLoading, setImageLoading] = useState(true); 9 10 const formattedPrice = new Intl.NumberFormat('en-US', { 11 style: 'currency', 12 currency: 'EUR' 13 }).format(build.total_price || 0); 14 15 useEffect(() => { 16 onGetBuildDetails({buildId: build.id}) 17 .then(details => { 18 const caseComponent = details.components.find((c: any) => c.type === 'case'); 19 setCaseImage(caseComponent?.imgUrl || caseComponent?.imgUrl || "https://placehold.co/600x400?text=PC+Build"); 20 }) 21 .catch(() => { 22 }) 23 .finally(() => setImageLoading(false)); 24 }, [build.id]); 8 25 9 26 return ( 10 27 <Card 11 sx={{ height: '100%', display: 'flex', flexDirection: 'column', cursor: 'pointer', transition: 'all 0.2s', '&:hover': { transform: 'translateY(-4px)', boxShadow: 6 } }}12 28 onClick={onClick} 29 sx={{ 30 width: '100%', 31 height: '100%', 32 display: 'flex', 33 flexDirection: 'column', 34 cursor: onClick ? 'pointer' : 'default', 35 transition: 'transform 0.2s, box-shadow 0.2s', 36 '&:hover': onClick ? { 37 transform: 'translateY(-4px)', 38 boxShadow: 6 39 } : {}, 40 position: 'relative' 41 }} 13 42 > 14 <CardMedia 15 component="img" 16 height="160" 17 image={build.img_url || "https://placehold.co/600x400?text=PC+Build"} 18 alt={build.name} 19 /> 20 <CardContent sx={{ flexGrow: 1, pb: 1 }}> 21 <Typography gutterBottom variant="h6" noWrap title={build.name}> 22 {build.name} 23 </Typography> 43 <Box sx={{position: 'relative', paddingTop: '75%'}}> 44 <CardMedia 45 component="img" 46 image={caseImage || '/placeholder-pc.png'} 47 alt={build.name} 48 sx={{ 49 position: 'absolute', 50 top: 0, 51 left: 0, 52 width: '100%', 53 height: '100%', 54 objectFit: 'contain', 55 p: 2, 56 bgcolor: '#f5f5f5' 57 }} 58 /> 59 </Box> 24 60 25 {/*Ne se renderira user-ot*/} 26 <Box sx={{ display: 'flex', alignItems: 'center', mb: 1, color: 'text.secondary' }}> 27 {/* <PersonIcon sx={{ fontSize: 16, mr: 0.5 }} />*/} 28 {/* <Typography variant="caption">{build.user || 'Unknown'}</Typography>*/} 61 <CardContent sx={{flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between'}}> 62 <Box sx={{mb: 2}}> 63 <Typography variant="h6" component="div" fontWeight="bold" wrap title={build.name}> 64 {build.name} 65 </Typography> 29 66 </Box> 30 67 31 <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}> 32 <Chip label={formattedPrice} size="small" color="primary" variant="outlined" /> 33 <Box sx={{ display: 'flex', alignItems: 'center' }}> 34 <StarIcon fontSize="small" sx={{ color: '#faaf00', mr: 0.5 }} /> 68 <Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto'}}> 69 <Chip 70 label={formattedPrice} 71 color="primary" 72 variant="outlined" 73 size="small" 74 sx={{fontWeight: 'bold'}} 75 /> 76 <Box sx={{display: 'flex', alignItems: 'center'}}> 77 <StarIcon sx={{color: '#faaf00', fontSize: 20, mr: 0.5}}/> 35 78 <Typography variant="body2" fontWeight="bold"> 36 79 {Number(build.avgRating || 5).toFixed(1)} -
components/BuildDetailsDialog.tsx
rae5d054 r8a7f936 1 1 import React, { useEffect, useState } from 'react'; 2 2 import { 3 Dialog, DialogTitle, DialogContent, Button, Grid, Box, Typography,4 IconButton, Tab, Tabs, Table, TableBody, TableCell, TableRow, Rating, TextField, Avatar, Chip 3 Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Box, Typography, 4 IconButton, Tab, Tabs, Table, TableBody, TableCell, TableRow, Rating, TextField, Avatar, Chip, Alert 5 5 } from '@mui/material'; 6 6 import CloseIcon from '@mui/icons-material/Close'; … … 9 9 import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; 10 10 import PersonIcon from "@mui/icons-material/Person"; 11 import { onGetBuildDetails, onSetReview, onToggleFavorite} from '../pages/+Layout.telefunc';11 import {onGetBuildDetails, onSetReview, onToggleFavorite, onCloneBuild, onSetRating} from '../pages/+Layout.telefunc'; 12 12 13 13 const formatPrice = (price: any) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(Number(price) || 0); 14 14 15 export default function BuildDetailsDialog({ open, buildId, onClose, onClone,currentUser }: any) {15 export default function BuildDetailsDialog({ open, buildId, onClose, currentUser }: any) { 16 16 const [details, setDetails] = useState<any>(null); 17 17 const [loading, setLoading] = useState(false); 18 18 const [tabIndex, setTabIndex] = useState(0); 19 const [cloneDialogOpen, setCloneDialogOpen] = useState(false); 20 const [cloningBuildId, setCloningBuildId] = useState<number | null>(null); 19 21 20 22 const [reviewText, setReviewText] = useState(""); … … 45 47 const handleSubmitReview = async () => { 46 48 if (!currentUser) return alert("Please login to review."); 47 await onSetReview({ buildId, content: reviewText }); 48 49 setReviewText(""); 49 50 await onSetReview({ 51 buildId, 52 content: reviewText, 53 // rating: ratingVal 54 }); 55 56 await onSetRating({ 57 buildId, 58 value: ratingVal 59 }) 50 60 51 61 const refreshed = await onGetBuildDetails({ buildId }); … … 53 63 }; 54 64 65 const handleCloneConfirm = async () => { 66 if (!cloningBuildId) return; 67 68 try { 69 const newBuildId = await onCloneBuild({ buildId: cloningBuildId }); 70 71 window.location.href = `/forge?buildId=${newBuildId}`; 72 73 setCloneDialogOpen(false); 74 setCloningBuildId(null); 75 } catch (e) { 76 alert("Failed to clone build. Please try again."); 77 } 78 }; 79 80 55 81 if (!open) return null; 56 82 57 // @ts-ignore58 83 return ( 59 <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth scroll="paper"> 60 {loading || !details ? ( 61 <Box sx={{ p: 5, textAlign: 'center' }}>Loading Forge Schematics...</Box> 62 ) : ( 63 <> 64 <DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', bgcolor: '#ff8201' }}> 65 <Box> 66 <Typography variant="h5" fontWeight="bold">{details.name}</Typography> 67 <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> 68 <PersonIcon sx={{ fontSize: 16 }} /> 69 <Typography variant="subtitle2" color="text.secondary" fontWeight="bold"> 70 by {details.creator} 71 </Typography> 72 <Chip label={formatPrice(details.totalPrice)} size="small" color="primary" variant="outlined" /> 84 <> 85 <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth scroll="paper"> 86 {loading || !details ? ( 87 <Box sx={{ p: 5, textAlign: 'center' }}>Loading Forge Schematics...</Box> 88 ) : ( 89 <> 90 <DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', bgcolor: '#ff8201' }}> 91 <Box> 92 <Typography variant="h5" fontWeight="bold">{details.name}</Typography> 93 <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> 94 <PersonIcon sx={{ fontSize: 16 }} /> 95 <Typography variant="subtitle2" color="text.secondary" fontWeight="bold"> 96 by {details.creator} 97 </Typography> 98 <Chip label={formatPrice(details.totalPrice)} size="small" color="primary" variant="outlined" /> 99 </Box> 73 100 </Box> 74 </Box> 75 <IconButton onClick={onClose}><CloseIcon /></IconButton> 76 </DialogTitle> 77 78 <DialogContent sx={{ p: 0 }}> 79 <Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, bgcolor: 'primary', position: 'sticky', top: 0, zIndex: 1 }}> 80 <Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}> 81 <Tab label="Specs" /> 82 <Tab label={`Reviews (${details.ratingStatistics.ratingCount})`} /> 83 </Tabs> 84 </Box> 85 86 <Box sx={{ p: 3 }}> 87 {tabIndex === 0 && ( 88 <Grid container spacing={2}> 89 <Grid item xs={12} md={8}> 90 <Table size="small"> 91 <TableBody> 92 {details.components.map((comp: any) => ( 93 <TableRow key={comp.id}> 94 <TableCell sx={{ width: 50 }}> 95 <Avatar 96 src={comp.img_url || undefined} 97 variant="rounded" 98 sx={{ width: 45, height: 45, bgcolor: '#ff8201'}} 99 > 100 {comp.type?.substring(0, 3)?.toUpperCase()} 101 </Avatar> 102 </TableCell> 103 <TableCell> 104 <Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textTransform: 'uppercase' }}> 105 {comp.type} 106 </Typography> 107 <Typography variant="body1" fontWeight="500"> 108 {comp.brand} {comp.name} 109 </Typography> 110 </TableCell> 111 <TableCell align="right" sx={{ fontWeight: 'bold', color: '#ff8201' }}> 112 {formatPrice(comp.price)} 101 <IconButton onClick={onClose}><CloseIcon /></IconButton> 102 </DialogTitle> 103 104 <DialogContent sx={{ p: 0 }}> 105 <Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, bgcolor: 'primary', position: 'sticky', top: 0, zIndex: 1 }}> 106 <Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}> 107 <Tab label="Specs" /> 108 <Tab label={`Reviews (${details.ratingStatistics.ratingCount})`} /> 109 </Tabs> 110 </Box> 111 112 <Box sx={{ p: 3 }}> 113 {tabIndex === 0 && ( 114 <Grid container spacing={2}> 115 <Grid item xs={12} md={8}> 116 <Table size="small"> 117 <TableBody> 118 {details.components.map((comp: any) => ( 119 <TableRow key={comp.id}> 120 <TableCell sx={{ width: 50 }}> 121 <Avatar 122 src={comp.img_url || undefined} 123 variant="rounded" 124 sx={{ width: 45, height: 45, bgcolor: '#ff8201'}} 125 > 126 {comp.type?.substring(0, 3)?.toUpperCase()} 127 </Avatar> 128 </TableCell> 129 <TableCell> 130 <Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textTransform: 'uppercase' }}> 131 {comp.type} 132 </Typography> 133 <Typography variant="body1" fontWeight="500"> 134 {comp.brand} {comp.name} 135 </Typography> 136 </TableCell> 137 <TableCell align="right" sx={{ fontWeight: 'bold', color: '#ff8201' }}> 138 {formatPrice(comp.price)} 139 </TableCell> 140 </TableRow> 141 ))} 142 <TableRow sx={{ bgcolor: '#424343' }}> 143 <TableCell colSpan={2} sx={{ fontWeight: 'bold', color: '#ff8201' }}>TOTAL</TableCell> 144 <TableCell align="right" sx={{ fontWeight: 'bold', fontSize: '1.1rem', color: 'primary.main' }}> 145 {formatPrice(details.totalPrice)} 113 146 </TableCell> 114 147 </TableRow> 115 ))} 116 <TableRow sx={{ bgcolor: '#424343' }}> 117 <TableCell colSpan={2} sx={{ fontWeight: 'bold', color: '#ff8201' }}>TOTAL</TableCell> 118 <TableCell align="right" sx={{ fontWeight: 'bold', fontSize: '1.1rem', color: 'primary.main' }}> 119 {formatPrice(details.totalPrice)} 120 </TableCell> 121 </TableRow> 122 </TableBody> 123 </Table> 148 </TableBody> 149 </Table> 150 </Grid> 151 152 <Grid item xs={12} md={4}> 153 <Box sx={{ bgcolor: '#424343', p: 2, borderRadius: 2, mb: 2 }}> 154 <Typography color="primary.main" gutterBottom fontWeight="bold">Builder's Notes</Typography> 155 <Typography color="primary.main" variant="body2" sx={{ fontStyle: 'italic' }}> 156 "{details.description || "No notes provided."}" 157 </Typography> 158 </Box> 159 160 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> 161 <Button 162 variant="contained" 163 color="primary" 164 size="large" 165 startIcon={<AutoFixHighIcon />} 166 onClick={() => { 167 setCloningBuildId(details.id); 168 setCloneDialogOpen(true); 169 }} 170 > 171 Clone & Edit 172 </Button> 173 <Button 174 variant={details.isFavorite ? "contained" : "outlined"} 175 color={details.isFavorite ? "error" : "primary"} 176 startIcon={details.isFavorite ? <FavoriteIcon /> : <FavoriteBorderIcon />} 177 onClick={handleFavorite} 178 > 179 {details.isFavorite ? "Favorited" : "Add to Favorites"} 180 </Button> 181 </Box> 182 </Grid> 124 183 </Grid> 125 126 <Grid item xs={12} md={4}> 127 <Box sx={{ bgcolor: '#424343', p: 2, borderRadius: 2, mb: 2 }}> 128 <Typography color="primary.main" gutterBottom fontWeight="bold">Builder's Notes</Typography> 129 <Typography color="primary.main" variant="body2" sx={{ fontStyle: 'italic' }}> 130 "{details.description || "No notes provided."}" 131 </Typography> 184 )} 185 186 {tabIndex === 1 && ( 187 <Box> 188 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4, p: 2, bgcolor: '#5e5e5e', borderRadius: 2 }}> 189 <Typography variant="h3" fontWeight="bold">{details.ratingStatistics.averageRating.toFixed(1)}</Typography> 190 <Box> 191 <Rating value={details.ratingStatistics.averageRating} readOnly precision={0.5} /> 192 <Typography variant="body2" color="text.secondary">{details.ratingStatistics.ratingCount} ratings</Typography> 193 </Box> 132 194 </Box> 133 195 134 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> 135 <Button 136 variant="contained" 137 color="primary" 138 size="large" 139 startIcon={<AutoFixHighIcon />} 140 onClick={() => onClone({ buildId: details.id })} 141 > 142 Clone & Edit 143 </Button> 144 <Button 145 variant={details.isFavorite ? "contained" : "outlined"} 146 color={details.isFavorite ? "error" : "primary"} 147 startIcon={details.isFavorite ? <FavoriteIcon /> : <FavoriteBorderIcon />} 148 onClick={handleFavorite} 149 > 150 {details.isFavorite ? "Favorited" : "Add to Favorites"} 151 </Button> 152 </Box> 153 </Grid> 154 </Grid> 155 )} 156 157 {tabIndex === 1 && ( 158 <Box> 159 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4, p: 2, bgcolor: '#5e5e5e', borderRadius: 2 }}> 160 <Typography variant="h3" fontWeight="bold">{details.ratingStatistics.averageRating.toFixed(1)}</Typography> 161 <Box> 162 <Rating value={details.ratingStatistics.averageRating} readOnly precision={0.5} /> 163 <Typography variant="body2" color="text.secondary">{details.ratingStatistics.ratingCount} ratings</Typography> 196 {currentUser && details.userId !== currentUser.id && ( 197 <Box sx={{ mb: 4, p: 2, border: '1px solid #ddd', borderRadius: 2 }}> 198 <Typography variant="subtitle2" gutterBottom>Your Review</Typography> 199 <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> 200 <Rating value={ratingVal} onChange={(_, v) => setRatingVal(v || 5)} /> 201 </Box> 202 <TextField 203 fullWidth 204 multiline 205 rows={2} 206 placeholder="Share your thoughts on this build..." 207 value={reviewText} 208 onChange={(e) => setReviewText(e.target.value)} 209 sx={{ mb: 1 }} 210 /> 211 <Button size="small" variant="contained" onClick={handleSubmitReview}> 212 Submit Review 213 </Button> 214 </Box> 215 )} 216 217 {currentUser && details.userId === currentUser.id && ( 218 <Alert severity="info" sx={{ mb: 4 }}> 219 You cannot rate your own builds. 220 </Alert> 221 )} 222 223 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> 224 {details.reviews.map((rev: any, i: number) => ( 225 <Box key={i} sx={{ pb: 2, borderBottom: '1px solid #eee' }}> 226 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> 227 <Typography fontWeight="bold" variant="body2">{rev.username}</Typography> 228 <Typography variant="caption" color="text.secondary">{rev.createdAt}</Typography> 229 </Box> 230 <Typography variant="body2">{rev.content}</Typography> 231 </Box> 232 ))} 233 {details.reviews.length === 0 && ( 234 <Typography color="text.secondary" align="center">No reviews yet. Be the first!</Typography> 235 )} 164 236 </Box> 165 237 </Box> 166 167 {currentUser && ( 168 <Box sx={{ mb: 4, p: 2, border: '1px solid #ddd', borderRadius: 2 }}> 169 <Typography variant="subtitle2" gutterBottom>Your Review</Typography> 170 <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> 171 <Rating value={ratingVal} onChange={(_, v) => setRatingVal(v || 5)} /> 172 </Box> 173 <TextField 174 fullWidth 175 multiline 176 rows={2} 177 placeholder="Share your thoughts on this build..." 178 value={reviewText} 179 onChange={(e) => setReviewText(e.target.value)} 180 sx={{ mb: 1 }} 181 /> 182 <Button size="small" variant="contained" onClick={handleSubmitReview}> 183 Submit Review 184 </Button> 185 </Box> 186 )} 187 188 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2}}> 189 {details.reviews.map((rev: any, i: number) => ( 190 <Box key={i} sx={{ pb: 2, borderBottom: '1px solid #eee'}}> 191 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> 192 <Typography fontWeight="bold" variant="body2">{rev.username}</Typography> 193 <Typography variant="caption" color="text.secondary">{rev.createdAt}</Typography> 194 </Box> 195 <Typography variant="body2">{rev.content}</Typography> 196 </Box> 197 ))} 198 {details.reviews.length === 0 && ( 199 <Typography color="text.secondary" align="center">No reviews yet. Be the first!</Typography> 200 )} 201 </Box> 202 </Box> 203 )} 204 </Box> 205 </DialogContent> 206 </> 207 )} 208 </Dialog> 238 )} 239 </Box> 240 </DialogContent> 241 </> 242 )} 243 </Dialog> 244 245 <Dialog open={cloneDialogOpen} onClose={() => setCloneDialogOpen(false)}> 246 <DialogTitle>Clone Build</DialogTitle> 247 <DialogContent> 248 <Typography> 249 This will create a copy of "{details?.name}" in your Forge so you can edit it. 250 All components will be copied over. 251 </Typography> 252 </DialogContent> 253 <DialogActions> 254 <Button onClick={() => setCloneDialogOpen(false)}>Cancel</Button> 255 <Button 256 variant="contained" 257 onClick={handleCloneConfirm} 258 disabled={!cloningBuildId} 259 > 260 Clone Build 261 </Button> 262 </DialogActions> 263 </Dialog> 264 </> 209 265 ); 210 266 } -
components/ComponentDetailsDialog.tsx
rae5d054 r8a7f936 33 33 const renderValue = (key: string, val: any) => { 34 34 if (Array.isArray(val)) { 35 if (val.length > 0 && typeof val[0] === 'object') { 36 return val.map((v: any) => v.formFactor || v.socket || JSON.stringify(v)).join(', '); 35 if (val.length === 0) return 'None'; 36 37 if (typeof val[0] === 'string') { 38 return val.join(', '); 37 39 } 40 41 if (typeof val[0] === 'object') { 42 const parts = val 43 .map((v: any) => 44 v.socket || 45 v.formFactor || 46 v.name || 47 v.type || 48 Object.values(v)[0] 49 ) 50 .filter(Boolean); 51 return parts.length > 0 ? parts.join(', ') : 'None'; 52 } 53 38 54 return val.join(', '); 39 55 } … … 42 58 const lowerKey = key.toLowerCase(); 43 59 44 // Dodava merni edinici vo zavisnost koja komponenta e45 60 if (lowerKey.includes('capacity') || lowerKey.includes('vram') || lowerKey === 'memory') { 46 61 return `${strVal} GB`; … … 58 73 return `${strVal} GHz`; 59 74 } 75 60 76 if (lowerKey.includes('speed')) { 61 77 return `${strVal} MHz`; -
components/ComponentDialog.tsx
rae5d054 r8a7f936 1 1 import React, {useEffect, useState} from 'react'; 2 2 import { 3 Dialog, DialogContent, IconButton, Grid, Box, Typography,3 Dialog, DialogContent, IconButton, Box, Typography, Chip, 4 4 Slider, FormControl, InputLabel, Select, MenuItem, Checkbox, ListItemText, 5 Card, CardContent, CardMedia, Button, CircularProgress, Divider, AppBar, Toolbar 5 Card, CardContent, CardMedia, Button, CircularProgress, AppBar, Toolbar, InputAdornment, TextField, 6 TextField as MuiTextField, DialogTitle, DialogActions, Snackbar, Alert 6 7 } from '@mui/material'; 7 8 import CloseIcon from '@mui/icons-material/Close'; 8 import FilterListIcon from '@mui/icons-material/FilterList';9 import SortIcon from '@mui/icons-material/Sort'; 10 11 import {onGet AllComponents} from '../pages/+Layout.telefunc';9 import AddCircleIcon from '@mui/icons-material/AddCircle'; 10 11 import {onGetAllComponents, onGetAuthState, onSuggestComponent} from '../pages/+Layout.telefunc' 12 import {onGetCompatibleComponents} from '../pages/forge/forge.telefunc'; 12 13 import ComponentDetailsDialog from "./ComponentDetailsDialog"; 13 14 const formatMoney = (amount: number) => `$${amount}`; 15 16 export default function ComponentBrowserDialog({open, category, onClose}: any) { 14 import SearchIcon from "@mui/icons-material/Search"; 15 16 const formatMoney = (amount: number) => `$${Number(amount).toFixed(2)}`; 17 18 export default function ComponentDialog({open, category, onClose, mode, onSelect, currentBuildId}: any) { 17 19 const [components, setComponents] = useState<any[]>([]); 18 20 const [loading, setLoading] = useState(false); 19 20 21 const [selectedComponent, setSelectedComponent] = useState<any>(null); 21 22 const [priceRange, setPriceRange] = useState<number[]>([0, 2000]); 22 23 const [selectedBrands, setSelectedBrands] = useState<string[]>([]); 23 24 const [availableBrands, setAvailableBrands] = useState<string[]>([]); 24 25 25 const [sortOrder, setSortOrder] = useState<string>('default'); 26 27 const [tempSearchQuery, setTempSearchQuery] = useState(""); 28 const [searchQuery, setSearchQuery] = useState(""); 29 30 const [suggestOpen, setSuggestOpen] = useState(false); 31 const [suggestForm, setSuggestForm] = useState({ 32 link: '', 33 description: '', 34 componentType: category || '' 35 }); 36 const [suggestLoading, setSuggestLoading] = useState(false); 37 const [snackbarOpen, setSnackbarOpen] = useState(false); 38 const [snackbarMessage, setSnackbarMessage] = useState(""); 39 const [userId, setUserId] = useState<number | null>(null); 40 41 useEffect(() => { 42 const timeoutId = setTimeout(() => { 43 setSearchQuery(tempSearchQuery); 44 }, 300); 45 return () => clearTimeout(timeoutId); 46 }, [tempSearchQuery]); 47 48 // Load user auth 49 useEffect(() => { 50 if (open) { 51 onGetAuthState().then(userData => { 52 setUserId(userData.userId); 53 }); 54 } 55 }, [open]); 26 56 27 57 useEffect(() => { … … 30 60 setSortOrder('price_desc'); 31 61 32 onGetAllComponents({componentType: category}) 62 const shouldUseCompatibility = mode === 'forge' && currentBuildId; 63 const fetcher = shouldUseCompatibility 64 ? onGetCompatibleComponents({ 65 buildId: currentBuildId, 66 componentType: category, 67 limit: 100, 68 sort: 'price_desc' 69 }) 70 : onGetAllComponents({ 71 componentType: category, 72 limit: 100, 73 sort: 'price_desc' 74 }); 75 76 fetcher 33 77 .then((data) => { 34 setComponents(data); 35 36 const brands = Array.from(new Set(data.map((c: any) => c.brand))); 78 const comps = data || []; 79 setComponents(comps); 80 81 const brands = Array.from(new Set(comps.map((c: any) => c.brand))); 37 82 setAvailableBrands(brands as string[]); 38 39 const maxPrice = data.length > 0 ? Math.max(...data.map((c: any) => Number(c.price))) : 2000; 83 const maxPrice = comps.length > 0 ? Math.max(...comps.map((c: any) => Number(c.price))) : 2000; 40 84 setPriceRange([0, Math.ceil(maxPrice)]); 41 85 }) 42 .catch(console.error) 86 .catch((err) => { 87 console.error('[Dialog] fetch error:', err); 88 setComponents([]); 89 }) 43 90 .finally(() => setLoading(false)); 44 91 } 45 }, [open, category]); 92 }, [open, category, mode, currentBuildId]); 93 94 const handleSuggestChange = (e: React.ChangeEvent<HTMLInputElement>) => { 95 setSuggestForm({ 96 ...suggestForm, 97 [e.target.name]: e.target.value 98 }); 99 }; 100 101 const submitSuggestion = async () => { 102 if (!userId) { 103 setSnackbarMessage("Please login to submit suggestions!"); 104 setSnackbarOpen(true); 105 return; 106 } 107 108 if (!suggestForm.link || !suggestForm.description) { 109 setSnackbarMessage("Please fill in all fields!"); 110 setSnackbarOpen(true); 111 return; 112 } 113 114 setSuggestLoading(true); 115 try { 116 const suggestionId = await onSuggestComponent({ 117 link: suggestForm.link, 118 description: suggestForm.description, 119 componentType: suggestForm.componentType 120 }); 121 122 setSnackbarMessage("Suggestion submitted! Admin will review it."); 123 setSnackbarOpen(true); 124 setSuggestOpen(false); 125 setSuggestForm({link: '', description: '', componentType: category || ''}); 126 } catch (error) { 127 console.error('Suggestion error:', error); 128 setSnackbarMessage("Failed to submit suggestion. Try again."); 129 setSnackbarOpen(true); 130 } finally { 131 setSuggestLoading(false); 132 } 133 }; 46 134 47 135 let processedComponents = components.filter(comp => { 48 136 const matchesBrand = selectedBrands.length === 0 || selectedBrands.includes(comp.brand); 49 137 const matchesPrice = Number(comp.price) >= priceRange[0] && Number(comp.price) <= priceRange[1]; 50 return matchesBrand && matchesPrice; 138 const matchesSearch = searchQuery === "" || 139 comp.name.toLowerCase().includes(searchQuery.toLowerCase()) || 140 comp.brand.toLowerCase().includes(searchQuery.toLowerCase()); 141 142 return matchesBrand && matchesPrice && matchesSearch; 51 143 }); 52 144 … … 65 157 66 158 return ( 67 <Dialog 68 open={open} 69 onClose={onClose} 70 maxWidth="xl" 71 fullWidth 72 sx={{height: '90vh'}} 73 > 74 <AppBar position="relative" sx={{bgcolor: '#ff8201'}}> 75 <Toolbar> 76 <Typography sx={{ml: 2, flex: 1, textTransform: 'capitalize'}} variant="h6" component="div"> 77 Browsing: <b>{category === 'gpu' ? 'Graphics Cards' : category?.toUpperCase()}</b> 78 </Typography> 79 <IconButton edge="start" color="inherit" onClick={onClose}> 80 <CloseIcon/> 81 </IconButton> 82 </Toolbar> 83 </AppBar> 84 85 <DialogContent sx={{p: 0, display: 'flex', height: '100%'}}> 86 <Box sx={{ 87 width: 300, 88 borderRight: '1px solid #ddd', 89 p: 3, 90 bgcolor: '#1e1e1e', 91 display: {xs: 'none', md: 'block'} 92 }}> 93 <Box sx={{display: 'flex', alignItems: 'center', mb: 2}}> 94 <SortIcon sx={{mr: 1}}/> 95 <Typography variant="h6">Sort By</Typography> 159 <> 160 <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth sx={{height: '90vh'}}> 161 <AppBar position="relative" sx={{bgcolor: '#ff8201'}}> 162 <Toolbar> 163 <Typography sx={{ml: 2, flex: 1, textTransform: 'capitalize'}} variant="h6" component="div"> 164 Browsing: <b>{category === 'gpu' ? 'Graphics Cards' : category?.toUpperCase()}</b> 165 {mode === 'forge' && currentBuildId && ( 166 <Typography variant="caption" 167 sx={{ml: 2, bgcolor: 'rgba(0,0,0,0.2)', px: 1, borderRadius: 1}}> 168 COMPATIBILITY MODE ON 169 </Typography> 170 )} 171 </Typography> 172 <IconButton edge="start" color="inherit" onClick={onClose}> 173 <CloseIcon/> 174 </IconButton> 175 </Toolbar> 176 </AppBar> 177 178 <DialogContent sx={{p: 0, display: 'flex', height: '100%'}}> 179 <Box sx={{ 180 width: 300, 181 borderRight: '1px solid #ddd', 182 p: 3, 183 bgcolor: '#1e1e1e', 184 display: {xs: 'none', md: 'block'}, 185 overflowY: 'auto' 186 }}> 187 <TextField 188 fullWidth 189 size="small" 190 placeholder="Search components..." 191 value={tempSearchQuery} 192 onChange={(e) => setTempSearchQuery(e.target.value)} 193 sx={{mb: 2}} 194 InputProps={{ 195 startAdornment: <InputAdornment position="start"><SearchIcon/></InputAdornment>, 196 }} 197 /> 198 199 200 <FormControl fullWidth size="small" sx={{mb: 2}}> 201 <InputLabel>Sort By</InputLabel> 202 <Select value={sortOrder} label="Sort By" onChange={(e) => setSortOrder(e.target.value)}> 203 <MenuItem value="price_asc">Price: Low to High</MenuItem> 204 <MenuItem value="price_desc">Price: High to Low</MenuItem> 205 </Select> 206 </FormControl> 207 208 <FormControl fullWidth size="small" sx={{mb: 2}}> 209 <InputLabel>Brands</InputLabel> 210 <Select multiple value={selectedBrands} onChange={handleBrandChange} label="Brands" 211 renderValue={(s) => s.join(', ')}> 212 {availableBrands.map((brand) => ( 213 <MenuItem key={brand} value={brand}> 214 <Checkbox checked={selectedBrands.indexOf(brand) > -1}/> 215 <ListItemText primary={brand}/> 216 </MenuItem> 217 ))} 218 </Select> 219 </FormControl> 220 221 <Typography gutterBottom fontWeight="bold">Price Range</Typography> 222 <Slider value={priceRange} onChange={(_, v) => setPriceRange(v as number[])} min={0} max={2000} 223 sx={{color: '#ff8201'}}/> 224 <Box sx={{display: 'flex', justifyContent: 'space-between', mt: 1}}> 225 <Typography variant="caption">${priceRange[0]}</Typography> 226 <Typography variant="caption">${priceRange[1]}+</Typography> 227 </Box> 228 {/*TO BE FINISHED*/} 229 <Button 230 fullWidth 231 variant="contained" 232 startIcon={<AddCircleIcon/>} 233 onClick={() => setSuggestOpen(true)} 234 sx={{ 235 mt: 2, 236 bgcolor: '#ff8201', 237 fontWeight: 'bold', 238 fontSize: '0.875rem', 239 '&:hover': {bgcolor: '#e67300'} 240 }} 241 > 242 Suggest Component 243 </Button> 96 244 </Box> 97 <FormControl fullWidth size="small" sx={{mb: 4}}> 98 <InputLabel>Price</InputLabel> 99 <Select 100 value={sortOrder} 101 label="Price" 102 onChange={(e) => setSortOrder(e.target.value)}> 103 {/*<MenuItem value="default">Featured</MenuItem>*/} 104 <MenuItem value="price_asc">Price: Low to High</MenuItem> 105 <MenuItem value="price_desc">Price: High to Low</MenuItem> 106 </Select> 107 </FormControl> 108 109 <Divider sx={{mb: 3}}/> 110 111 <Box sx={{display: 'flex', alignItems: 'center', mb: 2}}> 112 <FilterListIcon sx={{mr: 1}}/> 113 <Typography variant="h6">Filters</Typography> 114 </Box> 115 116 <Typography gutterBottom fontWeight="bold">Price Range</Typography> 117 <Slider 118 value={priceRange} 119 onChange={(_, newValue) => setPriceRange(newValue as number[])} 120 valueLabelDisplay="auto" 121 min={0} 122 max={components.length > 0 ? Math.max(...components.map(c => Number(c.price))) : 2000} 123 sx={{color: '#ff8201', mb: 2}} 124 /> 125 <Box sx={{display: 'flex', justifyContent: 'space-between', mb: 2}}> 126 <Typography variant="caption">{formatMoney(priceRange[0])}</Typography> 127 <Typography variant="caption">{formatMoney(priceRange[1])}</Typography> 128 </Box> 129 130 <FormControl fullWidth size="small"> 131 <InputLabel>Brands</InputLabel> 132 <Select 133 multiple 134 value={selectedBrands} 135 onChange={handleBrandChange} 136 renderValue={(selected) => selected.join(', ')} 137 label="Brands" 138 > 139 {availableBrands.map((brand) => ( 140 <MenuItem key={brand} value={brand}> 141 <Checkbox checked={selectedBrands.indexOf(brand) > -1}/> 142 <ListItemText primary={brand}/> 143 </MenuItem> 144 ))} 145 </Select> 146 </FormControl> 147 </Box> 148 149 <Box sx={{flex: 1, p: 3, overflowY: 'auto'}}> 150 {loading ? ( 151 <Box sx={{display: 'flex', justifyContent: 'center', mt: 10}}> 152 <CircularProgress sx={{color: '#ff8201'}}/> 153 </Box> 154 ) : ( 155 <Grid container spacing={1}> 156 {processedComponents.map((comp) => ( 157 <Grid item xs={12} sm={6} md={4} lg={3} key={comp.id}> 158 <Card elevation={1} sx={{ 159 height: '100%', 160 display: 'flex', 161 flexDirection: 'column', 162 maxWidth: 220, 163 width: 220, 164 overflow: 'hidden', 165 whiteSpace: 'nowrap', 166 textOverflow: 'ellipsis' 167 }}> 168 <CardMedia 169 component="img" 170 height="140" 171 // image={`https://placehold.co/400x400?text=${encodeURIComponent(comp.name)}`} 172 image={comp.imgUrl} 173 alt={comp.name} 174 sx={{p: 2, objectFit: 'contain'}} 175 /> 176 <CardContent sx={{flexGrow: 1}}> 177 <Typography variant="caption" color="text.secondary" 178 sx={{textTransform: 'uppercase'}}> 179 {comp.brand} 180 </Typography> 181 <Typography variant="subtitle1" fontWeight="bold" sx={{ 182 lineHeight: 1.2, 183 mb: 1, 245 246 <Box sx={{flex: 1, p: 3, overflowY: 'auto', bgcolor: '#121212'}}> 247 {loading ? ( 248 <Box sx={{display: 'flex', justifyContent: 'center', mt: 10}}> 249 <CircularProgress sx={{color: '#ff8201'}}/> 250 </Box> 251 ) : ( 252 <Box sx={{ 253 display: 'grid', 254 gridTemplateColumns: { 255 xs: '1fr', 256 sm: 'repeat(2, 1fr)', 257 md: 'repeat(3, 1fr)', 258 lg: 'repeat(4, 1fr)', 259 xl: 'repeat(5, 1fr)' 260 }, 261 gap: 3, 262 width: '100%' 263 }}> 264 {processedComponents.map((comp) => ( 265 <Box key={comp.id} sx={{width: '100%'}}> 266 <Card 267 elevation={3} 268 sx={{ 269 width: '100%', 270 height: '100%', 271 display: 'flex', 272 flexDirection: 'column', 273 transition: 'transform 0.2s, box-shadow 0.2s', 274 '&:hover': { 275 transform: 'translateY(-4px)', 276 boxShadow: 6 277 } 278 }} 279 > 280 <Box sx={{position: 'relative', paddingTop: '75%', bgcolor: '#1e1e1e'}}> 281 <CardMedia 282 component="img" 283 image={comp.imgUrl || comp.img_url || `https://placehold.co/400x400?text=${comp.name}`} 284 alt={comp.name} 285 sx={{ 286 position: 'absolute', 287 top: 0, 288 left: 0, 289 width: '100%', 290 height: '100%', 291 objectFit: 'contain', 292 p: 2 293 }} 294 /> 295 </Box> 296 297 <CardContent sx={{ 298 flexGrow: 1, 299 display: 'flex', 300 flexDirection: 'column', 301 justifyContent: 'space-between' 184 302 }}> 185 {comp.name} 186 </Typography> 187 188 <Box sx={{ 189 display: 'flex', 190 justifyContent: 'space-between', 191 alignItems: 'center', 192 mt: 2 193 }}> 194 <Typography variant="h6" color="primary.main"> 303 <Box> 304 <Typography variant="caption" color="text.secondary" 305 sx={{textTransform: 'uppercase', letterSpacing: 0.5}}> 306 {comp.brand} 307 </Typography> 308 <Typography variant="subtitle1" fontWeight="bold" sx={{ 309 lineHeight: 1.2, 310 mt: 0.5, 311 mb: 1, 312 overflow: 'hidden', 313 textOverflow: 'ellipsis', 314 display: '-webkit-box', 315 WebkitLineClamp: 2, 316 WebkitBoxOrient: 'vertical', 317 minHeight: '2.4em' 318 }}> 319 {comp.name} 320 </Typography> 321 </Box> 322 <Typography variant="h6" color="primary.main" fontWeight="bold"> 195 323 {formatMoney(comp.price)} 196 324 </Typography> 325 </CardContent> 326 327 <Box sx={{p: 2, pt: 0, display: 'flex', flexDirection: 'column', gap: 1}}> 328 <Button 329 fullWidth 330 variant="contained" 331 onClick={() => setSelectedComponent(comp)} 332 sx={{ 333 bgcolor: '#ff8201', 334 fontWeight: 'bold', 335 '&:hover': {bgcolor: '#e67300'} 336 }} 337 > 338 Details 339 </Button> 340 {mode === 'forge' && onSelect && ( 341 <Button 342 fullWidth 343 variant="contained" 344 onClick={() => onSelect(comp)} 345 sx={{ 346 bgcolor: '#4caf50', 347 fontWeight: 'bold', 348 '&:hover': {bgcolor: '#388e3c'} 349 }} 350 > 351 Add 352 </Button> 353 )} 197 354 </Box> 198 </CardContent> 199 <Box sx={{p: 2, pt: 0}}> 200 <Button 201 fullWidth 202 variant="outlined" 203 onClick={() => setSelectedComponent(comp)} 204 sx={{ 205 backgroundColor: '#ff8201', 206 color: 'white', 207 borderColor: '#ff8201', 208 onHover: {backgroundColor: '#e67300', borderColor: '#e67300'} 209 }} 210 > 211 Details 212 </Button> 213 </Box> 214 </Card> 215 </Grid> 216 ))} 217 {processedComponents.length === 0 && ( 218 <Box sx={{width: '100%', textAlign: 'center', mt: 5}}> 219 <Typography variant="h6" color="text.secondary">No components match.</Typography> 220 </Box> 221 )} 222 </Grid> 223 )} 224 </Box> 225 </DialogContent> 226 <ComponentDetailsDialog 227 open={!!selectedComponent} 228 component={selectedComponent} 229 onClose={() => setSelectedComponent(null)} 230 /> 231 </Dialog> 355 </Card> 356 </Box> 357 ))} 358 {processedComponents.length === 0 && ( 359 <Box sx={{gridColumn: '1 / -1', width: '100%', textAlign: 'center', mt: 5}}> 360 <Typography variant="h6" color="text.secondary"> 361 No compatible components found. 362 </Typography> 363 {mode === 'forge' && ( 364 <Typography variant="body2" color="error"> 365 (Try removing incompatible parts) 366 </Typography> 367 )} 368 </Box> 369 )} 370 </Box> 371 )} 372 </Box> 373 </DialogContent> 374 375 <ComponentDetailsDialog 376 open={!!selectedComponent} 377 component={selectedComponent} 378 onClose={() => setSelectedComponent(null)} 379 /> 380 </Dialog> 381 382 <Dialog open={suggestOpen} onClose={() => setSuggestOpen(false)} maxWidth="sm" fullWidth> 383 <DialogTitle>Suggest a New Component</DialogTitle> 384 <DialogContent> 385 <MuiTextField 386 fullWidth 387 label="Product Link *" 388 name="link" 389 value={suggestForm.link} 390 onChange={handleSuggestChange} 391 placeholder="https://example.com/product" 392 sx={{mt: 1}} 393 size="small" 394 /> 395 <MuiTextField 396 fullWidth 397 label="Description *" 398 name="description" 399 value={suggestForm.description} 400 onChange={handleSuggestChange} 401 multiline 402 rows={3} 403 placeholder="Why should we add this component? Any specs/details?" 404 sx={{mt: 2}} 405 size="small" 406 /> 407 <Typography variant="caption" color="text.secondary" sx={{mt: 1, display: 'block'}}> 408 Category: <b>{category?.toUpperCase()}</b> 409 </Typography> 410 </DialogContent> 411 <DialogActions> 412 <Button onClick={() => setSuggestOpen(false)}>Cancel</Button> 413 <Button 414 onClick={submitSuggestion} 415 disabled={suggestLoading || !userId} 416 variant="contained" 417 startIcon={suggestLoading ? <CircularProgress size={20}/> : null} 418 > 419 {suggestLoading ? 'Submitting...' : 'Submit Suggestion'} 420 </Button> 421 </DialogActions> 422 </Dialog> 423 424 <Snackbar 425 open={snackbarOpen} 426 autoHideDuration={4000} 427 onClose={() => setSnackbarOpen(false)} 428 anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} 429 > 430 <Alert onClose={() => setSnackbarOpen(false)} sx={{width: '100%'}}> 431 {snackbarMessage} 432 </Alert> 433 </Snackbar> 434 </> 232 435 ); 233 436 }
Note:
See TracChangeset
for help on using the changeset viewer.
