Compare commits

..

10 Commits

98 changed files with 4102 additions and 99 deletions

View File

View File

@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>test-ai</title> <title>HoshiAI</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

20
package-lock.json generated
View File

@ -16,6 +16,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-qr-code": "^2.0.18",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"react-toastify": "^11.0.5" "react-toastify": "^11.0.5"
}, },
@ -3797,6 +3798,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qr.js": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -3845,6 +3852,19 @@
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-qr-code": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
"integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1",
"qr.js": "0.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@ -18,6 +18,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-qr-code": "^2.0.18",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"react-toastify": "^11.0.5" "react-toastify": "^11.0.5"
}, },

View File

@ -6,6 +6,7 @@ import { ToastContainer } from "react-toastify";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
function App() { function App() {

View File

@ -1,7 +1,9 @@
import axios from "axios"; import axios from "axios";
const baseURL = import.meta.env.VITE_API_URL;
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: "http://127.0.0.1:8000", baseURL,
timeout: 30 * 1000, timeout: 30 * 1000,
}); });

32
src/api/categoryApi.ts Normal file
View File

@ -0,0 +1,32 @@
import type { CategoryType } from "../components/shared/types/TestTypes";
import axiosInstance from "./axiosInstance";
interface CategoriesResponse {
data: CategoryType[];
}
export const getCategories = async () => {
const res = await axiosInstance.get<CategoriesResponse>("/api/categories");
return res.data.data;
};
export const getCategoryById = async (id: number) => {
const res = await axiosInstance.get<{data:CategoryType}>(`/api/categories/${id}`);
return res.data.data;
};
export const createCategory = async (name: string) => {
const res = await axiosInstance.post(`/api/categories`, { name });
return res.data;
};
export const updateCategory = async (id: number, name: string) => {
const res = await axiosInstance.put(`/api/categories/${id}`, { name });
return res.data;
};
export const deleteCategory = async (id: number) => {
const res = await axiosInstance.delete(`/api/categories/${id}`);
return res.data;
};

24
src/api/logsApi.ts Normal file
View File

@ -0,0 +1,24 @@
import axiosInstance from "./axiosInstance";
export type LogType = {
id: number;
description: string;
created_at: string;
};
interface LogsResponse {
data: LogType[];
meta: {
current_page: number;
last_page: number;
per_page: number;
total: number;
};
}
export const getLogs = async (page = 1) => {
const res = await axiosInstance.get<LogsResponse>("/api/logs", {
params: { page },
});
return res.data;
};

39
src/api/metricsService.ts Normal file
View File

@ -0,0 +1,39 @@
import axiosInstance from "./axiosInstance";
export const hitApi = async (url: string): Promise<void> => {
await axiosInstance.post("/api/hit", { url });
};
export interface HitCountType {
id: number;
ip: string;
device_type: string;
user_agent: string;
country: string | null;
url: string;
created_at?: string;
}
export interface HitCountsResponse {
data: HitCountType[];
links: {
first: string;
last: string;
prev: string | null;
next: string | null;
};
meta: {
current_page: number;
from: number;
last_page: number;
per_page: number;
total: number;
};
}
export const getHitCounts = async (page = 1) => {
const res = await axiosInstance.get<HitCountsResponse>("/api/hitcounts", {
params: { page },
});
return res.data;
};

69
src/api/questionsApi.ts Normal file
View File

@ -0,0 +1,69 @@
import type { Variant } from "../components/shared/types/QuestionTypes";
import axiosInstance from "./axiosInstance";
type GetQuestionsParams = {
page?: number;
id?: number;
};
export type QuestionPayload = {
title: string;
description?: string;
type: "single" | "multiple" | "text";
difficulty: number;
variants: Variant[];
correct_answers: number[] | string[];
category_id: number;
};
export type OpenaiPayload = {
type: "single" | "multiple" | "text";
category_id: number;
language: string;
difficulty: number;
promt: string;
};
type OpenaiGenerateResponse = {
question: QuestionPayload;
};
export const getQuestions = async ({ page = 1, id }: GetQuestionsParams) => {
const params: Record<string, any> = { page };
if (id) params.category_id = id;
const { data } = await axiosInstance.get("/api/questions", { params });
return data;
};
export const getQuestionById = async (id: number) => {
const response = await axiosInstance.get(`/api/questions/${id}`);
return response.data.data;
};
export const createQuestion = async (payload: QuestionPayload) => {
const res = await axiosInstance.post("/api/questions", payload);
return res.data;
};
export const updateQuestion = async (id: number, payload: QuestionPayload) => {
const res = await axiosInstance.put(`/api/questions/${id}`, payload);
return res.data;
};
export const deleteQuestion = async (id: number) => {
const res = await axiosInstance.delete(`/api/questions/${id}`);
return res.data;
};
export const openaiGenerateQuestion = async (
payload: OpenaiPayload
): Promise<QuestionPayload> => {
const res = await axiosInstance.post<OpenaiGenerateResponse>(
'/api/questions/openai-generate',
payload
);
return res.data.question;
};

129
src/api/testApi.ts Normal file
View File

