added tests by test and polished adaptivity

This commit is contained in:
David Katrinka 2025-12-26 22:02:37 +01:00
parent cba24b05c9
commit 6d9fa9061d
16 changed files with 376 additions and 30 deletions

View File

@ -1,6 +1,11 @@
import type { CategoryType } from "../components/shared/types/TestTypes";
import axiosInstance from "./axiosInstance"; import axiosInstance from "./axiosInstance";
interface CategoriesResponse {
data: CategoryType[];
}
export const getCategories = async () => { export const getCategories = async () => {
const res = await axiosInstance.get("/api/categories"); const res = await axiosInstance.get<CategoriesResponse>("/api/categories");
return res.data.data; return res.data.data;
}; };

View File

@ -1,7 +1,9 @@
import type { import {
StartTestPayload, type TestType,
SubmitAnswerPayload, type PaginatedTests,
UserTestType, type StartTestPayload,
type SubmitAnswerPayload,
type UserTestType,
} from "../components/shared/types/TestTypes"; } from "../components/shared/types/TestTypes";
import axiosInstance from "./axiosInstance"; import axiosInstance from "./axiosInstance";
@ -13,6 +15,14 @@ export const startTest = async (data: StartTestPayload) => {
return res.data.data; return res.data.data;
}; };
export const startTestById = async (id: number) => {
const res = await axiosInstance.post<{ data: UserTestType }>(
"/api/user-tests/by-test",
{ test_id: id }
);
return res.data.data;
};
export const completeUserTest = async (userTestId: number) => { export const completeUserTest = async (userTestId: number) => {
const res = await axiosInstance.post<{ message: string }>( const res = await axiosInstance.post<{ message: string }>(
`/api/user-tests/${userTestId}/complete` `/api/user-tests/${userTestId}/complete`
@ -28,7 +38,9 @@ export const getUserTestById = async (userTestId: number) => {
}; };
export const getUserTests = async () => { export const getUserTests = async () => {
const res = await axiosInstance.get<{data: UserTestType[]}>("/api/user-tests/me"); const res = await axiosInstance.get<{ data: UserTestType[] }>(
"/api/user-tests/me"
);
return res.data.data; return res.data.data;
}; };
@ -39,3 +51,16 @@ export const submitAnswer = async (data: SubmitAnswerPayload) => {
); );
return res.data; return res.data;
}; };
export const getTests = async (categoryId?: number, page = 1) => {
const params: Record<string, number> = { page };
if (categoryId !== undefined) params.category_id = categoryId;
const res = await axiosInstance.get<PaginatedTests>("/api/tests", { params });
return res.data;
};
export const getTestById = async (id: number) => {
const res = await axiosInstance.get<{ data: TestType }>(`/api/tests/${id}`);
return res.data.data;
};

View File

@ -109,6 +109,15 @@ function Header() {
)} )}
</span> </span>
)} )}
<MenuItem
onClick={() => {
handleCloseNavMenu();
navigate("/tests");
}}
>
<Typography textAlign="center">Tests</Typography>
</MenuItem>
</Menu> </Menu>
</Box> </Box>
@ -160,6 +169,15 @@ function Header() {
)} )}
</> </>
)} )}
<Button
sx={{ my: 2, color: "white", display: "block" }}
onClick={() => {
handleCloseNavMenu();
navigate("/tests");
}}
>
<Typography textAlign="center">Tests</Typography>
</Button>
</Box> </Box>
</Toolbar> </Toolbar>
</Container> </Container>

View File

