Changeset 958bc89


Ignore:
Timestamp:
12/29/25 04:34:44 (6 months ago)
Author:
Mihail <mihail2.naumov@…>
Branches:
main
Children:
41a2f81
Parents:
f46bf5c
Message:

Added add component button, fixed several things

Files:
1 added
6 edited

Legend:

Unmodified
Added
Removed
  • components/BuildCard.tsx

    rf46bf5c r958bc89  
    11import React, {useEffect, useState} from 'react';
    2 import {Card, CardContent, CardMedia, Typography, Box, Chip} from '@mui/material';
     2import {Card, CardContent, CardMedia, Typography, Box, Chip, CircularProgress} from '@mui/material';
    33import StarIcon from '@mui/icons-material/Star';
    44import {onGetBuildDetails} from "../pages/+Layout.telefunc";
     
    77    const [caseImage, setCaseImage] = useState<string>("https://placehold.co/600x400?text=PC+Build");
    88    const [imageLoading, setImageLoading] = useState(true);
     9    const [details, setDetails] = useState<any>(null);
    910
    1011    const formattedPrice = new Intl.NumberFormat('en-US', {
     
    1415
    1516    useEffect(() => {
     17        setImageLoading(true);
    1618        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                );
    2027            })
    21             .catch(() => {
     28            .catch((error) => {
     29                console.error("Failed to load build details:", error);
     30                setDetails(null);
    2231            })
    23             .finally(() => setImageLoading(false));
     32            .finally(() => {
     33                setImageLoading(false);
     34            });
    2435    }, [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    }
    2544
    2645    return (
     
    4463                <CardMedia
    4564                    component="img"
    46                     image={caseImage || '/placeholder-pc.png'}
     65                    image={caseImage}
    4766                    alt={build.name}
    4867                    sx={{
     
    6079
    6180            <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
    6287                <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."}
    6594                    </Typography>
    6695                </Box>
     
    75104                    />
    76105                    <Box sx={{display: 'flex', alignItems: 'center'}}>
    77                         <StarIcon sx={{color: '#faaf00', fontSize: 20, mr: 0.5}}/>
     106                        <StarIcon sx={{color: '#faaf00', fontSize: 20, ml: 1}}/>
    78107                        <Typography variant="body2" fontWeight="bold">
    79                             {Number(build.avgRating || 5).toFixed(1)}
     108                            {Number(build.avgRating || 0).toFixed(1)}
    80109                        </Typography>
    81110                    </Box>
  • components/BuildDetailsDialog.tsx

    rf46bf5c r958bc89  
    1 import React, { useEffect, useState } from 'react';
     1import React, {useEffect, useState} from 'react';
    22import {
    33    Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Box, Typography,
     
    5151    }, [open, buildId]);
    5252
    53     // Ownership check for edit button
    5453    useEffect(() => {
    5554        if (open && buildId !== null && typeof buildId === 'number') {
    56             onGetBuildState({ buildId })  // ← Only buildId, no userId!
     55            onGetBuildState({buildId})
    5756                .then(state => {
    5857                    setIsOwner(!!state);
     
    9190
    9291        try {
    93             const newBuildId = await onCloneBuild({ buildId: cloningBuildId });
     92            const newBuildId = await onCloneBuild({buildId: cloningBuildId});
    9493            window.location.href = `/forge?buildId=${newBuildId}`;
    9594            setCloneDialogOpen(false);
  • database/drizzle/util/componentFieldConfig.ts

    rf46bf5c r958bc89  
    6666
    6767export 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'],
    7070    memory: ['type', 'speed', 'capacity', 'modules'],
    7171    storage: ['type', 'capacity', 'formFactor'],
  • pages/dashboard/admin/+Page.tsx

    rf46bf5c r958bc89  
    1010import BuildIcon from '@mui/icons-material/Build';
    1111import MemoryIcon from '@mui/icons-material/Memory';
     12import AddIcon from '@mui/icons-material/Add';
     13import DeleteIcon from '@mui/icons-material/Delete';
    1214
    1315import {
     
    1921import BuildCard from '../../../components/BuildCard';
    2022import BuildDetailsDialog from '../../../components/BuildDetailsDialog';
    21 import DeleteIcon from "@mui/icons-material/Delete";
    2223import {onDeleteBuild} from "../user/userDashboard.telefunc";
     24import AddComponentDialog from "../../../components/AddComponentDialog";
    2325
    2426export default function AdminDashboard() {
     
    2628    const [loading, setLoading] = useState(true);
    2729    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: ''});
    2935    const [deleteLoading, setDeleteLoading] = useState(false);
     36    const [addDialogOpen, setAddDialogOpen] = useState(false);
    3037
    3138    const [buildApprovalDialog, setBuildApprovalDialog] = useState<{
     
    4552
    4653    const loadData = () => {
     54        setLoading(true);
    4755        getAdminInfoAndData()
    4856            .then(res => {
     
    5361                console.error(err);
    5462                alert("Failed to load admin data. Are you an admin?");
     63                setLoading(false);
    5564            });
    5665    };
     
    114123
    115124    const openDeleteDialog = (buildId: number, buildName: string) => {
    116         setDeleteDialog({ open: true, buildId, buildName });
     125        setDeleteDialog({open: true, buildId, buildName});
    117126    };
    118127
     
    122131        try {
    123132            await onDeleteBuild({buildId: deleteDialog.buildId});
    124             setDeleteDialog({ open: false, buildId: null, buildName: '' });
    125             loadData(); // refresh na user i favorite builds
     133            setDeleteDialog({open: false, buildId: null, buildName: ''});
     134            loadData();
    126135            setSelectedBuildId(null);
    127136        } catch (e) {
     
    132141    };
    133142
    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    }
    136160
    137161    return (
     
    151175                            <AdminPanelSettingsIcon sx={{fontSize: 70}}/>
    152176                        </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>
    155181
    156182                        <Chip label="ADMINISTRATOR" color="error" variant="outlined" sx={{fontWeight: 'bold'}}/>
     
    162188                            </Typography>
    163189                            <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>
    164204                        </Box>
    165205                    </Paper>
     
    190230                                        </TableHead>
    191231                                        <TableBody>
    192                                             {data.componentSuggestions.map((sug: any) => (
     232                                            {data.componentSuggestions?.map((sug: any) => (
    193233                                                <TableRow key={sug.id}>
    194234                                                    <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                                                        >
    197242                                                            {sug.description || sug.link}
    198243                                                        </a>
    199244                                                    </TableCell>
    200                                                     <TableCell>{sug.componentType.toUpperCase()}</TableCell>
     245                                                    <TableCell>{sug.componentType?.toUpperCase()}</TableCell>
    201246                                                    <TableCell>{sug.userId}</TableCell>
    202247                                                    <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                                                        >
    205253                                                            <CheckCircleIcon/>
    206254                                                        </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                                                        >
    209260                                                            <CancelIcon/>
    210261                                                        </IconButton>
     
    232283                                    <Grid container spacing={2}>
    233284                                        {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}>
    235286                                                <Box sx={{position: 'relative'}}>
    236287                                                    <BuildCard
     
    242293                                                        display: 'flex',
    243294                                                        gap: 1,
    244                                                         justifyContent: 'center',
    245                                                         bgcolor: '#292929',
    246                                                         p: 1,
    247                                                         borderRadius: 1
     295                                                        justifyContent: 'center'
    248296                                                    }}>
    249297                                                        <Button
     
    251299                                                            color="warning"
    252300                                                            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                                                            }}
    255306                                                        >
    256307                                                            Review
     
    275326                                    <Grid container spacing={2}>
    276327                                        {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'}}>
    279330                                                    <BuildCard
    280331                                                        build={build}
    281332                                                        onClick={() => setSelectedBuildId(build.id)}
    282333                                                    />
    283                                                     <Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
     334                                                    <Box sx={{mt: 1, display: 'flex', justifyContent: 'center'}}>
    284335                                                        <Button
    285336                                                            variant="outlined"
     
    291342                                                                openDeleteDialog(build.id, build.name);
    292343                                                            }}
    293                                                             sx={{ width: '100%' }}
     344                                                            fullWidth
    294345                                                        >
    295346                                                            Delete
     
    307358            </Grid>
    308359
    309             <Dialog open={suggestionDialog.open}
    310                     onClose={() => setSuggestionDialog({...suggestionDialog, open: false})}>
     360            <Dialog open={suggestionDialog.open} onClose={() => setSuggestionDialog({...suggestionDialog, open: false})}>
    311361                <DialogTitle>Review Component Suggestion</DialogTitle>
    312362                <DialogContent>
    313363                    <Typography gutterBottom>Status: <b>{suggestionDialog.action.toUpperCase()}</b></Typography>
    314364                    <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 }}
    317372                    />
    318373                </DialogContent>
     
    323378            </Dialog>
    324379
    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            >
    327384                <DialogTitle>Review Build</DialogTitle>
    328385                <DialogContent>
     
    368425            </Dialog>
    369426
    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            >
    371431                <DialogTitle>Delete Build</DialogTitle>
    372432                <DialogContent>
     
    378438                <DialogActions>
    379439                    <Button
    380                         onClick={() => setDeleteDialog({ open: false, buildId: null, buildName: '' })}
     440                        onClick={() => setDeleteDialog({open: false, buildId: null, buildName: ''})}
    381441                        disabled={deleteLoading}
    382442                    >
     
    388448                        color="error"
    389449                        disabled={deleteLoading}
    390                         startIcon={deleteLoading ? <CircularProgress size={20} /> : <DeleteIcon />}
     450                        startIcon={deleteLoading ? <CircularProgress size={20}/> : <DeleteIcon />}
    391451                    >
    392452                        {deleteLoading ? 'Deleting...' : 'Delete Build'}
     
    397457            <BuildDetailsDialog
    398458                open={!!selectedBuildId}
    399                 buildId={selectedBuildId}
    400                 currentUser={data.admin?.id}
     459                buildId={selectedBuildId!}
     460                currentUser={data.admin}
    401461                onClose={() => setSelectedBuildId(null)}
    402                 onClone={() => alert("Clone disabled in admin view")}
    403462                isDashboardView={true}
     463            />
     464
     465            <AddComponentDialog
     466                open={addDialogOpen}
     467                onClose={() => setAddDialogOpen(false)}
     468                onSuccess={() => {
     469                    loadData();
     470                    setAddDialogOpen(false);
     471                }}
    404472            />
    405473        </Container>
  • pages/dashboard/admin/adminDashboard.telefunc.ts

    rf46bf5c r958bc89  
    11import * as drizzleQueries from "../../../database/drizzle/queries";
    2 import { requireAdmin } from "../../../server/telefunc/ctx";
     2import {requireAdmin} from "../../../server/telefunc/ctx";
    33import {Abort} from "telefunc";
    44import { validateComponentSpecificData } from "../../../database/drizzle/util/componentFieldConfig";
  • pages/forge/+Page.tsx

    rf46bf5c r958bc89  
    33    Container, Paper, Typography, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
    44    Button, IconButton, Avatar, TextField, Grid, Chip, CircularProgress,
    5     Menu, MenuItem, ListItemIcon
     5    Menu, MenuItem, ListItemIcon, Dialog, DialogTitle, DialogContent, DialogActions
    66} from '@mui/material';
    77import AddIcon from '@mui/icons-material/Add';
     
    1818    onRemoveComponentFromBuild,
    1919    onDeleteBuild,
    20     onGetBuildState, onGetBuildComponents
     20    onGetBuildState,
     21    onGetBuildComponents
    2122} from './forge.telefunc';
    2223
     
    5758    const [detailsOpen, setDetailsOpen] = useState<any>(null);
    5859    const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
     60    const [submitDialogOpen, setSubmitDialogOpen] = useState(false);  // NEW
     61    const [tempDescription, setTempDescription] = useState("");      // NEW
    5962    const isSubmittedRef = React.useRef(false);
    6063
     
    6366        setTotalPrice(price);
    6467    }, [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]);
    6578
    6679    useEffect(() => {
     
    91104        if (urlBuildId && Number.isInteger(Number(urlBuildId)) && Number(urlBuildId) > 0) {
    92105            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) {
    96110                        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 || "");
    116113                    }
    117114                })
    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                });
    119140        }
    120141    }, []);
     
    159180    };
    160181
    161 
    162182    const handleRemovePart = async (slotId: string) => {
    163183        const slot = slots.find(s => s.id === slotId);
     
    199219    };
    200220
    201     const handleSubmit = async () => {
     221    const handleSubmit = () => {
    202222        if (!buildName.trim()) return alert("Please name your build!");
    203223        if (!buildId) return alert("You must add at least one component before submitting.");
    204224
     225        setTempDescription(description || "");
     226        setSubmitDialogOpen(true);
     227    };
     228
     229    const handleSubmitConfirm = async () => {
     230        if(!buildId) return;
     231
    205232        setIsSubmitting(true);
     233        setSubmitDialogOpen(false);
     234
    206235        try {
     236            await saveBuildState({ buildId, name: buildName.trim(), description: tempDescription });
    207237            const result = await onEditBuild({buildId});
    208 
    209238            if (!result) throw new Error("Failed to save build");
    210239
     
    218247        }
    219248    };
    220 
    221249
    222250    const activeSlotType = useMemo(() => {
     
    410438                onClose={() => setDetailsOpen(null)}
    411439            />
     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>
    412480        </Container>
    413481    );
Note: See TracChangeset for help on using the changeset viewer.