@ -0,0 +1,129 @@
import {
type TestType,
type PaginatedTests,
type StartTestPayload,
type SubmitAnswerPayload,
type UserTestType,
} from "../components/shared/types/TestTypes";
import axiosInstance from "./axiosInstance";
export type UserTestsResponse = {
data: UserTestType[];
links: {
first: string;
last: string;
prev: string | null;
next: string | null;
};
meta: {
current_page: number;
from: number | null;
last_page: number;
path: string;
per_page: number;
to: number | null;
total: number;
links: {
url: string | null;
label: string;
active: boolean;
}[];
};
};
export type TestPayload = {
title: string;
description: string;
closed_at: string | null;
category_id: number;
questions: number[];
};
export const startTest = async (data: StartTestPayload) => {
const res = await axiosInstance.post<{ data: UserTestType }>(
"/api/user-tests",
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) => {
const res = await axiosInstance.post<{ message: string }>(
`/api/user-tests/${userTestId}/complete`
);
return res.data;
};
export const getUserTestById = async (userTestId: number) => {
const res = await axiosInstance.get<{ data: UserTestType }>(
`/api/user-tests/${userTestId}`
);
return res.data.data;
};
export const getUserTests = async () => {
const res = await axiosInstance.get<{ data: UserTestType[] }>(
"/api/user-tests/me"
);
return res.data.data;
};
export const submitAnswer = async (data: SubmitAnswerPayload) => {
const res = await axiosInstance.post<{ message: string }>(
`/api/user-test-answers/${data.answerId}/submit`,
{ answer: data.answer }
);
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;
};
export const getAllUserTests = async (
page = 1,
test_id?: number,
question_id?: number
) => {
const res = await axiosInstance.get<UserTestsResponse>("/api/user-tests", {
params: { page, test_id, question_id },
});
return res.data;
};
export const deleteUserTest = async (id: number) => {
const res = await axiosInstance.delete(`/api/user-tests/${id}`);
return res.data;
};
export const createTest = async (payload: TestPayload) => {
const res = await axiosInstance.post("/api/tests", payload);
return res.data;
};
export const updateTest = async (id: number, payload: TestPayload) => {
const res = await axiosInstance.put(`/api/tests/${id}`, payload);
return res.data;
};
export const deleteTest = async (id: number) => {
const res = await axiosInstance.delete(`/api/tests/${id}`);
return res.data;
};

59
src/api/usersApi.ts Normal file
View File

@ -0,0 +1,59 @@
import type { User } from "../components/shared/types/AuthTypes";
import axiosInstance from "./axiosInstance";
export type UsersResponse = {
data: User[];
meta: {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
};
};
export interface UserPayload {
username: string;
email: string;
password?: string;
type: string;
}
export const getUsers = async (page = 1) => {
const res = await axiosInstance.get<UsersResponse>("/api/users", {
params: { page },
});
return res.data;
};
export const getUserById = async (id: number) => {
const res = await axiosInstance.get(`/api/users/${id}`);
return res.data.data;
};
export const deleteUser = async (id: number) => {
const res = await axiosInstance.delete(`/api/users/${id}`);
return res.data;
};
export const createUser = async (payload: UserPayload) => {
const res = await axiosInstance.post<User>("/api/users",{
username: payload.username,
email: payload.email,
email_verified_at: "2025-11-12T10:13:48.000000Z",
password: payload.password,
type: payload.type,
});
return res.data;
};
export const updateUser = async (id: number, payload: UserPayload) => {
const res = await axiosInstance.put<User>(`/api/users/${id}`, {
username: payload.username,
email: payload.email,
password: payload.password,
type: payload.type,
});
return res.data;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@ -0,0 +1,89 @@
import {
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import { NavLink } from "react-router-dom";
import PeopleIcon from "@mui/icons-material/People";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import QuizIcon from "@mui/icons-material/Quiz";
import CategoryIcon from "@mui/icons-material/Category";
import ArticleIcon from "@mui/icons-material/Article";
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import FmdBadIcon from "@mui/icons-material/FmdBad";
import { useAuth } from "../../context/AuthContext";
type MenuItem = {
label: string;
icon: React.ReactNode;
to: string;
};
const drawerWidth = 240;
const adminMenu: MenuItem[] = [
{ label: "Users", icon: <PeopleIcon />, to: "/dashboard/users" },
{ label: "Questions", icon: <HelpOutlineIcon />, to: "/dashboard/questions" },
{ label: "Tests", icon: <QuizIcon />, to: "/dashboard/tests" },
{
label: "User Tests",
icon: <AssignmentIndIcon />,
to: "/dashboard/user-tests",
},
{ label: "Categories", icon: <CategoryIcon />, to: "/dashboard/categories" },
{ label: "Hitcounts", icon: <FmdBadIcon />, to: "/dashboard/hitcounts" },
{ label: "Logs", icon: <ArticleIcon />, to: "/dashboard/logs" },
];
const creatorMenu: MenuItem[] = [
{ label: "Questions", icon: <HelpOutlineIcon />, to: "/dashboard/questions" },
{ label: "Tests", icon: <QuizIcon />, to: "/dashboard/tests" },
];
export const AdminSidebar = () => {
const { user } = useAuth();
const menuItems =
user?.type === "admin"
? adminMenu
: user?.type === "creator"
? creatorMenu
: [];
return (
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
top: 70,
},
}}
>
<List>
{menuItems.map((item) => (
<ListItemButton
key={item.label}
component={NavLink}
to={item.to}
sx={{
"&.active": {
bgcolor: "action.selected",
"& .MuiListItemIcon-root": {
color: "primary.main",
},
},
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
))}
</List>
</Drawer>
);
};

View File

@ -0,0 +1,142 @@
import { useState } from "react";
import { Box, Button, Typography } from "@mui/material";
import { arraysEqual } from "../../utils/functions";
interface Variant {
id: number;
text: string;
}
interface LearningAnswersProps {
variants?: Variant[];
correctAnswers: number[] | string[];
type: "single" | "multiple" | "text";
userAnswers?: number[];
}
const LearningAnswers = ({
variants = [],
correctAnswers = [],
type,
userAnswers,
}: LearningAnswersProps) => {
const [showAnswer, setShowAnswer] = useState(false);
const hasAnswered = userAnswers !== null && userAnswers !== undefined;
const isEmptyAnswer = hasAnswered && userAnswers.length === 0;
const isCorrect = hasAnswered
? arraysEqual(correctAnswers as number[], userAnswers as number[])
: false;
const normalizedUserAnswers = userAnswers ?? [];
const getUserAnswerText = () => {
if (!normalizedUserAnswers.length) return "";
const selectedVariants = variants.filter((v) =>
normalizedUserAnswers.includes(v.id)
);
return selectedVariants.map((v) => v.text).join(", ");
};
if (type === "text") {
return (
<Box sx={{ mt: 2 }}>
{hasAnswered ? (
isEmptyAnswer ? (
<Typography
variant="body1"
color="text.secondary"
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
>
You didnt answer
</Typography>
) : (
<Typography
variant="body1"
color="primary"
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
>
Your answer: {userAnswers.join(", ")}
</Typography>
)
) : null}
{(hasAnswered || showAnswer) && (
<Typography
variant="body1"
color="success.main"
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
>
Correct answer: {correctAnswers.join(", ")}
</Typography>
)}
{!hasAnswered && (
<Button
variant="contained"
color="primary"
sx={{ mt: 2 }}
onClick={() => setShowAnswer(!showAnswer)}
>
{showAnswer ? "Hide Answer" : "Reveal Answer"}
</Button>
)}
</Box>
);
}
return (
<Box sx={{ mt: 2 }}>
{hasAnswered ? (
isEmptyAnswer ? (
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
You didnt answer
</Typography>
) : (
<Typography
variant="body1"
color={isCorrect ? "success.main" : "error.main"}
sx={{ mb: 1 }}
>
Your answer: {getUserAnswerText()} {" "}
{isCorrect ? "Correct" : "Incorrect"}
</Typography>
)
) : null}
{variants.map((variant) => (
<Box
key={variant.id}
sx={{
p: 2,
my: 1,
borderRadius: 2,
border: "1px solid #ccc",
backgroundColor:
showAnswer || hasAnswered
? (correctAnswers as number[]).includes(variant.id)
? "success.light"
: normalizedUserAnswers.includes(variant.id)
? "error.light"
: "background.paper"
: "background.paper",
}}
>
<Typography>{variant.text}</Typography>
</Box>
))}
{!hasAnswered && (
<Button
variant="contained"
color="primary"
sx={{ mt: 2 }}
onClick={() => setShowAnswer(!showAnswer)}
>
{showAnswer ? "Hide Answer" : "Reveal Answer"}
</Button>
)}
</Box>
);
};
export default LearningAnswers;

View File

@ -0,0 +1,15 @@
import { useEffect } from "react";
import { hitApi } from "../../api/metricsService";
import { useLocation } from "react-router-dom";
const Cholecounter = () => {
const location = useLocation();
useEffect(() => {
hitApi(window.location.href);
}, [location.pathname]);
return null;
};
export default Cholecounter;

View File

@ -0,0 +1,150 @@
import { useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
MenuItem,
CircularProgress,
} from "@mui/material";
import { useOpenaiGenerateQuestion } from "../../hooks/questions/useOpenaiGenerateQuestion";
import type { QuestionPayload } from "../../api/questionsApi";
import { useCategories } from "../../hooks/categories/useCategories";
type Props = {
open: boolean;
onClose: () => void;
onGenerated: (question: QuestionPayload) => void;
};
const GenerateQuestionModal = ({ open, onClose, onGenerated }: Props) => {
const generateMutation = useOpenaiGenerateQuestion();
const [type, setType] = useState<"single" | "multiple" | "text">("single");
const [categoryId, setCategoryId] = useState<number | "">("");
const [language, setLanguage] = useState<"en" | "sr" | "hu" | "ru">("en");
const [difficulty, setDifficulty] = useState(5);
const [prompt, setPrompt] = useState("");
const { data: categories } = useCategories();
const handleGenerate = () => {
if (!categoryId || !prompt.trim()) return;
generateMutation.mutate(
{
type,
category_id: Number(categoryId),
language,
difficulty,
promt: prompt,
},
{
onSuccess: (data) => {
onGenerated(data);
onClose();
},
}
);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Generate Question (AI)</DialogTitle>
<DialogContent dividers>
<TextField
select
label="Type"
value={type}
onChange={(e) =>
setType(e.target.value as "single" | "multiple" | "text")
}
fullWidth
sx={{ mb: 2 }}
>
<MenuItem value="single">Single</MenuItem>
<MenuItem value="multiple">Multiple</MenuItem>
<MenuItem value="text">Text</MenuItem>
</TextField>
{/* <TextField
label="Category ID"
type="number"
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
fullWidth
sx={{ mb: 2 }}
/> */}
<TextField
select
label="Category"
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
fullWidth
required
sx={{ mb: 3 }}
>
{categories?.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Language"
value={language}
onChange={(e) =>
setLanguage(e.target.value as "en" | "sr" | "hu" | "ru")
}
fullWidth
sx={{ mb: 2 }}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="sr">Serbian</MenuItem>
<MenuItem value="hu">Hungarian</MenuItem>
<MenuItem value="ru">Russian</MenuItem>
</TextField>
<TextField
label="Difficulty (110)"
type="number"
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
fullWidth
sx={{ mb: 2 }}
/>
<TextField
label="Prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
fullWidth
multiline
rows={3}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleGenerate}
disabled={generateMutation.isPending}
>
{generateMutation.isPending ? (
<CircularProgress size={20} />
) : (
"Generate"
)}
</Button>
</DialogActions>
</Dialog>
);
};
export default GenerateQuestionModal;

View File

@ -29,7 +29,7 @@ function Header() {
setAnchorElNav(null); setAnchorElNav(null);
}; };
const goToAdmin = () => navigate("/admin"); const goToDashboard = () => navigate("/dashboard");
return ( return (
<AppBar sx={{ backgroundColor: "#4b2981" }} position="static"> <AppBar sx={{ backgroundColor: "#4b2981" }} position="static">
@ -95,20 +95,27 @@ function Header() {
> >
<Typography textAlign="center">Profile</Typography> <Typography textAlign="center">Profile</Typography>
</MenuItem> </MenuItem>
{user.type === "admin" && ( {(user.type === "admin" || user.type === "creator") && (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
handleCloseNavMenu(); handleCloseNavMenu();
goToAdmin(); goToDashboard();
}} }}
> >
<Typography textAlign="center"> <Typography textAlign="center">Dashboard</Typography>
Admin Dashboard
</Typography>
</MenuItem> </MenuItem>
)} )}
</span> </span>
)} )}
<MenuItem
onClick={() => {
handleCloseNavMenu();
navigate("/tests");
}}
>
<Typography textAlign="center">Tests</Typography>
</MenuItem>
</Menu> </Menu>
</Box> </Box>
@ -150,16 +157,25 @@ function Header() {
> >
Profile Profile
</Button> </Button>
{user.type === "admin" && ( {(user.type === "admin" || user.type === "creator") && (
<Button <Button
onClick={goToAdmin} onClick={goToDashboard}
sx={{ my: 2, color: "white", display: "block" }} sx={{ my: 2, color: "white", display: "block" }}
> >
Admin Dashboard Dashboard
</Button> </Button>
)} )}
</> </>
)} )}
<Button
sx={{ my: 2, color: "white", display: "block" }}
onClick={() => {
handleCloseNavMenu();
navigate("/tests");
}}
>
Tests
</Button>
</Box> </Box>
</Toolbar> </Toolbar>
</Container> </Container>

View File

@ -0,0 +1,18 @@
import type { ReactNode } from "react";
import { useAuth } from "../../context/AuthContext";
import Container from "../shared/Container";
import Forbidden from "../../pages/Unauthorized/Forbidden";
interface ProtectedRouteProps {
children: ReactNode;
}
export const AdminProtectedRoute = ({ children }: ProtectedRouteProps) => {
const { user, isLoading } = useAuth();
if (isLoading) return <Container>Loading...</Container>;
if (user?.type === "user" || user?.type === "banned") return <Forbidden />;
return <>{children}</>;
};

View File

