Changeset 958bc89
- Timestamp:
- 12/29/25 04:34:44 (6 months ago)
- Branches:
- main
- Children:
- 41a2f81
- Parents:
- f46bf5c
- Files:
-
- 1 added
- 6 edited
-
components/AddComponentDialog.tsx (added)
-
components/BuildCard.tsx (modified) (6 diffs)
-
components/BuildDetailsDialog.tsx (modified) (3 diffs)
-
database/drizzle/util/componentFieldConfig.ts (modified) (1 diff)
-
pages/dashboard/admin/+Page.tsx (modified) (22 diffs)
-
pages/dashboard/admin/adminDashboard.telefunc.ts (modified) (1 diff)
-
pages/forge/+Page.tsx (modified) (9 diffs)
Legend:
- Unmodified
- Added
- Removed
-
components/BuildCard.tsx
rf46bf5c r958bc89 1 1 import React, {useEffect, useState} from 'react'; 2 import {Card, CardContent, CardMedia, Typography, Box, Chip } from '@mui/material';2 import {Card, CardContent, CardMedia, Typography, Box, Chip, CircularProgress} from '@mui/material'; 3 3 import StarIcon from '@mui/icons-material/Star'; 4 4 import {onGetBuildDetails} from "../pages/+Layout.telefunc"; … … 7 7 const [caseImage, setCaseImage] = useState<string>("https://placehold.co/600x400?text=PC+Build"); 8 8 const [imageLoading, setImageLoading] = useState(true); 9 const [details, setDetails] = useState<any>(null); 9 10 10 11 const formattedPrice = new Intl.NumberFormat('en-US', { … … 14 15 15 16 useEffect(() => { 17 setImageLoading(true); 16 18 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"); 19 .then(fullDetails => { 20 setDetails(fullDetails); 21 22 const caseComponent = fullDetails.components?.find((c: any) => c.type === 'case'); 23 setCaseImage( 24 caseComponent?.imgUrl || 25 "https://placehold.co/600x400?text=PC+Build" 26 ); 20 27 }) 21 .catch(() => { 28 .catch((error) => { 29 console.error("Failed to load build details:", error); 30 setDetails(null); 22 31 }) 23 .finally(() => setImageLoading(false)); 32 .finally(() => { 33 setImageLoading(false); 34 }); 24 35 }, [build.id]); 36 37 if (imageLoading) { 38 return ( 39 <Card sx={{width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center'}}> 40 <CircularProgress /> 41 </Card> 42 ); 43 } 25 44 26 45 return ( … … 44 63 <CardMedia 45 64 component="img" 46 image={caseImage || '/placeholder-pc.png'}65 image={caseImage} 47 66 alt={build.name} 48 67 sx={{ … … 60 79 61 80 <CardContent sx={{flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between'}}> 81 <Box sx={{mb: 1}}> 82 <Typography variant="h6" component="div" fontWeight="bold"> 83 {build.name} 84 </Typography> 85 </Box> 86 62 87 <Box sx={{mb: 2}}> 63 <Typography variant="h6" component="div" fontWeight="bold" wrap title={build.name}> 64 {build.name} 88 <Typography 89 variant="body2" 90 color="text.secondary" 91 sx={{fontStyle: 'italic', fontSize: '0.875rem'}} 92 > 93 {details?.description || "No description provided."} 65 94 </Typography> 66 95 </Box> … … 75 104 /> 76 105 <Box sx={{display: 'flex', alignItems: 'center'}}> 77 <StarIcon sx={{color: '#faaf00', fontSize: 20, m r: 0.5}}/>106 <StarIcon sx={{color: '#faaf00', fontSize: 20, ml: 1}}/> 78 107 <Typography variant="body2" fontWeight="bold"> 79 {Number(build.avgRating || 5).toFixed(1)}108 {Number(build.avgRating || 0).toFixed(1)} 80 109 </Typography> 81 110 </Box> -
components/BuildDetailsDialog.tsx
rf46bf5c r958bc89 1 import React, { useEffect, useState} from 'react';1 import React, {useEffect, useState} from 'react'; 2 2 import { 3 3 Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Box, Typography, … … 51 51 }, [open, buildId]); 52 52 53 // Ownership check for edit button54 53 useEffect(() => { 55 54 if (open && buildId !== null && typeof buildId === 'number') { 56 onGetBuildState({ buildId }) // ← Only buildId, no userId!55 onGetBuildState({buildId}) 57 56 .then(state => { 58 57 setIsOwner(!!state); … … 91 90 92 91 try { 93 const newBuildId = await onCloneBuild({ buildId: cloningBuildId});92 const newBuildId = await onCloneBuild({buildId: cloningBuildId}); 94 93 window.location.href = `/forge?buildId=${newBuildId}`; 95 94 setCloneDialogOpen(false); -
database/drizzle/util/componentFieldConfig.ts
rf46bf5c r958bc89 66 66 67 67 export const requiredFields: Record<ComponentType, string[]> = { 68 cpu: ['socket', 'cores', 'threads', 'baseClock', ' tdp'],69 gpu: ['vram', ' tdp', 'chipset', 'length'],68 cpu: ['socket', 'cores', 'threads', 'baseClock', 'boostClock', 'tdp'], 69 gpu: ['vram', 'baseClock', 'boostClock', 'tdp', 'chipset', 'length'], 70 70 memory: ['type', 'speed', 'capacity', 'modules'], 71 71 storage: ['type', 'capacity', 'formFactor'], -
pages/dashboard/admin/+Page.tsx
rf46bf5c r958bc89 10 10 import BuildIcon from '@mui/icons-material/Build'; 11 11 import MemoryIcon from '@mui/icons-material/Memory'; 12 import AddIcon from '@mui/icons-material/Add'; 13 import DeleteIcon from '@mui/icons-material/Delete'; 12 14 13 15 import { … … 19 21 import BuildCard from '../../../components/BuildCard'; 20 22 import BuildDetailsDialog from '../../../components/BuildDetailsDialog'; 21 import DeleteIcon from "@mui/icons-material/Delete";22 23 import {onDeleteBuild} from "../user/userDashboard.telefunc"; 24 import AddComponentDialog from "../../../components/AddComponentDialog"; 23 25 24 26 export default function AdminDashboard() { … … 26 28 const [loading, setLoading] = useState(true); 27 29 const [selectedBuildId, setSelectedBuildId] = useState<number | null>(null); 28 const [deleteDialog, setDeleteDialog] = useState<{ open: boolean, buildId: number | null, buildName: string }>({ open: false, buildId: null, buildName: '' }); 30 const [deleteDialog, setDeleteDialog] = useState<{ 31 open: boolean, 32 buildId: number | null, 33 buildName: string 34 }>({open: false, buildId: null, buildName: ''}); 29 35 const [deleteLoading, setDeleteLoading] = useState(false); 36 const [addDialogOpen, setAddDialogOpen] = useState(false); 30 37 31 38 const [buildApprovalDialog, setBuildApprovalDialog] = useState<{ … … 45 52 46 53 const loadData = () => { 54 setLoading(true); 47 55 getAdminInfoAndData() 48 56 .then(res => { … … 53 61 console.error(err); 54 62 alert("Failed to load admin data. Are you an admin?"); 63 setLoading(false); 55 64 }); 56 65 }; … … 114 123 115 124 const openDeleteDialog = (buildId: number, buildName: string) => { 116 setDeleteDialog({ open: true, buildId, buildName});125 setDeleteDialog({open: true, buildId, buildName}); 117 126 }; 118 127 … … 122 131 try { 123 132 await onDeleteBuild({buildId: deleteDialog.buildId}); 124 setDeleteDialog({ open: false, buildId: null, buildName: ''});125 loadData(); // refresh na user i favorite builds133 setDeleteDialog({open: false, buildId: null, buildName: ''}); 134 loadData(); 126 135 setSelectedBuildId(null); 127 136 } catch (e) { … … 132 141 }; 133 142 134 if (loading) return <Box sx={{p: 5, textAlign: 'center'}}><CircularProgress/></Box>; 135 if (!data) return <Box sx={{p: 5, textAlign: 'center'}}>Access Denied</Box>; 143 if (loading) { 144 return ( 145 <Container maxWidth="xl" sx={{mt: 4, mb: 10, display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh'}}> 146 <CircularProgress /> 147 </Container> 148 ); 149 } 150 151 if (!data) { 152 return ( 153 <Container maxWidth="xl" sx={{mt: 4, mb: 10, textAlign: 'center', p: 5}}> 154 <Typography variant="h6" color="error"> 155 Access Denied - Admin privileges required 156 </Typography> 157 </Container> 158 ); 159 } 136 160 137 161 return ( … … 151 175 <AdminPanelSettingsIcon sx={{fontSize: 70}}/> 152 176 </Avatar> 153 <Typography variant="h5" fontWeight="bold">{data.admin.username}</Typography> 154 <Typography variant="body2" sx={{opacity: 0.7, mb: 3}}>{data.admin.email}</Typography> 177 <Typography variant="h5" fontWeight="bold">{data.admin?.username || 'Admin'}</Typography> 178 <Typography variant="body2" sx={{opacity: 0.7, mb: 3}}> 179 {data.admin?.email || 'admin@example.com'} 180 </Typography> 155 181 156 182 <Chip label="ADMINISTRATOR" color="error" variant="outlined" sx={{fontWeight: 'bold'}}/> … … 162 188 </Typography> 163 189 <Typography variant="caption">Pending Builds</Typography> 190 </Box> 191 192 <Box sx={{width: '100%', textAlign: 'center', mt: 3}}> 193 <Button 194 variant="contained" 195 color="warning" 196 size="large" 197 fullWidth 198 startIcon={<BuildIcon />} 199 onClick={() => setAddDialogOpen(true)} 200 sx={{ mb: 2 }} 201 > 202 Add Component 203 </Button> 164 204 </Box> 165 205 </Paper> … … 190 230 </TableHead> 191 231 <TableBody> 192 {data.componentSuggestions .map((sug: any) => (232 {data.componentSuggestions?.map((sug: any) => ( 193 233 <TableRow key={sug.id}> 194 234 <TableCell sx={{maxWidth: 300}}> 195 <a href={sug.link} title={sug.link} 196 style={{textDecoration: 'none', color: 'inherit'}}> 235 <a 236 href={sug.link} 237 target="_blank" 238 rel="noopener noreferrer" 239 title={sug.link} 240 style={{textDecoration: 'none', color: 'inherit'}} 241 > 197 242 {sug.description || sug.link} 198 243 </a> 199 244 </TableCell> 200 <TableCell>{sug.componentType .toUpperCase()}</TableCell>245 <TableCell>{sug.componentType?.toUpperCase()}</TableCell> 201 246 <TableCell>{sug.userId}</TableCell> 202 247 <TableCell align="right"> 203 <IconButton size="small" color="success" 204 onClick={() => openSuggestionReview(sug.id, 'approved')}> 248 <IconButton 249 size="small" 250 color="success" 251 onClick={() => openSuggestionReview(sug.id, 'approved')} 252 > 205 253 <CheckCircleIcon/> 206 254 </IconButton> 207 <IconButton size="small" color="error" 208 onClick={() => openSuggestionReview(sug.id, 'rejected')}> 255 <IconButton 256 size="small" 257 color="error" 258 onClick={() => openSuggestionReview(sug.id, 'rejected')} 259 > 209 260 <CancelIcon/> 210 261 </IconButton> … … 232 283 <Grid container spacing={2}> 233 284 {data.pendingBuilds.map((build: any) => ( 234 <Grid item xs={12} md={4} key={build.id}>285 <Grid item xs={12} sm={6} md={4} lg={3} key={build.id}> 235 286 <Box sx={{position: 'relative'}}> 236 287 <BuildCard … … 242 293 display: 'flex', 243 294 gap: 1, 244 justifyContent: 'center', 245 bgcolor: '#292929', 246 p: 1, 247 borderRadius: 1 295 justifyContent: 'center' 248 296 }}> 249 297 <Button … … 251 299 color="warning" 252 300 size="small" 253 startIcon={<BuildIcon/>} 254 onClick={() => openBuildApproval(build.id, build.name)} 301 startIcon={<BuildIcon />} 302 onClick={(e) => { 303 e.stopPropagation(); 304 openBuildApproval(build.id, build.name); 305 }} 255 306 > 256 307 Review … … 275 326 <Grid container spacing={2}> 276 327 {data.userBuilds.slice(0, 4).map((build: any) => ( 277 <Grid item xs={12} sm={6} md={4} key={build.id}>278 <Box sx={{ position: 'relative'}}>328 <Grid item xs={12} sm={6} md={4} lg={3} key={build.id}> 329 <Box sx={{position: 'relative'}}> 279 330 <BuildCard 280 331 build={build} 281 332 onClick={() => setSelectedBuildId(build.id)} 282 333 /> 283 <Box sx={{ mt: 1, display: 'flex', justifyContent: 'center'}}>334 <Box sx={{mt: 1, display: 'flex', justifyContent: 'center'}}> 284 335 <Button 285 336 variant="outlined" … … 291 342 openDeleteDialog(build.id, build.name); 292 343 }} 293 sx={{ width: '100%' }}344 fullWidth 294 345 > 295 346 Delete … … 307 358 </Grid> 308 359 309 <Dialog open={suggestionDialog.open} 310 onClose={() => setSuggestionDialog({...suggestionDialog, open: false})}> 360 <Dialog open={suggestionDialog.open} onClose={() => setSuggestionDialog({...suggestionDialog, open: false})}> 311 361 <DialogTitle>Review Component Suggestion</DialogTitle> 312 362 <DialogContent> 313 363 <Typography gutterBottom>Status: <b>{suggestionDialog.action.toUpperCase()}</b></Typography> 314 364 <TextField 315 fullWidth label="Admin Comment" multiline rows={3} 316 value={adminComment} onChange={(e) => setAdminComment(e.target.value)} 365 fullWidth 366 label="Admin Comment" 367 multiline 368 rows={3} 369 value={adminComment} 370 onChange={(e) => setAdminComment(e.target.value)} 371 sx={{ mt: 2 }} 317 372 /> 318 373 </DialogContent> … … 323 378 </Dialog> 324 379 325 <Dialog open={buildApprovalDialog.open} 326 onClose={() => setBuildApprovalDialog({open: false, buildId: null, buildName: ''})}> 380 <Dialog 381 open={buildApprovalDialog.open} 382 onClose={() => setBuildApprovalDialog({open: false, buildId: null, buildName: ''})} 383 > 327 384 <DialogTitle>Review Build</DialogTitle> 328 385 <DialogContent> … … 368 425 </Dialog> 369 426 370 <Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, buildId: null, buildName: '' })}> 427 <Dialog 428 open={deleteDialog.open} 429 onClose={() => setDeleteDialog({open: false, buildId: null, buildName: ''})} 430 > 371 431 <DialogTitle>Delete Build</DialogTitle> 372 432 <DialogContent> … … 378 438 <DialogActions> 379 439 <Button 380 onClick={() => setDeleteDialog({ open: false, buildId: null, buildName: ''})}440 onClick={() => setDeleteDialog({open: false, buildId: null, buildName: ''})} 381 441 disabled={deleteLoading} 382 442 > … … 388 448 color="error" 389 449 disabled={deleteLoading} 390 startIcon={deleteLoading ? <CircularProgress size={20} /> : <DeleteIcon />}450 startIcon={deleteLoading ? <CircularProgress size={20}/> : <DeleteIcon />} 391 451 > 392 452 {deleteLoading ? 'Deleting...' : 'Delete Build'} … … 397 457 <BuildDetailsDialog 398 458 open={!!selectedBuildId} 399 buildId={selectedBuildId }400 currentUser={data.admin ?.id}459 buildId={selectedBuildId!} 460 currentUser={data.admin} 401 461 onClose={() => setSelectedBuildId(null)} 402 onClone={() => alert("Clone disabled in admin view")}403 462 isDashboardView={true} 463 /> 464 465 <AddComponentDialog 466 open={addDialogOpen} 467 onClose={() => setAddDialogOpen(false)} 468 onSuccess={() => { 469 loadData(); 470 setAddDialogOpen(false); 471 }} 404 472 /> 405 473 </Container> -
pages/dashboard/admin/adminDashboard.telefunc.ts
rf46bf5c r958bc89 1 1 import * as drizzleQueries from "../../../database/drizzle/queries"; 2 import { requireAdmin} from "../../../server/telefunc/ctx";2 import {requireAdmin} from "../../../server/telefunc/ctx"; 3 3 import {Abort} from "telefunc"; 4 4 import { validateComponentSpecificData } from "../../../database/drizzle/util/componentFieldConfig"; -
pages/forge/+Page.tsx
rf46bf5c r958bc89 3 3 Container, Paper, Typography, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, 4 4 Button, IconButton, Avatar, TextField, Grid, Chip, CircularProgress, 5 Menu, MenuItem, ListItemIcon 5 Menu, MenuItem, ListItemIcon, Dialog, DialogTitle, DialogContent, DialogActions 6 6 } from '@mui/material'; 7 7 import AddIcon from '@mui/icons-material/Add'; … … 18 18 onRemoveComponentFromBuild, 19 19 onDeleteBuild, 20 onGetBuildState, onGetBuildComponents 20 onGetBuildState, 21 onGetBuildComponents 21 22 } from './forge.telefunc'; 22 23 … … 57 58 const [detailsOpen, setDetailsOpen] = useState<any>(null); 58 59 const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); 60 const [submitDialogOpen, setSubmitDialogOpen] = useState(false); // NEW 61 const [tempDescription, setTempDescription] = useState(""); // NEW 59 62 const isSubmittedRef = React.useRef(false); 60 63 … … 63 66 setTotalPrice(price); 64 67 }, [slots]); 68 69 useEffect(() => { 70 if (buildId && buildName.trim()) { 71 const timeoutId = setTimeout(() => { 72 saveBuildState({ buildId, name: buildName.trim(), description }); 73 }, 1000); 74 75 return () => clearTimeout(timeoutId); 76 } 77 }, [buildId, buildName, description]); 65 78 66 79 useEffect(() => { … … 91 104 if (urlBuildId && Number.isInteger(Number(urlBuildId)) && Number(urlBuildId) > 0) { 92 105 const loadBuildId = Number(urlBuildId); 93 onGetBuildComponents({ buildId: loadBuildId }) 94 .then(async (components) => { 95 if (components && components.length > 0) { 106 107 onGetBuildState({ buildId: loadBuildId }) 108 .then((buildState) => { 109 if (buildState) { 96 110 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"); 111 setBuildName(buildState.build.name); 112 setDescription(buildState.build.description || ""); 116 113 } 117 114 }) 118 .catch(() => {}); 115 .catch(() => {}) 116 .finally(() => { 117 onGetBuildComponents({ buildId: loadBuildId }) 118 .then(async (components) => { 119 if (components && components.length > 0) { 120 const detailedComponents = await Promise.all( 121 components.map(async (c: any) => { 122 const full = await onGetComponentDetails({ componentId: c.id }).catch(() => null); 123 return full ? { ...c, ...full, details: full?.details } : c; 124 }) 125 ); 126 127 const componentMap = new Map(); 128 detailedComponents.forEach((c: any) => componentMap.set(c.type, c)); 129 130 setSlots(prevSlots => prevSlots.map(slot => { 131 const match = componentMap.get(slot.type); 132 return match ? { ...slot, component: match } : slot; 133 })); 134 135 window.history.replaceState({}, document.title, "/forge"); 136 } 137 }) 138 .catch(() => {}); 139 }); 119 140 } 120 141 }, []); … … 159 180 }; 160 181 161 162 182 const handleRemovePart = async (slotId: string) => { 163 183 const slot = slots.find(s => s.id === slotId); … … 199 219 }; 200 220 201 const handleSubmit = async() => {221 const handleSubmit = () => { 202 222 if (!buildName.trim()) return alert("Please name your build!"); 203 223 if (!buildId) return alert("You must add at least one component before submitting."); 204 224 225 setTempDescription(description || ""); 226 setSubmitDialogOpen(true); 227 }; 228 229 const handleSubmitConfirm = async () => { 230 if(!buildId) return; 231 205 232 setIsSubmitting(true); 233 setSubmitDialogOpen(false); 234 206 235 try { 236 await saveBuildState({ buildId, name: buildName.trim(), description: tempDescription }); 207 237 const result = await onEditBuild({buildId}); 208 209 238 if (!result) throw new Error("Failed to save build"); 210 239 … … 218 247 } 219 248 }; 220 221 249 222 250 const activeSlotType = useMemo(() => { … … 410 438 onClose={() => setDetailsOpen(null)} 411 439 /> 440 441 <Dialog open={submitDialogOpen} onClose={() => setSubmitDialogOpen(false)} maxWidth="sm" fullWidth> 442 <DialogTitle sx={{ bgcolor: '#ff8201', color: 'white', fontWeight: 'bold' }}> 443 Build Description 444 </DialogTitle> 445 <DialogContent sx={{ p: 3 }}> 446 <Typography variant="body1" sx={{ mb: 2, color: 'text.secondary' }}> 447 Add some notes about your build (optional): 448 </Typography> 449 <TextField 450 fullWidth 451 multiline 452 rows={4} 453 placeholder="e.g. Gaming build for 1440p, quiet operation, future-proof..." 454 value={tempDescription} 455 onChange={(e) => setTempDescription(e.target.value)} 456 variant="outlined" 457 /> 458 </DialogContent> 459 <DialogActions sx={{ p: 3, pt: 0 }}> 460 <Button onClick={() => setSubmitDialogOpen(false)}> 461 Cancel 462 </Button> 463 <Button 464 variant="contained" 465 color="primary" 466 onClick={handleSubmitConfirm} 467 disabled={isSubmitting} 468 > 469 {isSubmitting ? ( 470 <> 471 <CircularProgress size={20} sx={{ mr: 1 }} /> 472 Submitting... 473 </> 474 ) : ( 475 'Submit Build' 476 )} 477 </Button> 478 </DialogActions> 479 </Dialog> 412 480 </Container> 413 481 );
Note:
See TracChangeset
for help on using the changeset viewer.
