Changeset 8a7f936
- Timestamp:
- 12/29/25 00:37:49 (6 months ago)
- Branches:
- main
- Children:
- 5af32f0
- Parents:
- ae5d054
- Files:
-
- 8 edited
-
components/BuildCard.tsx (modified) (1 diff)
-
components/BuildDetailsDialog.tsx (modified) (4 diffs)
-
components/ComponentDetailsDialog.tsx (modified) (3 diffs)
-
components/ComponentDialog.tsx (modified) (3 diffs)
-
database/drizzle/queries/components.ts (modified) (1 diff)
-
pages/dashboard/admin/+Page.tsx (modified) (3 diffs)
-
pages/forge/+Page.tsx (modified) (1 diff)
-
pages/index/+Page.tsx (modified) (11 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 } -
database/drizzle/queries/components.ts
rae5d054 r8a7f936 247 247 if(!existingComponents) return null; 248 248 249 250 249 251 const existing = { 250 252 cpu: existingComponents.find(c => c.type === 'cpu'), -
pages/dashboard/admin/+Page.tsx
rae5d054 r8a7f936 49 49 const openSuggestionReview = (id: number, action: 'approved' | 'rejected') => { 50 50 setSuggestionDialog({ open: true, id, action }); 51 setAdminComment(action === 'approved' ? " Approved by admin." : "Rejected:");51 setAdminComment(action === 'approved' ? "" : ""); 52 52 }; 53 53 … … 123 123 </a> 124 124 </TableCell> 125 <TableCell>{sug.componentType }</TableCell>125 <TableCell>{sug.componentType.toUpperCase()}</TableCell> 126 126 <TableCell>{sug.userId}</TableCell> 127 127 <TableCell align="right"> … … 207 207 208 208 <Dialog open={suggestionDialog.open} onClose={() => setSuggestionDialog({...suggestionDialog, open: false})}> 209 <DialogTitle>Review Suggestion</DialogTitle>209 <DialogTitle>Review Component Suggestion</DialogTitle> 210 210 <DialogContent> 211 211 <Typography gutterBottom>Status: <b>{suggestionDialog.action.toUpperCase()}</b></Typography> -
pages/forge/+Page.tsx
rae5d054 r8a7f936 1 export default function ForgePage(){ 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 2 224 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 ); 5 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 } -
pages/index/+Page.tsx
rae5d054 r8a7f936 1 import React, { useEffect, useState} from 'react';1 import React, {useEffect, useState} from 'react'; 2 2 import { 3 3 Container, Box, Typography, Button, Grid, IconButton, Dialog, DialogTitle, DialogContent … … 10 10 import BuildCard from '../../components/BuildCard'; 11 11 12 import { onGetApprovedBuilds, onGetAuthState, onCloneBuild} from '../+Layout.telefunc';12 import {onGetApprovedBuilds, onGetAuthState, onCloneBuild} from '../+Layout.telefunc'; 13 13 14 14 export default function HomePage() { … … 29 29 ] = await Promise.all([ 30 30 onGetAuthState(), 31 onGetApprovedBuilds({ limit: 5 , sort: 'rating_desc'}),32 onGetApprovedBuilds({ limit: 12}),33 onGetApprovedBuilds({ limit: 10, sort: 'rating_desc'})31 onGetApprovedBuilds({limit: 5, sort: 'rating_desc'}), 32 onGetApprovedBuilds({limit: 12}), 33 onGetApprovedBuilds({limit: 20, sort: 'rating_desc'}) 34 34 ]); 35 35 … … 45 45 } 46 46 } 47 47 48 loadSite(); 48 49 }, []); … … 51 52 if (!data?.isLoggedIn) return alert("Please login to clone builds!"); 52 53 if (confirm(`Clone this build to your dashboard?`)) { 53 await onCloneBuild({ buildId});54 await onCloneBuild({buildId}); 54 55 alert("Build cloned! Check your dashboard."); 55 56 setSelectedBuildId(null); … … 57 58 }; 58 59 59 if (!data) return <Box sx={{ p: 10, textAlign: 'center'}}>Loading Forge...</Box>;60 if (!data) return <Box sx={{p: 10, textAlign: 'center'}}>Loading Forge...</Box>; 60 61 61 62 return ( … … 69 70 } 70 71 }}> 71 <Box sx={{ position: 'relative', textAlign: 'center', zIndex: 1, p: 2}}>72 <Box sx={{position: 'relative', textAlign: 'center', zIndex: 1, p: 2}}> 72 73 <Typography variant="h2" fontWeight="bold" gutterBottom>Forge Your Ultimate Machine</Typography> 73 <Typography variant="h5" sx={{ maxWidth: '800px', mx: 'auto', mb: 1}}>74 <Typography variant="h5" sx={{maxWidth: '800px', mx: 'auto', mb: 1}}> 74 75 Build, share, discuss, and discover custom PC configurations. 75 76 </Typography> 76 <Button variant="contained" size="large" href="/forge" startIcon={<AutoFixHighIcon />}77 sx={{ fontSize: '1.2rem', px: 4, py: 1.5, mt: 2}}>77 <Button variant="contained" size="large" href="/forge" startIcon={<AutoFixHighIcon/>} 78 sx={{fontSize: '1.2rem', px: 4, py: 1.5, mt: 2}}> 78 79 Start Forging 79 80 </Button> … … 81 82 </Box> 82 83 83 <Container maxWidth="xl" sx={{ mt: 6, mb: 10}}>84 <Container maxWidth="xl" sx={{mt: 6, mb: 10}}> 84 85 <Box sx={{ 85 mb: 4, borderLeft: '5px solid #ff8201', pl: 2,86 mb: 2, borderLeft: '5px solid #ff8201', pl: 1, 86 87 display: 'flex', justifyContent: 'space-between', alignItems: 'end' 87 88 }}> … … 92 93 </Typography> 93 94 </Box> 94 <Button variant="outlined" startIcon={<ListIcon />} onClick={() => setOpenRankedPopup(true)}>95 <Button variant="outlined" startIcon={<ListIcon/>} onClick={() => setOpenRankedPopup(true)}> 95 96 Show All Top Ranked 96 97 </Button> 97 98 </Box> 98 99 <Grid container spacing={3} sx={{ mb: 8, alignItems: 'stretch' }}> 100 {data.prebuilts.slice(0, 3).map((build: any) => ( 101 <Grid item xs={12} sm={6} md={3} key={build.id} sx={{ display: 'flex' }}> 102 <Box sx={{ width: '100%' }}> 103 <BuildCard 104 build={build} 105 onClick={() => setSelectedBuildId(build.id)} 106 /> 107 </Box> 108 </Grid> 99 <Box 100 sx={{ 101 display: 'grid', 102 gridTemplateColumns: { 103 xs: '1fr', 104 sm: 'repeat(2, 1fr)', 105 md: 'repeat(3, 1fr)', 106 lg: 'repeat(4, 1fr)', 107 xl: 'repeat(5, 1fr)', 108 }, 109 gap: 3, 110 width: '90%', 111 }} 112 > 113 {data.prebuilts.map((build: any) => ( 114 <BuildCard 115 key={build.id} 116 build={build} 117 onClick={() => setSelectedBuildId(build.id)} 118 /> 109 119 ))} 110 </Grid> 111 112 <SectionHeader title="Community Forge" subtitle="Fresh builds from users around the world."/> 113 <Grid container spacing={3}> 120 </Box> 121 122 <Box sx={{ 123 mb: 2, mt: 2, borderLeft: '5px solid #ff8201', pl: 1, 124 }}> 125 <Typography variant="h4" fontWeight="bold" color="text.primary">Community Forge</Typography> 126 <Typography variant="subtitle1" color="text.secondary" sx={{mb: 2}}>Fresh builds from users around 127 the world.</Typography> 128 </Box> 129 <Box 130 sx={{ 131 display: 'grid', 132 gridTemplateColumns: { 133 xs: '1fr', 134 sm: 'repeat(2, 1fr)', 135 md: 'repeat(3, 1fr)', 136 lg: 'repeat(4, 1fr)', 137 xl: 'repeat(5, 1fr)', 138 }, 139 gap: 3, 140 width: '90%', 141 }} 142 > 114 143 {data.communityBuilds.map((build: any) => ( 115 <Grid item xs={12} sm={6} md={3} key={build.id}> 116 <BuildCard 117 build={build} 118 onClick={() => setSelectedBuildId(build.id)} 119 /> 120 </Grid> 144 <BuildCard 145 key={build.id} 146 build={build} 147 onClick={() => setSelectedBuildId(build.id)} 148 /> 121 149 ))} 122 </ Grid>123 124 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 6}}>150 </Box> 151 152 <Box sx={{display: 'flex', justifyContent: 'center', mt: 6}}> 125 153 <Button variant="outlined" size="large" href="/completed-builds">View All Community Builds</Button> 126 154 </Box> … … 135 163 /> 136 164 137 <Dialog open={openRankedPopup} onClose={() => setOpenRankedPopup(false)} maxWidth="lg" fullWidth scroll="paper"> 138 <DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> 165 <Dialog open={openRankedPopup} onClose={() => setOpenRankedPopup(false)} maxWidth="xl" fullWidth 166 scroll="paper"> 167 <DialogTitle sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}> 139 168 Hall of Fame (Top Rated) 140 <IconButton onClick={() => setOpenRankedPopup(false)}><CloseIcon /></IconButton>169 <IconButton onClick={() => setOpenRankedPopup(false)}><CloseIcon/></IconButton> 141 170 </DialogTitle> 142 171 <DialogContent dividers> 143 <Grid container spacing={3} >172 <Grid container spacing={3} sx={{p: 1}}> 144 173 {allRanked.map((build: any, index: number) => ( 145 <Grid item xs={12} sm={6} md={3} key={`ranked-${build.id}`}> 146 <Box sx={{ position: 'relative' }}> 174 <Grid 175 item 176 xs={12} 177 sm={6} 178 md={3} 179 key={`ranked-${build.id}`} 180 sx={{display: 'flex'}} 181 > 182 <Box sx={{position: 'relative', width: '100%', display: 'flex'}}> 147 183 <Box sx={{ 148 184 position: 'absolute', top: -10, left: -10, zIndex: 1, 149 width: 30, height: 30, borderRadius: '50%', 150 bgcolor: index < 3 ? '#ff8201' : 'grey.700', color: 'white', 151 display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' 185 width: 35, height: 35, borderRadius: '50%', 186 bgcolor: index < 3 ? '#ff8201' : 'grey.800', color: 'white', 187 display: 'flex', alignItems: 'center', justifyContent: 'center', 188 fontWeight: 'bold', border: '2px solid white', boxShadow: 2 152 189 }}> 153 190 #{index + 1} … … 166 203 </DialogContent> 167 204 </Dialog> 168 169 205 </Box> 170 206 ); 171 207 } 172 173 function SectionHeader({ title, subtitle }: { title: string, subtitle: string }) {174 return (175 <Box sx={{ mb: 4, borderLeft: '5px solid #ff8201', pl: 2 }}>176 <Typography variant="h4" fontWeight="bold" color="text.primary">{title}</Typography>177 <Typography variant="subtitle1" color="text.secondary">{subtitle}</Typography>178 </Box>179 );180 }
Note:
See TracChangeset
for help on using the changeset viewer.