@ -1,25 +1,33 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Box } from "@mui/material";
import { Link } from "react-router-dom";
export const QuestionWrapper = styled("div")({ export const QuestionWrapper = styled(Link)({
display: "flex", display: "flex",
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",
textDecoration: "none",
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

@ -7,22 +7,15 @@ import {
} from "./Question.styles"; } from "./Question.styles";
import CategoryIcon from "@mui/icons-material/Category"; import CategoryIcon from "@mui/icons-material/Category";
import PersonIcon from "@mui/icons-material/Person"; import PersonIcon from "@mui/icons-material/Person";
import type { QuestionType } from "../shared/types/QuestionTypes";
type Question = { interface QuestionProps {
title: string; question: QuestionType;
category: string; }
description: string;
difficulty: number;
author: string;
};
type QuestionProps = {
question: Question;
};
const Question = ({ question }: QuestionProps) => { const Question = ({ question }: QuestionProps) => {
return ( return (
<QuestionWrapper> <QuestionWrapper to={`/questions/${question.id}`}>
<QuestionTitle> <QuestionTitle>
<Typography sx={{ mr: "10px" }} variant="h5"> <Typography sx={{ mr: "10px" }} variant="h5">
{question.title} {question.title}
@ -31,17 +24,20 @@ const Question = ({ question }: QuestionProps) => {
variant="outlined" variant="outlined"
icon={<CategoryIcon />} icon={<CategoryIcon />}
color="primary" color="primary"
label={`${question.category}`} label={question.category.name}
/> />
</QuestionTitle> </QuestionTitle>
<Typography variant="body1">{question.description}</Typography> <Typography variant="body1">{question.description}</Typography>
<QuestionMetadata> <QuestionMetadata>
<Typography variant="body1"> <Typography variant="body1">
Difficulty: {question.difficulty} Difficulty: {question.difficulty}
</Typography> </Typography>
<AuthorMeta> <AuthorMeta>
<PersonIcon /> <PersonIcon />
<Typography variant="body1">{question.author}</Typography> <Typography variant="body1">{question.author.username}</Typography>
</AuthorMeta> </AuthorMeta>
</QuestionMetadata> </QuestionMetadata>
</QuestionWrapper> </QuestionWrapper>

View File

@ -9,49 +9,92 @@ import {
} from "@mui/material"; } from "@mui/material";
import { StartTestWrapper } from "./StartTestForm.styles"; import { StartTestWrapper } from "./StartTestForm.styles";
import { useState } from "react"; import { useState } from "react";
import { notifyError, notifySuccess } from "../shared/toastify"; import { useCategories } from "../../hooks/categories/useCategories";
import type { Category } from "../shared/types/QuestionTypes";
import { useStartTest } from "../../hooks/tests/useStartTest";
import { toast } from "react-toastify";
const StartTestForm = () => { const StartTestForm = () => {
const [category, setCategory] = useState(""); const [category, setCategory] = useState("");
const [minDifficulty, setMinDifficulty] = useState(1); const [minDifficulty, setMinDifficulty] = useState(1);
const [maxDifficulty, setMaxDifficulty] = useState(10); const [maxDifficulty, setMaxDifficulty] = useState(10);
const { data: categories, isLoading, isError } = useCategories();
const startTestMutation = useStartTest();
const handleStartTest = () => {
if (!category) {
toast.error("Please select a category!");
return;
}
startTestMutation.mutate({
category_id: Number(category),
min_difficulty: minDifficulty,
max_difficulty: maxDifficulty,
});
};
const handleFeelingLucky = () => {
if (!categories || categories.length === 0) {
toast.error("No categories available");
return;
}
const randomCategory =
categories[Math.floor(Math.random() * categories.length)];
const minDifficulty = 1;
const maxDifficulty = 10;
startTestMutation.mutate({
category_id: randomCategory.id,
min_difficulty: minDifficulty,
max_difficulty: maxDifficulty,
});
};
const handleChangeMinDifficulty = (e: SelectChangeEvent<number>) => { const handleChangeMinDifficulty = (e: SelectChangeEvent<number>) => {
const value = Number(e.target.value); const value = Number(e.target.value);
if(value <= maxDifficulty){ if (value <= maxDifficulty) {
setMinDifficulty(value); setMinDifficulty(value);
} else{ } else {
notifyError("Min difficulty should be smaller than max difficulty!") toast.error("Min difficulty should be smaller than max difficulty!");
} }
} };
const handleChangeMaxDifficulty = (e: SelectChangeEvent<number>) => { const handleChangeMaxDifficulty = (e: SelectChangeEvent<number>) => {
const value = Number(e.target.value); const value = Number(e.target.value);
if(value >= minDifficulty){ if (value >= minDifficulty) {
setMaxDifficulty(value); setMaxDifficulty(value);
} else{ } else {
notifyError("Max difficulty should be bigger than min difficulty!") toast.error("Max difficulty should be bigger than min difficulty!");
} }
} };
return ( return (
<StartTestWrapper> <StartTestWrapper>
<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}
label="Category" label="Category"
onChange={(e) => setCategory(e.target.value as string)} onChange={(e) => setCategory(e.target.value)}
> >
<MenuItem value={10}>Physics</MenuItem> {isLoading && <MenuItem disabled>Loading...</MenuItem>}
<MenuItem value={20}>Microcontrollers</MenuItem> {isError && <MenuItem disabled>Error loading categories</MenuItem>}
<MenuItem value={30}>English</MenuItem> {categories?.map((cat: Category) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
<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}
@ -69,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}
@ -83,10 +127,16 @@ const StartTestForm = () => {
</Select> </Select>
</FormControl> </FormControl>
<Tooltip title="Start a test based on selected parameters."> <Tooltip title="Start a test based on selected parameters.">
<Button onClick={() => notifySuccess("Started")} variant="contained">Start Test</Button> <Button onClick={handleStartTest} variant="contained">
Start Test
</Button>
</Tooltip> </Tooltip>
<Tooltip title="Random category and difficulty test."> <Tooltip title="Random category and difficulty test.">
<Button sx={{ backgroundColor: "#6610F2" }} variant="contained"> <Button
onClick={handleFeelingLucky}
sx={{ backgroundColor: "#6610F2" }}
variant="contained"
>
Im Feeling Lucky Im Feeling Lucky
</Button> </Button>
</Tooltip> </Tooltip>

View File

@ -0,0 +1,87 @@
import { Box, Button, Chip, Typography } from "@mui/material";
import { Link } from "react-router-dom";
import { formatDate } from "../../utils/functions";
import type { UserTestType } from "../shared/types/TestTypes";
import { useGetTestById } from "../../hooks/tests/useGetTestById";
interface TestCardProps {
test: UserTestType;
}
const TestCard = ({ test }: TestCardProps) => {
let title: string | undefined = "User Test";
if (test.test_id) {
const { data } = useGetTestById(test.test_id);
title = data?.title;
}
let statusLabel = "";
let statusColor: "primary" | "success" | "error" = "primary";
if (test.is_completed) {
statusLabel = "Completed";
statusColor = "success";
} else 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",
}}
>
<Box>
<Typography variant="h6">{title}</Typography>
{test.closed_at && (
<Typography variant="body2" color="textSecondary">
Expires at: {formatDate(test.closed_at)}
</Typography>
)}
{test.is_completed && test.score !== undefined && (
<Typography variant="body2" color="textSecondary">
Score: {test.score}%
</Typography>
)}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Chip label={statusLabel} color={statusColor} />
{test.is_available && !test.is_completed && (
<Button
component={Link}
to={`/tests/${test.id}`}
variant="contained"
color="primary"
>
Continue Test
</Button>
)}
{test.is_completed && (
<Button
component={Link}
to={`/tests/${test.id}`}
variant="contained"
color="success"
>
View Results
</Button>
)}
</Box>
</Box>
);
};
export default TestCard;

View File

@ -0,0 +1,103 @@
import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
import CategoryIcon from "@mui/icons-material/Category";
import type { TestType } from "../shared/types/TestTypes";
import { useStartTestById } from "../../hooks/tests/useStartTestById";
import { useNavigate } from "react-router-dom";
interface TestCardProps {
test: TestType;
}
const TestListCard = ({ test }: TestCardProps) => {
const { mutate: startTest } = useStartTestById();
const navigate = useNavigate();
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" },
}}
>
<Tooltip title="View test details" placement="top" arrow>
<Typography
variant="h6"
color="primary"
sx={{ cursor: "pointer", color: "#000" }}
onClick={() => navigate(`/tests/view/${test.id}`)}
>
{title}
</Typography>
</Tooltip>
<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

@ -0,0 +1,41 @@
import QRCode from "react-qr-code";
import { Modal, Box, Button } from "@mui/material";
import type { TestType } from "../shared/types/TestTypes";
interface QrProps {
open: boolean,
onClose: () => void,
test: TestType
}
const TestQrModal = ({ open, onClose, test }: QrProps) => {
return (
<Modal open={open} onClose={onClose}>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "white",
p: 3,
borderRadius: 2,
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<QRCode value={String(test.id)} size={200} />
<Button onClick={onClose} sx={{ mt: 2 }}>
Close
</Button>
</Box>
</Modal>
);
};
export default TestQrModal;

View File

@ -0,0 +1,98 @@
import { useState, useEffect } from "react";
import {
Typography,
Box,
Checkbox,
FormControlLabel,
Button,
TextField,
} from "@mui/material";
import type { UserTestAnswer } from "../shared/types/TestTypes";
import { useQueryClient } from "@tanstack/react-query";
interface TestQuestionProps {
qa: UserTestAnswer;
onSubmit: (answer: (string | number)[]) => void;
}
const TestQuestion = ({ qa, onSubmit }: TestQuestionProps) => {
const queryClient = useQueryClient();
const { question, answer: initialAnswer } = qa;
const [selectedAnswers, setSelectedAnswers] = useState<(string | number)[]>(
initialAnswer ?? []
);
useEffect(() => {
setSelectedAnswers(initialAnswer ?? []);
queryClient.invalidateQueries({ queryKey: ["current-test"] });
}, [initialAnswer, qa.question.id]);
const handleCheckboxChange = (variantId: number) => {
if (question.type === "single") {
setSelectedAnswers([variantId]);
} else {
setSelectedAnswers((prev) =>
prev.includes(variantId)
? prev.filter((x) => x !== variantId)
: [...prev, variantId]
);
}
};
const handleTextChange = (value: string) => {
setSelectedAnswers([value]);
};
const handleSubmit = () => {
onSubmit(selectedAnswers);
};
return (
<Box
sx={{
p: 3,
borderRadius: 2,
mb: 3,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography variant="h6">{question.title}</Typography>
{question.description && (
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{question.description}
</Typography>
)}
{question.type === "text" && (
<TextField
fullWidth
value={selectedAnswers[0] ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
variant="outlined"
/>
)}
{(question.type === "single" || question.type === "multiple") &&
question.variants.map((variant) => (
<FormControlLabel
key={variant.id}
control={
<Checkbox
checked={selectedAnswers.includes(variant.id)}
onChange={() => handleCheckboxChange(variant.id)}
/>
}
label={variant.text}
/>
))}
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSubmit}>
Submit Answer
</Button>
</Box>
);
};
export default TestQuestion;

View File

@ -0,0 +1,70 @@
import { TableRow, TableCell, Button } from "@mui/material";
import { useNavigate } from "react-router-dom";
import type { UserTestType } from "../../components/shared/types/TestTypes";
import { useGetTestById } from "../../hooks/tests/useGetTestById";
import { formatDate } from "../../utils/functions";
import { useAuth } from "../../context/AuthContext";
type Props = {
userTest: UserTestType;
onDelete: (id: number) => void;
};
const UserTestRow = ({ userTest, onDelete }: Props) => {
const navigate = useNavigate();
const { user } = useAuth();
const testId = userTest.test_id;
let title = "User Test";
let author = "—";
if (testId) {
const { data } = useGetTestById(testId);
title = data?.title ?? "User Test";
author = data?.author.username ?? "-";
}
return (
<TableRow>
<TableCell>{userTest.id}</TableCell>
<TableCell>{title}</TableCell>
<TableCell>{userTest.user?.username ?? "Unknown"}</TableCell>
<TableCell>{author}</TableCell>
<TableCell>
{userTest.score !== undefined ? userTest.score : "—"}
</TableCell>
<TableCell>{userTest.is_completed ? "Yes" : "No"}</TableCell>
<TableCell>{formatDate(userTest.created_at)}</TableCell>
<TableCell>{formatDate(userTest.closed_at)}</TableCell>
<TableCell>
<Button
size="small"
variant="outlined"
sx={{ mr: 1 }}
onClick={() => navigate(`/tests/${userTest.id}`)}
>
View
</Button>
{user?.type === "admin" && (
<Button
size="small"
color="error"
variant="outlined"
onClick={() => onDelete(userTest.id)}
>
Delete
</Button>
)}
</TableCell>
</TableRow>
);
};
export default UserTestRow;

View File

@ -1,4 +0,0 @@
import { toast } from "react-toastify";
export const notifySuccess = (msg: string) => toast.success(msg);
export const notifyError = (msg: string) => toast.error(msg);

View File

@ -0,0 +1,26 @@
export type Variant = {
id: number;
text: string;
};
export type Category = {
id: number;
name: string;
};
export type Author = {
id: number;
username: string;
};
export type QuestionType = {
id: number;
title: string;
description: string;
type: "single" | "multiple" | "text";
difficulty: number;
variants: Variant[];
correct_answers: number[] | string[];
category: Category;
author: Author;
};

View File

@ -0,0 +1,88 @@
import type { User } from "./AuthTypes";
import type { QuestionType } from "./QuestionTypes";
export type CategoryType = {
id: number;
name: string;
created_at?: string;
questions_count?: number;
user_tests_count?: number;
};
export interface StartTestPayload {
category_id: number;
min_difficulty: number;
max_difficulty: number;
}
export type UserTestAnswer = {
id: number;
user_test_id: number;
question_id: number;
question: QuestionType;
answer: (string | number)[] | null;
user_id: number;
is_correct: boolean;
created_at: string;
updated_at: string;
};
export type TestType = {
id: number;
title: string;
description: string | null;
category_id: number;
category: CategoryType;
questions?: QuestionType[];
is_available: boolean;
author_id: number;
author: User;
closed_at: string;
created_at: string;
updated_at: string;
};
export type UserTestType = {
id: number;
test_id: number | null;
test?: TestType;
user_id: number;
user?: User;
closed_at: string | null;
is_completed?: boolean;
score?: number;
is_available: boolean;
answers?: UserTestAnswer[];
created_at: string;
updated_at: string;
};
export type SubmitAnswerPayload = {
answerId: 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

@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useCurrentUser } from "../hooks/useCurrentUser"; import { useCurrentUser } from "../hooks/auth/useCurrentUser";
import { useLogin } from "../hooks/useLogin"; import { useLogin } from "../hooks/auth/useLogin";
import type { import type {
LoginPayload, LoginPayload,
LoginResponse, LoginResponse,

View File

@ -1,12 +1,12 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { activateAccount } from "../api/authApi"; import { activateAccount } from "../../api/authApi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export const useActivateAccount = () => { export const useActivateAccount = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return useMutation({ return useMutation({
mutationFn: (token: string) => activateAccount(token), mutationFn: activateAccount,
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.message); toast.success(data.message);
navigate("/login"); navigate("/login");

View File

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchMe } from "../api/authApi"; import { fetchMe } from "../../api/authApi";
export const useCurrentUser = () => export const useCurrentUser = () =>
useQuery({ useQuery({

View File

@ -1,13 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { loginRequest } from "../api/authApi"; import { loginRequest } from "../../api/authApi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import type { LoginPayload } from "../components/shared/types/AuthTypes";
export const useLogin = () => { export const useLogin = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: LoginPayload) => loginRequest(payload), mutationFn: loginRequest,
onSuccess: (data) => { onSuccess: (data) => {
localStorage.setItem("access_token", data.access_token); localStorage.setItem("access_token", data.access_token);

View File

@ -1,19 +1,12 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { registrationRequest } from "../api/authApi"; import { registrationRequest } from "../../api/authApi";
import type { RegistrationPayload } from "../components/shared/types/AuthTypes";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export const useRegistration = () => { export const useRegistration = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: registrationRequest,
username,
email,
password,
password_confirmation,
}: RegistrationPayload) =>
registrationRequest({ username, email, password, password_confirmation }),
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.message); toast.success(data.message);
navigate("/login"); navigate("/login");

View File

@ -1,10 +1,10 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { requestPasswordReset } from "../api/authApi"; import { requestPasswordReset } from "../../api/authApi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const useRequestPasswordReset = () => { export const useRequestPasswordReset = () => {
return useMutation({ return useMutation({
mutationFn: (email: string) => requestPasswordReset(email), mutationFn: requestPasswordReset,
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.message); toast.success(data.message);
}, },

View File

@ -1,11 +1,10 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import type { ResetPasswordPayload } from "../components/shared/types/AuthTypes"; import { resetPassword } from "../../api/authApi";
import { resetPassword } from "../api/authApi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const useResetPassword = () => { export const useResetPassword = () => {
return useMutation({ return useMutation({
mutationFn: (data: ResetPasswordPayload) => resetPassword(data), mutationFn: resetPassword,
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.message); toast.success(data.message);
}, },

View File

@ -0,0 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { getCategories } from "../../api/categoryApi";
export const useCategories = () =>
useQuery({
queryKey: ["categories"],
queryFn: getCategories,
});

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createCategory } from "../../api/categoryApi";
import { toast } from "react-toastify";
export const useCreateCategory = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => createCategory(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
toast.success("Category Created");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteCategory } from "../../api/categoryApi";
import { toast } from "react-toastify";
export const useDeleteCategory = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteCategory(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
toast.success("Category Deleted");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getCategoryById } from "../../api/categoryApi";
export const useCategoryById = (id?: number) => {
return useQuery({
queryKey: ["categories", id],
queryFn: () => getCategoryById(id!),
enabled: !!id,
});
};

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateCategory } from "../../api/categoryApi";
import { toast } from "react-toastify";
export const useUpdateCategory = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) =>
updateCategory(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
toast.success("Category Updated");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getHitCounts } from "../../api/metricsService";
export const useHitcounts = (page: number) => {
return useQuery({
queryKey: ["hitcounts", page],
queryFn: () => getHitCounts(page),
});
};

View File

@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getLogs } from "../../api/logsApi";
export const useLogs = (page: number) => {
return useQuery({
queryKey: ["logs", page],
queryFn: () => getLogs(page),
});
};

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createQuestion, type QuestionPayload } from "../../api/questionsApi";
import { toast } from "react-toastify";
export const useCreateQuestion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: QuestionPayload) => createQuestion(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["questions"] });
toast.success("Question Created");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteQuestion } from "../../api/questionsApi";
import { toast } from "react-toastify";
export const useDeleteQuestion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteQuestion(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["questions"] });
toast.success("Question Deleted");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,10 @@
import { useMutation } from "@tanstack/react-query";
import { openaiGenerateQuestion } from "../../api/questionsApi";
import type { OpenaiPayload } from "../../api/questionsApi";
import type { QuestionPayload } from "../../api/questionsApi";
export const useOpenaiGenerateQuestion = () => {
return useMutation<QuestionPayload, Error, OpenaiPayload>({
mutationFn: openaiGenerateQuestion,
});
};

View File

@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getQuestionById } from "../../api/questionsApi";
export const useQuestionById = (id?: number) => {
return useQuery({
queryKey: ["single-question", id],
queryFn: () => getQuestionById(Number(id!)),
enabled: !!id,
});
};

View File

@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { getQuestions } from "../../api/questionsApi";
type UseQuestionsParams = {
page?: number;
id?: number;
};
export const useQuestions = ({ page = 1, id }: UseQuestionsParams) => {
return useQuery({
queryKey: ["questions", { page, id }],
queryFn: () => getQuestions({ page, id }),
staleTime: 1000 * 60,
});
};

View File

@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateQuestion, type QuestionPayload } from "../../api/questionsApi";
import { toast } from "react-toastify";
export const useUpdateQuestion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: number; payload: QuestionPayload }) =>
updateQuestion(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["questions"] });
toast.success("Question Updated");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { completeUserTest } from "../../api/testApi";
import { toast } from "react-toastify";
export const useCompleteTest = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: completeUserTest,
onSuccess: (data) => {
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: ["current-test"] });
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
},
onError: (error: any) => {
toast.error(error.response?.data?.message);
},
});
};

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createTest } from "../../api/testApi";
import { toast } from "react-toastify";
export const useCreateTest = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tests"] });
toast.success("Test Created");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteTest } from "../../api/testApi";
import { toast } from "react-toastify";
export const useDeleteTest = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteTest(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tests"] });
toast.success("Test deleted");
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete test");
},
});
};