@ -1,4 +1,5 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Box } from "@mui/material";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export const QuestionWrapper = styled(Link)({ export const QuestionWrapper = styled(Link)({
@ -6,24 +7,27 @@ export const QuestionWrapper = styled(Link)({
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
padding: "15px", padding: "15px",
border: "3px solid #4B2981", border: "1px solid #ccc",
borderRadius: "10px", borderRadius: "10px",
marginBottom: "10px", marginBottom: "10px",
textDecoration: "none", textDecoration: "none",
color: "inherit" color: "inherit",
}); });
export const QuestionTitle = styled("div")({ export const QuestionTitle = styled(Box)({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
marginBottom: "10px" marginBottom: "10px",
"@media (max-width:600px)": {
flexDirection: "column",
},
}); });
export const QuestionMetadata = styled("div")({ export const QuestionMetadata = styled("div")({
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
color: "#4c4c4c", color: "#4c4c4c",
marginTop: "10px" marginTop: "10px",
}); });
export const AuthorMeta = styled("div")({ export const AuthorMeta = styled("div")({

View File

@ -75,6 +75,7 @@ const StartTestForm = () => {
<FormControl sx={{ minWidth: "300px" }} variant="filled"> <FormControl sx={{ minWidth: "300px" }} variant="filled">
<InputLabel id="category-select-label">Category</InputLabel> <InputLabel id="category-select-label">Category</InputLabel>
<Select <Select
sx={{ maxWidth: "270px" }}
labelId="category-select-label" labelId="category-select-label"
id="category-select" id="category-select"
value={category} value={category}
@ -93,6 +94,7 @@ const StartTestForm = () => {
<FormControl sx={{ minWidth: "200px" }} variant="filled"> <FormControl sx={{ minWidth: "200px" }} variant="filled">
<InputLabel id="min-difficulty-select-label">Min Difficulty</InputLabel> <InputLabel id="min-difficulty-select-label">Min Difficulty</InputLabel>
<Select <Select
sx={{ maxWidth: "270px" }}
labelId="min-difficulty-select-label" labelId="min-difficulty-select-label"
id="min-difficulty-select" id="min-difficulty-select"
value={minDifficulty} value={minDifficulty}
@ -110,6 +112,7 @@ const StartTestForm = () => {
<FormControl sx={{ minWidth: "200px" }} variant="filled"> <FormControl sx={{ minWidth: "200px" }} variant="filled">
<InputLabel id="max-difficulty-select-label">Max Difficulty</InputLabel> <InputLabel id="max-difficulty-select-label">Max Difficulty</InputLabel>
<Select <Select
sx={{ maxWidth: "270px" }}
labelId="max-difficulty-select-label" labelId="max-difficulty-select-label"
id="max-difficulty-select" id="max-difficulty-select"
value={maxDifficulty} value={maxDifficulty}

View File

@ -2,13 +2,19 @@ import { Box, Button, Chip, Typography } from "@mui/material";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { formatDate } from "../../utils/functions"; import { formatDate } from "../../utils/functions";
import type { UserTestType } from "../shared/types/TestTypes"; import type { UserTestType } from "../shared/types/TestTypes";
import { useGetTestById } from "../../hooks/Tests/useGetTestById";
interface TestCardProps { interface TestCardProps {
test: UserTestType; test: UserTestType;
} }
const TestCard = ({ test }: TestCardProps) => { const TestCard = ({ test }: TestCardProps) => {
const title = test.test?.title || "User Test"; let title: string | undefined = "User Test";
if (test.test_id) {
const { data } = useGetTestById(test.test_id);
title = data?.title;
}
let statusLabel = ""; let statusLabel = "";
let statusColor: "primary" | "success" | "error" = "primary"; let statusColor: "primary" | "success" | "error" = "primary";

View File

@ -0,0 +1,88 @@
import { Box, Button, Chip, Typography } from "@mui/material";
import CategoryIcon from "@mui/icons-material/Category";
import type { TestType } from "../shared/types/TestTypes";
import { useStartTestById } from "../../hooks/Tests/useStartTestById";
interface TestCardProps {
test: TestType;
}
const TestListCard = ({ test }: TestCardProps) => {
const { mutate: startTest } = useStartTestById();
const title = test.title;
let statusLabel = "";
let statusColor: "primary" | "success" | "error" = "primary";
if (!test.is_available) {
statusLabel = "Expired";
statusColor = "error";
} else {
statusLabel = "Active";
statusColor = "primary";
}
return (
<Box
sx={{
border: "1px solid #ccc",
borderRadius: 2,
p: 2,
mb: 2,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexDirection: {
xs: "column",
sm: "column",
md: "row"
},
}}
>
<Box>
<Box sx={{display: "flex", gap: 1, flexDirection: {xs: "column", sm: "row"}}}>
<Typography variant="h6">{title}</Typography>
<Chip
icon={<CategoryIcon />}
label={test.category.name}
color="primary"
variant="outlined"
/>
</Box>
{test.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{test.description}
</Typography>
)}
<Typography
variant="caption"
color="text.secondary"
display="block"
mt={1}
>
Closes: {new Date(test.closed_at).toLocaleDateString()}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 1 }}>
<Chip label={statusLabel} color={statusColor} />
{test.is_available && (
<Button
variant="contained"
color="primary"
onClick={() => startTest(test.id)}
>
Start Test
</Button>
)}
</Box>
</Box>
);
};
export default TestListCard;

View File

@ -33,7 +33,7 @@ export type TestType = {
description: string | null; description: string | null;
category_id: number; category_id: number;
category: CategoryType; category: CategoryType;
questions: QuestionType[]; questions?: QuestionType[];
is_available: boolean; is_available: boolean;
author_id: number; author_id: number;
author: User; author: User;
@ -61,3 +61,28 @@ export type SubmitAnswerPayload = {
answerId: number; answerId: number;
answer: (string | number)[]; answer: (string | number)[];
}; };
export type PaginatedTests = {
data: TestType[];
links: {
first: string | null;
last: string | null;
prev: string | null;
next: string | null;
};
meta: {
current_page: number;
from: number | null;
last_page: number;
links: {
url: string | null;
label: string;
page: number | null;
active: boolean;
}[];
path: string;
per_page: number;
to: number | null;
total: number;
};
};

View File

@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getTestById } from "../../api/testApi";
import type { TestType } from "../../components/shared/types/TestTypes";
export const useGetTestById = (id: number) => {
return useQuery<TestType>({
queryKey: ["tests", id],
queryFn: () => getTestById(id),
});
};

View File

@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { getTests } from "../../api/testApi";
type UseTestsParams = {
page?: number;
categoryId?: number;
};
export const useGetTests = ({ page = 1, categoryId }: UseTestsParams) => {
return useQuery({
queryKey: ["tests", { page, categoryId }],
queryFn: () => getTests(categoryId, page),
staleTime: 1000 * 60,
});
};

View File

@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { startTestById } from "../../api/testApi";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
export const useStartTestById = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: (id: number) => startTestById(id),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
toast.success("Test Started");
navigate(`/tests/${data.id}`);
},
onError: (error: any) => {
toast.error(error.response?.data?.message);
},
});
};

View File

@ -1,40 +1,89 @@
import { Typography, Pagination } from "@mui/material"; import {
Typography,
Pagination,
type SelectChangeEvent,
FormControl,
InputLabel,
Select,
MenuItem,
} from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import Container from "../../components/shared/Container"; import Container from "../../components/shared/Container";
import { WelcomeContainer } from "./IndexPage.styles"; import { WelcomeContainer } from "./IndexPage.styles";
import StartTestForm from "../../components/StartTestForm/StartTestForm"; import StartTestForm from "../../components/StartTestForm/StartTestForm";
import Question from "../../components/Question/Question"; import Question from "../../components/Question/Question";
import { useQuestions } from "../../hooks/Question/useQuestions"; import { useQuestions } from "../../hooks/Question/useQuestions";
import type { QuestionType } from "../../components/shared/types/QuestionTypes"; import type { QuestionType } from "../../components/shared/types/QuestionTypes";
import { useAuth } from "../../context/AuthContext";
import { useCategories } from "../../hooks/Question/useCategories";
const IndexPage = () => { const IndexPage = () => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const questionsQuery = useQuestions({ page });
const { data: categories } = useCategories();
const { user } = useAuth();
const [selectedCategory, setSelectedCategory] = useState<number | "all">(
"all"
);
const questionsQuery = useQuestions({
page,
id: selectedCategory === "all" ? undefined : selectedCategory,
});
const questions = questionsQuery.data?.data ?? []; const questions = questionsQuery.data?.data ?? [];
const loading = questionsQuery.isLoading; const loading = questionsQuery.isLoading;
const error = questionsQuery.error?.message ?? null; const error = questionsQuery.error?.message ?? null;
const meta = questionsQuery.data?.meta ?? null; const meta = questionsQuery.data?.meta ?? null;
const handleCategoryChange = (event: SelectChangeEvent<number | "all">) => {
const value = event.target.value;
setSelectedCategory(value === "all" ? "all" : Number(value));
setPage(1);
};
return ( return (
<Container> <Container>
<WelcomeContainer> <WelcomeContainer>
<Typography variant="h2">Welcome To HoshiAI!</Typography> <Typography align="center" variant="h2">Welcome To HoshiAI!</Typography>
<Typography variant="subtitle1">The best place to learn!</Typography> <Typography variant="subtitle1">The best place to learn!</Typography>
</WelcomeContainer> </WelcomeContainer>
<StartTestForm /> {user && (
<>
<Typography align="center" sx={{ mb: "36px" }} variant="h3">
Start a Test
</Typography>
<Typography sx={{ mb: "36px" }} variant="h3"> <StartTestForm />
</>
)}
<Typography align="center" sx={{ mb: "36px" }} variant="h3">
Browse Questions Browse Questions
</Typography> </Typography>
<FormControl sx={{ mb: 2, minWidth: 200 }}>
<InputLabel id="category-select-label">Category</InputLabel>
<Select
labelId="category-select-label"
value={selectedCategory}
onChange={handleCategoryChange}
label="Category"
>
<MenuItem value="all">All</MenuItem>
{categories?.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
{loading && <Typography>Loading questions...</Typography>} {loading && <Typography>Loading questions...</Typography>}
{error && <Typography color="error">{error}</Typography>} {error && <Typography color="error">{error}</Typography>}
{questions.map((q: QuestionType) => ( {questions.map((q: QuestionType) => (
<Question key={q.id} question={q}/> <Question key={q.id} question={q} />
))} ))}
{meta && meta.last_page > 1 && ( {meta && meta.last_page > 1 && (

View File

@ -1,10 +0,0 @@
import StartTestForm from "../../components/StartTestForm/StartTestForm";
const QuestionsPage = () => {
return(
<StartTestForm/>
)
}
export default QuestionsPage;

View File

@ -0,0 +1,83 @@
import { useState } from "react";
import Pagination from "@mui/material/Pagination";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import { useGetTests } from "../../hooks/Tests/useGetTests";
import Container from "../../components/shared/Container";
import TestListCard from "../../components/TestListCard/TestListCard";
import {
FormControl,
InputLabel,
MenuItem,
Select,
Typography,
type SelectChangeEvent,
} from "@mui/material";
import { useCategories } from "../../hooks/Question/useCategories";
const TestsPage = () => {
const [currentPage, setCurrentPage] = useState(1);
const [selectedCategory, setSelectedCategory] = useState<number | "all">(
"all"
);
const { data: categories } = useCategories();
const { data, isLoading, isError } = useGetTests({
categoryId: selectedCategory === "all" ? undefined : selectedCategory,
page: currentPage,
});
const handleCategoryChange = (event: SelectChangeEvent<number | "all">) => {
const value = event.target.value;
setSelectedCategory(value === "all" ? "all" : Number(value));
setCurrentPage(1);
};
if (isLoading) return <CircularProgress />;
if (isError) return <Alert severity="error">Failed to load tests</Alert>;
return (
<Container>
<Typography variant="h2">Curated tests</Typography>
<Box p={2}>
<FormControl sx={{ mb: 2, minWidth: 200 }}>
<InputLabel id="category-select-label">Category</InputLabel>
<Select
labelId="category-select-label"
value={selectedCategory}
onChange={handleCategoryChange}
label="Category"
>
<MenuItem value="all">All</MenuItem>
{categories?.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
{isLoading ? (
<div>Loading tests...</div>
) : isError ? (
<div>Failed to load tests</div>
) : (
data?.data.map((test) => <TestListCard key={test.id} test={test} />)
)}
{data?.meta?.last_page && data.meta.last_page > 1 && (
<Pagination
color="primary"
shape="rounded"
count={data.meta.last_page}
page={currentPage}
onChange={(_, value) => setCurrentPage(value)}
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }}
/>
)}
</Box>
</Container>
);
};
export default TestsPage;

View File

@ -11,6 +11,7 @@ import NotFoundPage from "../pages/NotFoundPage/NotFoundPage";
import ResetPasswordPage from "../pages/ResetPasswordPage/ResetPasswordPage"; import ResetPasswordPage from "../pages/ResetPasswordPage/ResetPasswordPage";
import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage"; import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage";
import { TestPage } from "../pages/TestPage/TestPage"; import { TestPage } from "../pages/TestPage/TestPage";
import TestsPage from "../pages/TestsPage/TestsPage";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -57,6 +58,10 @@ const router = createBrowserRouter([
path: "/tests/:id", path: "/tests/:id",
element: <TestPage/> element: <TestPage/>
}, },
{
path: "/tests",
element: <TestsPage/>
},
{ {
path: "*", path: "*",
element: <NotFoundPage />, element: <NotFoundPage />,