View File

@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteUserTest } from "../../api/testApi";
import { toast } from "react-toastify";
export const useDeleteUserTest = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteUserTest(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["all-user-tests"] });
toast.success("User Test deleted")
},
});
};

View File

@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getAllUserTests } from "../../api/testApi";
export const useGetAllUserTests = (
page: number,
test_id?: number,
question_id?: number
) =>
useQuery({
queryKey: ["all-user-tests", page],
queryFn: () => getAllUserTests(page, test_id, question_id),
});

View File

@ -0,0 +1,11 @@
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!),
enabled: !!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,9 @@
import { useQuery } from "@tanstack/react-query";
import { getUserTests } from "../../api/testApi";
export const useGetUserTests = () => {
return useQuery({
queryKey: ["user-tests"],
queryFn: () => getUserTests(),
});
};

View File

@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { startTest } from "../../api/testApi";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
export const useStartTest = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: startTest,
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

@ -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

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { submitAnswer } from "../../api/testApi";
import { toast } from "react-toastify";
export const useSubmitAnswer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: submitAnswer,
onSuccess: (data) => {
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: ["current-test"] });
},
onError: (error: any) => {
toast.error(error.response?.data?.message || "Something went wrong");
},
});
};

View File

@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTest, type TestPayload } from "../../api/testApi";
import { toast } from "react-toastify";
export const useUpdateTest = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: number; payload: TestPayload }) =>
updateTest(id, payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["tests"] });
queryClient.invalidateQueries({ queryKey: ["tests", variables.id] });
toast.success("Test updated");
},
onError: (error: any) => {
toast.error(error.message ?? "Failed to update test");
},
});
};

View File

@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getUserTestById } from "../../api/testApi";
export const useUserTestById = (id: number) => {
return useQuery({
queryKey: ["current-test"],
queryFn: () => getUserTestById(id),
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createUser, type UserPayload } from "../../api/usersApi";
import { toast } from "react-toastify";
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UserPayload) => createUser(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("User Created");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteUser } from "../../api/usersApi";
import { toast } from "react-toastify";
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("User Deleted!");
},
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateUser, type UserPayload } from "../../api/usersApi";
import { toast } from "react-toastify";
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: number; payload: UserPayload }) =>
updateUser(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("User Updated");
},
onError: (error) =>{
toast.error(error.message);
}
});
};

View File

@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getUserById } from "../../api/usersApi";
export const useUserById = (id?: number) => {
return useQuery({
queryKey: ["user", id],
queryFn: () => getUserById(id!),
enabled: !!id,
});
};

View File

@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getUsers } from "../../api/usersApi";
export const useUsers = (page = 1) => {
return useQuery({
queryKey: ["users", page],
queryFn: () => getUsers(page),
staleTime: 1000 * 60,
});
};

View File

@ -0,0 +1,20 @@
import { Outlet } from "react-router-dom";
import { AdminSidebar } from "../../components/AdminSidebar/AdminSidebar";
import { LayoutWrapper } from "../MainLayout/Layout.styles";
import Header from "../../components/Header/Header";
import { StyledDistance } from "../../components/shared/StyledDistance";
import Cholecounter from "../../components/Cholecounter/Cholecounter";
const AdminLayout = () => {
return (
<LayoutWrapper>
<Header />
<StyledDistance />
<AdminSidebar />
<Cholecounter/>
<Outlet />
</LayoutWrapper>
);
};
export default AdminLayout;

View File

@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom";
import Header from "../../components/Header/Header"; import Header from "../../components/Header/Header";
import { LayoutWrapper } from "./Layout.styles"; import { LayoutWrapper } from "./Layout.styles";
import { StyledDistance } from "../../components/shared/StyledDistance"; import { StyledDistance } from "../../components/shared/StyledDistance";
import Cholecounter from "../../components/Cholecounter/Cholecounter";
const MainLayout = () => { const MainLayout = () => {
return ( return (
@ -9,6 +10,7 @@ const MainLayout = () => {
<LayoutWrapper> <LayoutWrapper>
<Header /> <Header />
<StyledDistance /> <StyledDistance />
<Cholecounter/>
<Outlet /> <Outlet />
</LayoutWrapper> </LayoutWrapper>
</> </>

View File

@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useActivateAccount } from "../../hooks/useActivateAccount"; import { useActivateAccount } from "../../hooks/auth/useActivateAccount";
import { CircularProgress, Box, Typography } from "@mui/material"; import { CircularProgress, Box, Typography } from "@mui/material";
const ActivateAccountPage = () => { const ActivateAccountPage = () => {

View File

@ -0,0 +1,166 @@
import { useState } from "react";
import {
Box,
Button,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Pagination,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import Container from "../../components/shared/Container";
import type { TestType } from "../../components/shared/types/TestTypes";
import { useGetTests } from "../../hooks/tests/useGetTests";
import { useDeleteTest } from "../../hooks/tests/useDeleteTest";
import { formatDate } from "../../utils/functions";
import { useAuth } from "../../context/AuthContext";
const AdminTestsPage = () => {
const [currentPage, setCurrentPage] = useState(1);
const [deleteTestId, setDeleteTestId] = useState<number | null>(null);
const { user } = useAuth();
const { data, isLoading, isError } = useGetTests({ page: currentPage });
const deleteMutation = useDeleteTest();
const navigate = useNavigate();
const handleViewTest = (id: number) => {
navigate(`/tests/view/${id}`);
};
const handleUpdateTest = (id: number) => {
navigate(`/dashboard/tests/${id}/update`);
};
const handleDeleteTest = () => {
if (deleteTestId !== null) {
deleteMutation.mutate(deleteTestId, {
onSuccess: () => setDeleteTestId(null),
});
}
};
if (isLoading) return <CircularProgress />;
if (isError)
return <Typography color="error">Error loading tests.</Typography>;
return (
<Container>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h4">Tests</Typography>
<Button
variant="contained"
onClick={() => navigate("/dashboard/tests/create")}
>
Create Test
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Title</TableCell>
<TableCell>Category</TableCell>
<TableCell>Author</TableCell>
<TableCell>Available</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Closed At</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((test: TestType) => (
<TableRow key={test.id}>
<TableCell>{test.id}</TableCell>
<TableCell>{test.title}</TableCell>
<TableCell>{test.category?.name}</TableCell>
<TableCell>{test.author?.username}</TableCell>
<TableCell>{test.is_available ? "Yes" : "No"}</TableCell>
<TableCell>{formatDate(test.created_at)}</TableCell>
<TableCell>{formatDate(test.closed_at)}</TableCell>
<TableCell align="right">
<Button
size="small"
variant="outlined"
onClick={() => handleViewTest(test.id)}
sx={{ mr: 1 }}
>
View
</Button>
{(user?.type === "admin" || user?.id == test.author_id) && (
<>
<Button
size="small"
variant="outlined"
color="primary"
onClick={() => handleUpdateTest(test.id)}
sx={{ mr: 1 }}
>
Update
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => setDeleteTestId(test.id)}
>
Delete
</Button>
</>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Pagination
color="primary"
shape="rounded"
count={data?.meta.last_page}
page={currentPage}
onChange={(_, value) => setCurrentPage(value)}
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
/>
<Dialog
open={deleteTestId !== null}
onClose={() => setDeleteTestId(null)}
>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete this test?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTestId(null)}>Cancel</Button>
<Button
color="error"
onClick={handleDeleteTest}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default AdminTestsPage;

View File

@ -0,0 +1,114 @@
import { useState } from "react";
import {
Box,
Button,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Pagination,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import Container from "../../components/shared/Container";
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest";
import UserTestRow from "../../components/UserTestRow/UserTestRow";
const UserTestsPage = () => {
const [currentPage, setCurrentPage] = useState(1);
const [deleteId, setDeleteId] = useState<number | null>(null);
const { data, isLoading, isError } = useGetAllUserTests(currentPage);
const deleteMutation = useDeleteUserTest();
const handleDelete = () => {
if (deleteId !== null) {
deleteMutation.mutate(deleteId, {
onSuccess: () => setDeleteId(null),
});
}
};
if (isLoading) return <CircularProgress />;
if (isError)
return <Typography color="error">Error loading user tests.</Typography>;
return (
<Container>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h4">User Tests</Typography>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Test</TableCell>
<TableCell>Test Taker</TableCell>
<TableCell>Test Author</TableCell>
<TableCell>Score</TableCell>
<TableCell>Completed</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Closed At</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((ut) => (
<UserTestRow
key={ut.id}
userTest={ut}
onDelete={(id) => setDeleteId(id)}
/>
))}
</TableBody>
</Table>
</TableContainer>
<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",
}}
/>
<Dialog open={deleteId !== null} onClose={() => setDeleteId(null)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete this user test?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteId(null)}>Cancel</Button>
<Button
color="error"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default UserTestsPage;

View File

@ -0,0 +1,136 @@
import { useState } from "react";
import {
Box,
Button,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import Container from "../../components/shared/Container";
import { useCategories } from "../../hooks/categories/useCategories";
import { useDeleteCategory } from "../../hooks/categories/useDeleteCategory";
const CategoriesPage = () => {
const [deleteCategoryId, setDeleteCategoryId] = useState<number | null>(null);
const { data, isLoading, isError } = useCategories();
const deleteMutation = useDeleteCategory();
const navigate = useNavigate();
const handleCreateCategory = () => {
navigate("/dashboard/categories/create");
};
const handleUpdateCategory = (id: number) => {
navigate(`/dashboard/categories/${id}/update`);
};
const handleDeleteCategory = () => {
if (deleteCategoryId !== null) {
deleteMutation.mutate(deleteCategoryId, {
onSuccess: () => setDeleteCategoryId(null),
});
}
};
if (isLoading) return <CircularProgress />;
if (isError)
return <Typography color="error">Error loading categories.</Typography>;
return (
<Container>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h4">Categories</Typography>
<Button
variant="contained"
color="primary"
onClick={handleCreateCategory}
>
Create Category
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Questions</TableCell>
<TableCell>User Tests</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.map((category) => (
<TableRow key={category.id}>
<TableCell>{category.id}</TableCell>
<TableCell>{category.name}</TableCell>
<TableCell>{category.questions_count ?? "-"}</TableCell>
<TableCell>{category.user_tests_count ?? "-"}</TableCell>
<TableCell>
{category.created_at
? new Date(category.created_at).toLocaleDateString()
: "-"}
</TableCell>
<TableCell>
<Button
variant="outlined"
size="small"
sx={{ mr: 1 }}
onClick={() => handleUpdateCategory(category.id)}
>
Update
</Button>
<Button
variant="outlined"
color="error"
size="small"
onClick={() => setDeleteCategoryId(category.id)}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog
open={deleteCategoryId !== null}
onClose={() => setDeleteCategoryId(null)}
>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete this category?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteCategoryId(null)}>Cancel</Button>
<Button
color="error"
onClick={handleDeleteCategory}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default CategoriesPage;

View File

@ -0,0 +1,77 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
CircularProgress,
TextField,
Typography,
} from "@mui/material";
import { useCreateCategory } from "../../hooks/categories/useCreateCategory";
import { useUpdateCategory } from "../../hooks/categories/useUpdateCategory";
import { useCategoryById } from "../../hooks/categories/useGetCategoryById";
const CategoryForm = () => {
const { id } = useParams<{ id: string }>();
const isUpdate = Boolean(id);
const navigate = useNavigate();
const [name, setName] = useState("");
const { data: category, isLoading } = useCategoryById(
id ? Number(id) : undefined
);
const createCategoryMutation = useCreateCategory();
const updateCategoryMutation = useUpdateCategory();
useEffect(() => {
if (category) {
setName(category.name);
}
}, [category]);
if (isUpdate && isLoading) return <CircularProgress />;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isUpdate && id) {
updateCategoryMutation.mutate(
{ id: Number(id), name },
{ onSuccess: () => navigate("/dashboard/categories") }
);
} else {
createCategoryMutation.mutate(name, {
onSuccess: () => navigate("/dashboard/categories"),
});
}
};
return (
<Box sx={{ maxWidth: 500, mx: "auto", mt: 5 }}>
<Typography variant="h5" mb={3}>
{isUpdate ? "Update Category" : "Create Category"}
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
required
sx={{ mb: 3 }}
/>
<Button variant="contained" color="primary" type="submit">
{isUpdate ? "Update" : "Create"}
</Button>
</form>
</Box>
);
};
export default CategoryForm;

View File

@ -0,0 +1,71 @@
import { useState } from "react";
import {
Box,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Pagination,
} from "@mui/material";
import Container from "../../components/shared/Container";
import { useHitcounts } from "../../hooks/hitcounts/useHitcounts";
const HitCountsPage = () => {
const [currentPage, setCurrentPage] = useState(1);
const { data, isLoading, isError } = useHitcounts(currentPage);
if (isLoading) return <CircularProgress />;
if (isError)
return <Typography color="error">Error loading hitcounts.</Typography>;
return (
<Container>
<Box sx={{ mb: 3 }}>
<Typography variant="h4">Hit Counts</Typography>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>IP</TableCell>
<TableCell>Device</TableCell>
<TableCell>User Agent</TableCell>
<TableCell>Country</TableCell>
<TableCell>URL</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((hit) => (
<TableRow key={hit.id}>
<TableCell>{hit.id}</TableCell>
<TableCell>{hit.ip}</TableCell>
<TableCell>{hit.device_type}</TableCell>
<TableCell>{hit.user_agent}</TableCell>
<TableCell>{hit.country || "-"}</TableCell>
<TableCell>{hit.url}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Pagination
color="primary"
shape="rounded"
count={data?.meta.last_page || 1}
page={currentPage}
onChange={(_, value) => setCurrentPage(value)}
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
/>
</Container>
);
};
export default HitCountsPage;

View File

@ -7,11 +7,15 @@ export const WelcomeContainer = styled("div")({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
padding: "10px", padding: "10px",
marginBottom: "36px" marginBottom: "36px",
}); });
export const ButtonGroup = styled("div")({ export const ButtonGroup = styled("div")({
display: "flex", display: "flex",
marginTop: 20,
alignItems: "center",
justifyContent: "center",
gap: 10
}); });
export const IndexWrapper = styled("div")({ export const IndexWrapper = styled("div")({

View File

@ -1,29 +1,101 @@
import { Typography } from "@mui/material"; import {
Typography,
Pagination,
type SelectChangeEvent,
FormControl,
InputLabel,
Select,
MenuItem,
} from "@mui/material";
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/questions/useQuestions";
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
import { useAuth } from "../../context/AuthContext";
import { useCategories } from "../../hooks/categories/useCategories";
const IndexPage = () => { const IndexPage = () => {
const [page, setPage] = useState(1);
const myQuestion = { const { data: categories } = useCategories();
title: "Example Question", const { user } = useAuth();
category: "Math", const [selectedCategory, setSelectedCategory] = useState<number | "all">(
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ultricies, urna eu aliquet tincidunt, augue turpis gravida risus, sit amet posuere neque tellus sit amet justo. Curabitur non facilisis orci, vitae eleifend est. Cras fermentum, velit at scelerisque varius, mauris justo cursus lacus, ac porttitor ante metus sit amet nibh. Vivamus imperdiet, diam vel efficitur euismod, urna odio pharetra ipsum, vitae sollicitudin neque enim ut tortor. Donec gravida orci.", "all"
difficulty: 3, );
author: "David" const questionsQuery = useQuestions({
}; page,
id: selectedCategory === "all" ? undefined : selectedCategory,
});
const questions = questionsQuery.data?.data ?? [];
const loading = questionsQuery.isLoading;
const error = questionsQuery.error?.message ?? 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 />
<Typography sx={{mb:"36px"}} variant="h3">Browse Questions</Typography> {user && (
<Question question={myQuestion} /> <>
<Typography align="center" sx={{ mb: "36px" }} variant="h3">
Start a Test
</Typography>
<StartTestForm />
</>
)}
<Typography align="center" sx={{ mb: "36px" }} variant="h3">
Browse Questions
</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>}
{error && <Typography color="error">{error}</Typography>}
{questions.map((q: QuestionType) => (
<Question key={q.id} question={q} />
))}
{meta && meta.last_page > 1 && (
<Pagination
count={meta.last_page}
page={page}
onChange={(_, value) => setPage(value)}
color="primary"
shape="rounded"
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }}
/>
)}
</Container> </Container>
); );
}; };

View File

@ -0,0 +1,67 @@
import { useState } from "react";
import {
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Pagination,
} from "@mui/material";
import Container from "../../components/shared/Container";
import { useLogs } from "../../hooks/logsHook/useLogs";
const LogsPage = () => {
const [currentPage, setCurrentPage] = useState(1);
const { data, isLoading, isError } = useLogs(currentPage);
if (isLoading) return <CircularProgress />;
if (isError)
return <Typography color="error">Error loading logs.</Typography>;
return (
<Container>
<Typography variant="h4" mb={3}>
Activity Logs
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Description</TableCell>
<TableCell>Created At</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((log) => (
<TableRow key={log.id}>
<TableCell>{log.id}</TableCell>
<TableCell>{log.description}</TableCell>
<TableCell>
{new Date(log.created_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Pagination
color="primary"
shape="rounded"
count={data?.meta.last_page}
page={currentPage}
onChange={(_, value) => setCurrentPage(value)}
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
/>
</Container>
);
};
export default LogsPage;

View File

@ -1,11 +1,25 @@
import { Box, Button, Typography } from "@mui/material"; import { Box, Button, Tab, Tabs, Typography } from "@mui/material";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import Container from "../../components/shared/Container"; import Container from "../../components/shared/Container";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useGetUserTests } from "../../hooks/tests/useGetUserTests";
import TestCard from "../../components/TestCard/TestCard";
import { useState } from "react";
const ProfilePage = () => { const ProfilePage = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: tests, isLoading, error } = useGetUserTests();
const [tabIndex, setTabIndex] = useState(0);
const activeTests = tests?.filter((t) => t.is_available && !t.is_completed);
const completedTests = tests?.filter((t) => t.is_completed);
const expiredTests = tests?.filter((t) => !t.is_available && !t.is_completed);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setTabIndex(newValue);
};
return ( return (
<Container> <Container>
@ -27,15 +41,52 @@ const ProfilePage = () => {
</Button> </Button>
{user?.type === "admin" && ( {user?.type === "admin" && (
<Button <Button
sx={{marginLeft: "10px"}} sx={{ marginLeft: "10px" }}
variant="contained" variant="contained"
color="secondary" color="secondary"
onClick={() => navigate("/admin")} onClick={() => navigate("/dashboard")}
> >
Admin Dashboard Admin Dashboard
</Button> </Button>
)} )}
</Box> </Box>
<Box sx={{ mt: 4 }}>
<Typography variant="h5" mb={2}>
Your Tests
</Typography>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={`Active (${activeTests?.length || 0})`} />
<Tab label={`Completed (${completedTests?.length || 0})`} />
<Tab label={`Expired (${expiredTests?.length || 0})`} />
</Tabs>
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 2 }}>
{isLoading && <Typography>Loading tests...</Typography>}
{error && (
<Typography color="error">Failed to load tests.</Typography>
)}
{tabIndex === 0 &&
(activeTests?.length ? (
activeTests.map((t) => <TestCard key={t.id} test={t} />)
) : (
<Typography>No active tests</Typography>
))}
{tabIndex === 1 &&
(completedTests?.length ? (
completedTests.map((t) => <TestCard key={t.id} test={t} />)
) : (
<Typography>No completed tests</Typography>
))}
{tabIndex === 2 &&
(expiredTests?.length ? (
expiredTests.map((t) => <TestCard key={t.id} test={t} />)
) : (
<Typography>No expired tests</Typography>
))}
</Box>
</Box>
</Container> </Container>
); );
}; };

View File

@ -0,0 +1,287 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
CircularProgress,
MenuItem,
TextField,
Typography,
IconButton,
FormControlLabel,
Checkbox,
Paper,
} from "@mui/material";
import { Add, Remove } from "@mui/icons-material";
import { useCategories } from "../../hooks/categories/useCategories";
import { useCreateQuestion } from "../../hooks/questions/useCreateQuestion";
import { useUpdateQuestion } from "../../hooks/questions/useUpdateQuestion";
import type { QuestionPayload } from "../../api/questionsApi";
import type { Variant } from "../../components/shared/types/QuestionTypes";
import Container from "../../components/shared/Container";
import { useQuestionById } from "../../hooks/questions/useQuestionById";
import GenerateQuestionModal from "../../components/GenerateQuestionModal/GenerateQuestionModal";
const QuestionForm = () => {
const { id } = useParams<{ id: string }>();
const isUpdate = Boolean(id);
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [type, setType] = useState<"single" | "multiple" | "text">("single");
const [difficulty, setDifficulty] = useState(1);
const [categoryId, setCategoryId] = useState<number | "">("");
const [variants, setVariants] = useState<Variant[]>([{ id: 1, text: "" }]);
const [correctAnswers, setCorrectAnswers] = useState<number[]>([]);
const [textAnswer, setTextAnswer] = useState("");
const [openAiModal, setOpenAiModal] = useState(false);
const [generatedQuestion, setGeneratedQuestion] =
useState<QuestionPayload | null>(null);
const { data: categories } = useCategories();
const { data: question, isLoading: isLoadingQuestion } = useQuestionById(
isUpdate ? Number(id) : undefined
);
const createMutation = useCreateQuestion();
const updateMutation = useUpdateQuestion();
useEffect(() => {
if (question || generatedQuestion) {
const q = question ?? generatedQuestion;
setTitle(q.title);
setDescription(q.description ?? "");
setType(q.type);
setDifficulty(q.difficulty);
setCategoryId(q.category_id);
if (q.type === "text") {
setTextAnswer(q.correct_answers[0] as string);
} else {
setVariants(q.variants.length ? q.variants : [{ id: 1, text: "" }]);
setCorrectAnswers(q.correct_answers);
}
}
}, [question, generatedQuestion]);
if (isUpdate && isLoadingQuestion) return <CircularProgress />;
const handleVariantChange = (index: number, value: string) => {
const newVariants = [...variants];
newVariants[index].text = value;
setVariants(newVariants);
};
const addVariant = () => {
setVariants((prev) => [
...prev,
{
id: Math.max(0, ...prev.map((v) => v.id)) + 1,
text: "",
},
]);
};
const removeVariant = (index: number) => {
const removedId = variants[index].id;
setVariants((prev) => prev.filter((_, i) => i !== index));
setCorrectAnswers((prev) => prev.filter((id) => id !== removedId));
};
const toggleCorrectAnswer = (variantId: number) => {
if (type === "single") {
setCorrectAnswers([variantId]);
} else {
setCorrectAnswers((prev) =>
prev.includes(variantId)
? prev.filter((id) => id !== variantId)
: [...prev, variantId]
);
}
};
const validate = () => {
if (!title || !categoryId) return false;
if (type !== "text") {
if (variants.length < 2) return false;
if (correctAnswers.length === 0) return false;
if (type === "single" && correctAnswers.length !== 1) return false;
} else {
if (!textAnswer.trim()) return false;
}
if (difficulty < 1 || difficulty > 10) return false;
return true;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
const payload: QuestionPayload = {
title,
description,
type,
difficulty,
category_id: Number(categoryId),
variants: type === "text" ? [] : variants,
correct_answers: type === "text" ? [textAnswer]: correctAnswers,
};
if (isUpdate && id) {
updateMutation.mutate(
{ id: Number(id), payload },
{ onSuccess: () => navigate("/dashboard/questions") }
);
} else {
createMutation.mutate(payload, {
onSuccess: () => navigate("/dashboard/questions"),
});
}
};
return (
<Container>
<Typography variant="h5" mb={3}>
{isUpdate ? "Update Question" : "Create Question"}
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
required
sx={{ mb: 2 }}
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
fullWidth
multiline
rows={2}
sx={{ mb: 2 }}
/>
<TextField
select
label="Type"
value={type}
onChange={(e) =>
setType(e.target.value as "single" | "multiple" | "text")
}
fullWidth
sx={{ mb: 2 }}
>
<MenuItem value="single">Single Choice</MenuItem>
<MenuItem value="multiple">Multiple Choice</MenuItem>
<MenuItem value="text">Text Answer</MenuItem>
</TextField>
<TextField
label="Difficulty (1-10)"
type="number"
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
fullWidth
sx={{ mb: 2 }}
/>
<TextField
select
label="Category"
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
fullWidth
required
sx={{ mb: 3 }}
>
{categories?.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</TextField>
{type === "text" ? (
<TextField
label="Correct Answer"
value={textAnswer}
onChange={(e) => setTextAnswer(e.target.value)}
fullWidth
required
sx={{ mb: 2 }}
/>
) : (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle1">
Variants & Correct Answer
</Typography>
{variants.map((v, i) => (
<Paper
key={v.id}
sx={{
display: "flex",
alignItems: "center",
mb: 1,
p: 1,
}}
>
<TextField
value={v.text}
onChange={(e) => handleVariantChange(i, e.target.value)}
fullWidth
required
/>
<FormControlLabel
control={
<Checkbox
checked={correctAnswers.includes(v.id)}
onChange={() => toggleCorrectAnswer(v.id)}
/>
}
label="Correct"
sx={{ ml: 1 }}
/>
<IconButton onClick={() => removeVariant(i)}>
<Remove />
</IconButton>
</Paper>
))}
<Button variant="outlined" startIcon={<Add />} onClick={addVariant}>
Add Variant
</Button>
</Box>
)}
<Button variant="contained" type="submit">
{isUpdate ? "Update Question" : "Create Question"}
</Button>
{!isUpdate && (
<Button
variant="outlined"
sx={{ ml: 2 }}
onClick={() => setOpenAiModal(true)}
>
Generate with AI
</Button>
)}
</form>
<GenerateQuestionModal
open={openAiModal}
onClose={() => setOpenAiModal(false)}
onGenerated={(q) => setGeneratedQuestion(q)}
/>
</Container>
);
};
export default QuestionForm;

View File

@ -1,10 +1,168 @@
import { useState } from "react";
import StartTestForm from "../../components/StartTestForm/StartTestForm"; import {
Box,
Button,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Pagination,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useQuestions } from "../../hooks/questions/useQuestions";
import { useDeleteQuestion } from "../../hooks/questions/useDeleteQuestion";
import Container from "../../components/shared/Container";
import { useNavigate } from "react-router-dom";
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
import { useAuth } from "../../context/AuthContext";
const QuestionsPage = () => { const QuestionsPage = () => {
return( const [currentPage, setCurrentPage] = useState(1);
<StartTestForm/> const [deleteQuestionId, setDeleteQuestionId] = useState<number | null>(null);
) const { data, isLoading, isError } = useQuestions({ page: currentPage });
} const deleteMutation = useDeleteQuestion();
const navigate = useNavigate();
const { user } = useAuth();
export default QuestionsPage; const handleCreateQuestion = () => {
navigate("/dashboard/questions/create");
};
const handleUpdateQuestion = (id: number) => {
navigate(`/dashboard/questions/${id}/update`);
};
const handleViewQuestion = (id: number) => {
navigate(`/questions/${id}`);
};
const handleDeleteQuestion = () => {
if (deleteQuestionId !== null) {
deleteMutation.mutate(deleteQuestionId, {
onSuccess: () => setDeleteQuestionId(null),
});
}
};
if (isLoading) return <CircularProgress />;
if (isError)
return <Typography color="error">Error loading questions.</Typography>;
return (
<Container>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h4">Questions</Typography>
<Button
variant="contained"
color="primary"
onClick={handleCreateQuestion}
>
Create Question
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Title</TableCell>
<TableCell>Type</TableCell>
<TableCell>Difficulty</TableCell>
<TableCell>Category</TableCell>
<TableCell>Author</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((question: QuestionType) => (
<TableRow key={question.id}>
<TableCell>{question.id}</TableCell>
<TableCell>{question.title}</TableCell>
<TableCell>{question.type}</TableCell>
<TableCell>{question.difficulty}</TableCell>
<TableCell>{question.category?.name ?? "—"}</TableCell>
<TableCell>{question.author.username}</TableCell>
<TableCell sx={{ display: "flex" }}>
<Button
variant="outlined"
color="info"
size="small"
onClick={() => handleViewQuestion(question.id)}
sx={{ mr: 1 }}
>
View
</Button>
{(user?.type === "admin" ||
user?.id == question.author.id) && (
<>
<Button
variant="outlined"
color="primary"
size="small"
onClick={() => handleUpdateQuestion(question.id)}
sx={{ mr: 1 }}
>
Update
</Button>
<Button
variant="outlined"
color="error"
size="small"
onClick={() => setDeleteQuestionId(question.id)}
>
Delete
</Button>
</>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<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" }}
/>
<Dialog
open={deleteQuestionId !== null}
onClose={() => setDeleteQuestionId(null)}
>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete this question?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteQuestionId(null)}>Cancel</Button>
<Button
color="error"
onClick={handleDeleteQuestion}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default QuestionsPage;

View File

@ -3,7 +3,7 @@ import { useAuth } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Container from "../../components/shared/Container"; import Container from "../../components/shared/Container";
import { Box, Button, TextField, Typography } from "@mui/material"; import { Box, Button, TextField, Typography } from "@mui/material";
import { useRegistration } from "../../hooks/useRegistration"; import { useRegistration } from "../../hooks/auth/useRegistration";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
const RegistrationPage = () => { const RegistrationPage = () => {

View File

@ -3,7 +3,7 @@ import Container from "../../components/shared/Container";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRequestPasswordReset } from "../../hooks/useRequestPasswordReset"; import { useRequestPasswordReset } from "../../hooks/auth/useRequestPasswordReset";
const RequestResetPage = () => { const RequestResetPage = () => {
const { user } = useAuth(); const { user } = useAuth();

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useResetPassword } from "../../hooks/useResetPassword"; import { useResetPassword } from "../../hooks/auth/useResetPassword";
import Container from "../../components/shared/Container"; import Container from "../../components/shared/Container";
import { Box, Button, TextField, Typography } from "@mui/material"; import { Box, Button, TextField, Typography } from "@mui/material";

View File

@ -0,0 +1,28 @@
import { Container } from "@mui/material";
import { useParams } from "react-router-dom";
import { useQuestionById } from "../../hooks/questions/useQuestionById";
import Question from "../../components/Question/Question";
import LearningAnswers from "../../components/Answers/Answers";
const SingleQuestionPage = () => {
const { id } = useParams();
const { data: question, isLoading, error } = useQuestionById(Number(id));
if (isLoading) return <Container>Loading...</Container>;
if (error) return <Container>Error: {error.message}</Container>;
if (!question) return <Container>No question found</Container>;
return (
<Container sx={{ mt: 4 }}>
<Question question={question} />
<LearningAnswers
variants={question.variants}
correctAnswers={question.correct_answers}
type={question.type as "single" | "text"}
/>
</Container>
);
};
export default SingleQuestionPage;

View File

@ -0,0 +1,128 @@
import { useParams } from "react-router-dom";
import { useGetTestById } from "../../hooks/tests/useGetTestById";
import Container from "../../components/shared/Container";
import {
Box,
Button,
Pagination,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import LearningAnswers from "../../components/Answers/Answers";
import NotFoundPage from "../NotFoundPage/NotFoundPage";
import { useAuth } from "../../context/AuthContext";
import { useState } from "react";
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
import UserTestRow from "../../components/UserTestRow/UserTestRow";
import TestQrModal from "../../components/TestQrModal/TestQrModal";
export const SingleTestPage = () => {
const { id } = useParams();
const { user } = useAuth();
const [currentPage, setCurrentPage] = useState(1);
const { data: tests } = useGetAllUserTests(currentPage, Number(id));
const [qrOpen, setQrOpen] = useState(false);
const { data: test } = useGetTestById(Number(id));
if (!test) {
return <NotFoundPage />;
}
return (
<Container>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h2">{test.title}</Typography>
{(user?.type === "admin" || user?.type === "creator") && (
<Button variant="outlined" onClick={() => setQrOpen(true)}>
Show QR
</Button>
)}
</Box>
{test?.questions?.map((q) => (
<Box key={q.id} sx={{ mb: 4 }}>
<Typography variant="h6">{q.title}</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{q.description}
</Typography>
<LearningAnswers
type={q.type as "single" | "multiple" | "text"}
variants={q.variants}
correctAnswers={q.correct_answers}
/>
</Box>
))}
{(user?.type == "admin" || user?.type == "creator") && (
<span>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h4">{test.title} results</Typography>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Test</TableCell>
<TableCell>Test Taker</TableCell>
<TableCell>Test Author</TableCell>
<TableCell>Score</TableCell>
<TableCell>Completed</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Closed At</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tests?.data && tests.data.length > 0 ? (
tests.data.map((ut) => (
<UserTestRow
key={ut.id}
userTest={ut}
onDelete={() => {}}
/>
))
) : (
<TableRow>
<TableCell colSpan={9} align="center" sx={{ py: 3 }}>
<Typography variant="body1" color="text.secondary">
No results found for this test.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Pagination
color="primary"
shape="rounded"
count={tests?.meta.last_page}
page={currentPage}
onChange={(_, value) => setCurrentPage(value)}
sx={{
mt: 3,
mb: 3,
display: "flex",
justifyContent: "center",
}}
/>
</span>
)}
<TestQrModal open={qrOpen} onClose={() => setQrOpen(false)} test={test} />
</Container>
);
};

View File

@ -0,0 +1,193 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
CircularProgress,
FormControlLabel,
Checkbox,
MenuItem,
TextField,
Typography,
Pagination,
} from "@mui/material";
import { useCategories } from "../../hooks/categories/useCategories";
import { useGetTestById } from "../../hooks/tests/useGetTestById";
import { useQuestions } from "../../hooks/questions/useQuestions";
import { useCreateTest } from "../../hooks/tests/useCreateTest";
import { useUpdateTest } from "../../hooks/tests/useUpdateTest";
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
import Container from "../../components/shared/Container";
const TestForm = () => {
const { id } = useParams<{ id: string }>();
const isUpdate = Boolean(id);
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [closedAt, setClosedAt] = useState("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [selectedQuestions, setSelectedQuestions] = useState<number[]>([]);
const [questionsPage, setQuestionsPage] = useState(1);
const { data: test, isLoading: isLoadingTest } = useGetTestById(
isUpdate ? Number(id) : undefined
);
const { data: categories } = useCategories();
const { data: questions, isLoading: isLoadingQuestions } = useQuestions({
page: questionsPage,
id: categoryId || undefined,
});
const createMutation = useCreateTest();
const updateMutation = useUpdateTest();
useEffect(() => {
if (test) {
setTitle(test.title);
setDescription(test.description ?? "");
setClosedAt(test.closed_at);
setCategoryId(test.category_id);
setSelectedQuestions(test.questions?.map((q) => q.id) ?? []);
}
}, [test]);
if (isUpdate && isLoadingTest) return <CircularProgress />;
const toggleQuestion = (id: number) => {
setSelectedQuestions((prev) =>
prev.includes(id) ? prev.filter((q) => q !== id) : [...prev, id]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const payload = {
title,
description,
closed_at: closedAt || null,
category_id: Number(categoryId),
questions: selectedQuestions,
};
if (isUpdate && id) {
updateMutation.mutate(
{ id: Number(id), payload },
{ onSuccess: () => navigate("/dashboard/tests") }
);
} else {
createMutation.mutate(payload, {
onSuccess: () => navigate("/dashboard/tests"),
});
}
};
return (
<Container>
<Typography variant="h5" mb={3}>
{isUpdate ? "Update Test" : "Create Test"}
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
required
sx={{ mb: 2 }}
/>
<TextField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
fullWidth
multiline
rows={3}
sx={{ mb: 2 }}
/>
<TextField
type="datetime-local"
label="Closed At"
value={closedAt}
onChange={(e) => setClosedAt(e.target.value)}
fullWidth
InputLabelProps={{ shrink: true }}
sx={{ mb: 2 }}
/>
<TextField
select
label="Category"
value={categoryId}
onChange={(e) => {
setCategoryId(Number(e.target.value));
setQuestionsPage(1);
setSelectedQuestions([]);
}}
fullWidth
required
sx={{ mb: 3 }}
>
{categories?.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</TextField>
{categoryId && (
<>
<Typography variant="h6" mb={1}>
Questions
</Typography>
<Box sx={{ display: "flex", flexDirection: "column" }}>
{isLoadingQuestions ? (
<CircularProgress />
) : (
questions?.data.map((q: QuestionType) => (
<FormControlLabel
key={q.id}
control={
<Checkbox
checked={selectedQuestions.includes(q.id)}
onChange={() => toggleQuestion(q.id)}
/>
}
label={q.title}
/>
))
)}
</Box>
</>
)}
{!categoryId && (
<Typography variant="h6" mb={1}>
Please select category to be able to select questions
</Typography>
)}
<Pagination
color="primary"
shape="rounded"
page={questionsPage}
count={questions?.meta.last_page}
onChange={(_, page) => setQuestionsPage(page)}
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "start" }}
/>
<Button variant="contained" type="submit">
{isUpdate ? "Update Test" : "Create Test"}
</Button>
</form>
</Container>
);
};
export default TestForm;

View File

@ -0,0 +1,148 @@
import { useParams } from "react-router-dom";
import Container from "../../components/shared/Container";
import { useUserTestById } from "../../hooks/tests/useUserTestById";
import { useState } from "react";
import { Box, Button, Pagination, Typography } from "@mui/material";
import TestQuestion from "../../components/TestQuestion/TestQuestion";
import { useSubmitAnswer } from "../../hooks/tests/useSubmitAnswer";
import { useCompleteTest } from "../../hooks/tests/useCompleteTest";
import LearningAnswers from "../../components/Answers/Answers";
import { formatDate } from "../../utils/functions";
import NotFoundPage from "../NotFoundPage/NotFoundPage";
import { useAuth } from "../../context/AuthContext";
export const TestPage = () => {
const { id } = useParams();
const { user } = useAuth();
const { data: test, isLoading, error } = useUserTestById(Number(id));
const [currentQuestion, setCurrentQuestion] = useState(1);
const submitAnswerMutation = useSubmitAnswer();
const completeTestMutation = useCompleteTest();
const allAnswered = test?.answers?.every((ans) => ans.answer !== null);
const handleCompleteTest = () => {
if (test) completeTestMutation.mutate(test.id);
};
if (isLoading)
return (
<Container>
<Typography variant="h6" color="textSecondary">
Loading your test, please wait...
</Typography>
</Container>
);
if (error) return <NotFoundPage />;
if (!test) return <NotFoundPage />;
if (!test.is_available && !test.is_completed && user?.type == 'user')
return (
<Container>
<Typography variant="h6" color="textSecondary">
This test is no longer available.
</Typography>
</Container>
);
if (test.is_completed) {
return (
<Container>
<Typography variant="h2" sx={{ mb: 3 }}>
Results
</Typography>
<Typography variant="h5" color="secondary" sx={{ mb: 3 }}>
Your score: {test.score}%
</Typography>
{test?.answers?.map((ans) => (
<Box key={ans.question_id} sx={{ mb: 4 }}>
<Typography variant="h6">{ans.question.title}</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{ans.question.description}
</Typography>
<LearningAnswers
type={ans.question.type as "single" | "multiple" | "text"}
variants={ans.question.variants}
correctAnswers={ans.question.correct_answers}
userAnswers={ans.answer as number[]}
/>
</Box>
))}
</Container>
);
}
const questionsAndAnswers = test.answers;
const currentQA = questionsAndAnswers?.[currentQuestion - 1];
return (
<Container>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
mb: 4,
}}
>
<Typography variant="h2">{test.test?.title ?? "User Test"}</Typography>
<Typography variant="subtitle1">
Expires at: {test.closed_at ? formatDate(test.closed_at) : "N/A"}
</Typography>
</Box>
{currentQA ? (
<TestQuestion
qa={questionsAndAnswers[currentQuestion - 1]}
onSubmit={(ans) =>
submitAnswerMutation.mutate({
answerId: questionsAndAnswers[currentQuestion - 1].id,
answer: ans,
})
}
/>
) : (
<Typography>No question found.</Typography>
)}
<Box
sx={{
display: "flex",
justifyContent: "center",
mt: 2,
flexDirection: "column",
alignItems: "center",
}}
>
<Pagination
count={questionsAndAnswers?.length}
shape="rounded"
color="primary"
page={currentQuestion}
onChange={(_, page) => setCurrentQuestion(page)}
/>
{currentQuestion == questionsAndAnswers?.length && (
<Button
onClick={handleCompleteTest}
variant="contained"
color="success"
disabled={!allAnswered}
sx={{ mt: 15, fontSize: "32px" }}
>
Complete test
</Button>
)}
{currentQuestion == questionsAndAnswers?.length && !allAnswered && (
<Typography
variant="body2"
color="error"
sx={{ fontWeight: 500, textAlign: "center", mt: 2 }}
>
You must answer all questions before completing the test
</Typography>
)}
</Box>
</Container>
);
};

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/categories/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

@ -0,0 +1,19 @@
import { Box, Typography } from "@mui/material";
export default function Forbidden() {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
minHeight="80vh"
>
<Typography variant="h2" color="error" mb={2}>
403
</Typography>
<Typography variant="h5">Forbidden</Typography>
<Typography>You do not have access to this page.</Typography>
</Box>
);
}

View File

@ -0,0 +1,113 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
CircularProgress,
MenuItem,
TextField,
Typography,
} from "@mui/material";
import { useUserById } from "../../hooks/users/useUserById";
import { useCreateUser } from "../../hooks/users/useCreateUser";
import { useUpdateUser } from "../../hooks/users/useUpdateUser";
const UserForm = () => {
const { id } = useParams<{ id: string }>();
const isUpdate = Boolean(id);
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [type, setType] = useState("user");
const { data: user, isLoading: isLoadingUser } = useUserById(
id ? Number(id) : undefined
);
const createUserMutation = useCreateUser();
const updateUserMutation = useUpdateUser();
useEffect(() => {
if (user) {
setUsername(user.username);
setEmail(user.email);
setType(user.type);
}
}, [user]);
if (isUpdate && isLoadingUser) return <CircularProgress />;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const payload = { username, email, type, ...(password && { password }) };
if (isUpdate && id) {
updateUserMutation.mutate(
{ id: Number(id), payload },
{ onSuccess: () => navigate("/dashboard/users") }
);
} else {
createUserMutation.mutate(payload, {
onSuccess: () => navigate("/dashboard/users"),
});
}
};
return (
<Box sx={{ maxWidth: 500, mx: "auto", mt: 5 }}>
<Typography variant="h5" mb={3}>
{isUpdate ? "Update User" : "Create User"}
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
fullWidth
required
sx={{ mb: 2 }}
/>
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
fullWidth
required
sx={{ mb: 2 }}
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
sx={{ mb: 2 }}
placeholder={isUpdate ? "Leave blank to keep current password" : ""}
required={!isUpdate}
/>
<TextField
select
label="Type"
value={type}
onChange={(e) => setType(e.target.value)}
fullWidth
sx={{ mb: 3 }}
>
<MenuItem value="user">User</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="creator">Creator</MenuItem>
<MenuItem value="banned">Banned</MenuItem>
</TextField>
<Button variant="contained" color="primary" type="submit">
{isUpdate ? "Update" : "Create"}
</Button>
</form>
</Box>
);
};
export default UserForm;

View File

@ -0,0 +1,141 @@
import { useState } from "react";
import {
Box,
Button,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Pagination,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useUsers } from "../../hooks/users/useUsers";
import { useDeleteUser } from "../../hooks/users/useDeleteUser";
import Container from "../../components/shared/Container";
import { useNavigate } from "react-router-dom";
const UsersPage = () => {
const [currentPage, setCurrentPage] = useState(1);
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
const { data, isLoading, isError } = useUsers(currentPage);
const deleteMutation = useDeleteUser();
const navigate = useNavigate();
const handleCreateUser = () => {
navigate(`/dashboard/users/create`);
};
const handleUpdateUser = (id: number) => {
navigate(`/dashboard/users/${id}/update`);
};
const handleDeleteUser = () => {
if (deleteUserId !== null) {
deleteMutation.mutate(deleteUserId, {
onSuccess: () => setDeleteUserId(null),
});
}
};
if (isLoading) return <CircularProgress />;
if (isError)
return <Typography color="error">Error loading users.</Typography>;
return (
<Container>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h4">Users</Typography>
<Button variant="contained" color="primary" onClick={handleCreateUser}>
Create User
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Type</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.type}</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
variant="outlined"
color="primary"
size="small"
onClick={() => handleUpdateUser(user.id)}
sx={{ mr: 1 }}
>
Update
</Button>
<Button
variant="outlined"
color="error"
size="small"
onClick={() => setDeleteUserId(user.id)}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<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" }}
/>
<Dialog
open={deleteUserId !== null}
onClose={() => setDeleteUserId(null)}
>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete this user?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteUserId(null)}>Cancel</Button>
<Button
color="error"
onClick={handleDeleteUser}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default UsersPage;

View File

@ -9,6 +9,23 @@ import ActivateAccountPage from "../pages/ActivateAccountPage/ActivateAccountPag
import RequestResetPage from "../pages/RequestResetPage/RequestResetPage"; import RequestResetPage from "../pages/RequestResetPage/RequestResetPage";
import NotFoundPage from "../pages/NotFoundPage/NotFoundPage"; 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 { TestPage } from "../pages/TestPage/TestPage";
import TestsPage from "../pages/TestsPage/TestsPage";
import AdminLayout from "../layouts/AdminLayout/AdminLayout";
import UsersPage from "../pages/UsersPage/UsersPage";
import UserForm from "../pages/UserForm/UserForm";
import CategoriesPage from "../pages/CategoriesPage/CategoriesPage";
import CategoryForm from "../pages/CategoryForm/CategoryForm";
import LogsPage from "../pages/LogsPage/LogsPage";
import HitCountsPage from "../pages/HitcountsPage/HitcountsPage";
import UserTestsPage from "../pages/AdminUserTestsPage/AdminUserTestsPage";
import { SingleTestPage } from "../pages/SingleTestPage/SingleTestPage";
import AdminTestsPage from "../pages/AdminTestsPage/AdminTestsPage";
import TestForm from "../pages/TestForm/TestForm";
import QuestionsPage from "../pages/QuestionsPage/QuestionsPage";
import QuestionForm from "../pages/QuestionForm/QuestionForm";
import { AdminProtectedRoute } from "../components/ProtectedRoute/AdminProtectedRoute";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -47,12 +64,76 @@ const router = createBrowserRouter([
path: "/auth/reset-password/:token", path: "/auth/reset-password/:token",
element: <ResetPasswordPage />, element: <ResetPasswordPage />,
}, },
{
path: "/questions/:id",
element: <SingleQuestionPage />,
},
{
path: "/tests/:id",
element: (
<ProtectedRoute>
<TestPage />
</ProtectedRoute>
),
},
{ path: "/tests/view/:id", element: <SingleTestPage /> },
{
path: "/tests",
element: <TestsPage />,
},
{ {
path: "*", path: "*",
element: <NotFoundPage />, element: <NotFoundPage />,
}, },
], ],
}, },
{
path: "/dashboard",
element: (
<AdminProtectedRoute>
<AdminLayout />
</AdminProtectedRoute>
),
children: [
{ index: true, element: <QuestionsPage /> },
{
path: "users",
children: [
{ index: true, element: <UsersPage /> },
{ path: "create", element: <UserForm /> },
{ path: ":id/update", element: <UserForm /> },
],
},
{
path: "questions",
children: [
{ index: true, element: <QuestionsPage /> },
{ path: "create", element: <QuestionForm /> },
{ path: ":id/update", element: <QuestionForm /> },
],
},
{
path: "tests",
children: [
{ index: true, element: <AdminTestsPage /> },
{ path: "create", element: <TestForm /> },
{ path: ":id/update", element: <TestForm /> },
],
},
{ path: "user-tests", element: <UserTestsPage /> },
{
path: "categories",
children: [
{ index: true, element: <CategoriesPage /> },
{ path: "create", element: <CategoryForm /> },
{ path: ":id/update", element: <CategoryForm /> },
],
},
{ path: "logs", element: <LogsPage /> },
{ path: "hitcounts", element: <HitCountsPage /> },
],
},
]); ]);
export default router; export default router;

29
src/utils/functions.ts Normal file
View File

@ -0,0 +1,29 @@
export function arraysEqual(a: number[], b: number[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
a = [...a].sort((x, y) => x - y);
b = [...b].sort((x, y) => x - y);
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export const formatDate = (dateString: string | undefined | null) => {
if (!dateString) return "N/A";
const date = new Date(dateString);
const pad = (num: number) => num.toString().padStart(2, "0");
const day = pad(date.getDate());
const month = pad(date.getMonth() + 1);
const year = date.getFullYear();
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${day}/${month}/${year} ${hours}:${minutes}`;
};