Compare commits
No commits in common. "8d81c12fbee9409a8108348bdb17b04c62fab723" and "2b714e3a7811151be40112afbb91bd0b76f63ecf" have entirely different histories.
8d81c12fbe
...
2b714e3a78
@ -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>HoshiAI</title>
|
<title>test-ai</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@ -16,7 +16,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@ -3798,12 +3797,6 @@
|
|||||||
"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",
|
||||||
@ -3852,19 +3845,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,7 +6,6 @@ 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() {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
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,
|
baseURL: "http://127.0.0.1:8000",
|
||||||
timeout: 30 * 1000,
|
timeout: 30 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
BIN
src/assets/bg.jpg
Normal file
BIN
src/assets/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
BIN
src/assets/bg.png
Normal file
BIN
src/assets/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
src/assets/hoshi.jpg
Normal file
BIN
src/assets/hoshi.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@ -1,89 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
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 didn’t 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 didn’t 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;
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
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 (1–10)"
|
|
||||||
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;
|
|
||||||
@ -29,7 +29,7 @@ function Header() {
|
|||||||
setAnchorElNav(null);
|
setAnchorElNav(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToDashboard = () => navigate("/dashboard");
|
const goToAdmin = () => navigate("/admin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar sx={{ backgroundColor: "#4b2981" }} position="static">
|
<AppBar sx={{ backgroundColor: "#4b2981" }} position="static">
|
||||||
@ -95,27 +95,20 @@ function Header() {
|
|||||||
>
|
>
|
||||||
<Typography textAlign="center">Profile</Typography>
|
<Typography textAlign="center">Profile</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{(user.type === "admin" || user.type === "creator") && (
|
{user.type === "admin" && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleCloseNavMenu();
|
handleCloseNavMenu();
|
||||||
goToDashboard();
|
goToAdmin();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography textAlign="center">Dashboard</Typography>
|
<Typography textAlign="center">
|
||||||
|
Admin Dashboard
|
||||||
|
</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
handleCloseNavMenu();
|
|
||||||
navigate("/tests");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography textAlign="center">Tests</Typography>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -157,25 +150,16 @@ function Header() {
|
|||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Button>
|
</Button>
|
||||||
{(user.type === "admin" || user.type === "creator") && (
|
{user.type === "admin" && (
|
||||||
<Button
|
<Button
|
||||||
onClick={goToDashboard}
|
onClick={goToAdmin}
|
||||||
sx={{ my: 2, color: "white", display: "block" }}
|
sx={{ my: 2, color: "white", display: "block" }}
|
||||||
>
|
>
|
||||||
Dashboard
|
Admin Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
sx={{ my: 2, color: "white", display: "block" }}
|
|
||||||
onClick={() => {
|
|
||||||
handleCloseNavMenu();
|
|
||||||
navigate("/tests");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tests
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
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}</>;
|
|
||||||
};
|
|
||||||
@ -1,33 +1,25 @@
|
|||||||
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(Link)({
|
export const QuestionWrapper = styled("div")({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "15px",
|
padding: "15px",
|
||||||
border: "1px solid #ccc",
|
border: "3px solid #4B2981",
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
marginBottom: "10px",
|
|
||||||
textDecoration: "none",
|
|
||||||
color: "inherit",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const QuestionTitle = styled(Box)({
|
export const QuestionTitle = styled("div")({
|
||||||
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")({
|
||||||
|
|||||||
@ -7,15 +7,22 @@ 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";
|
|
||||||
|
|
||||||
interface QuestionProps {
|
type Question = {
|
||||||
question: QuestionType;
|
title: string;
|
||||||
}
|
category: string;
|
||||||
|
description: string;
|
||||||
|
difficulty: number;
|
||||||
|
author: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuestionProps = {
|
||||||
|
question: Question;
|
||||||
|
};
|
||||||
|
|
||||||
const Question = ({ question }: QuestionProps) => {
|
const Question = ({ question }: QuestionProps) => {
|
||||||
return (
|
return (
|
||||||
<QuestionWrapper to={`/questions/${question.id}`}>
|
<QuestionWrapper>
|
||||||
<QuestionTitle>
|
<QuestionTitle>
|
||||||
<Typography sx={{ mr: "10px" }} variant="h5">
|
<Typography sx={{ mr: "10px" }} variant="h5">
|
||||||
{question.title}
|
{question.title}
|
||||||
@ -24,20 +31,17 @@ const Question = ({ question }: QuestionProps) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
icon={<CategoryIcon />}
|
icon={<CategoryIcon />}
|
||||||
color="primary"
|
color="primary"
|
||||||
label={question.category.name}
|
label={`${question.category}`}
|
||||||
/>
|
/>
|
||||||
</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.username}</Typography>
|
<Typography variant="body1">{question.author}</Typography>
|
||||||
</AuthorMeta>
|
</AuthorMeta>
|
||||||
</QuestionMetadata>
|
</QuestionMetadata>
|
||||||
</QuestionWrapper>
|
</QuestionWrapper>
|
||||||
|
|||||||
@ -9,92 +9,49 @@ 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 { useCategories } from "../../hooks/categories/useCategories";
|
import { notifyError, notifySuccess } from "../shared/toastify";
|
||||||
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{
|
||||||
toast.error("Min difficulty should be smaller than max difficulty!");
|
notifyError("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{
|
||||||
toast.error("Max difficulty should be bigger than min difficulty!");
|
notifyError("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)}
|
onChange={(e) => setCategory(e.target.value as string)}
|
||||||
>
|
>
|
||||||
{isLoading && <MenuItem disabled>Loading...</MenuItem>}
|
<MenuItem value={10}>Physics</MenuItem>
|
||||||
{isError && <MenuItem disabled>Error loading categories</MenuItem>}
|
<MenuItem value={20}>Microcontrollers</MenuItem>
|
||||||
{categories?.map((cat: Category) => (
|
<MenuItem value={30}>English</MenuItem>
|
||||||
<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}
|
||||||
@ -112,7 +69,6 @@ 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}
|
||||||
@ -127,16 +83,10 @@ 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={handleStartTest} variant="contained">
|
<Button onClick={() => notifySuccess("Started")} variant="contained">Start Test</Button>
|
||||||
Start Test
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Random category and difficulty test.">
|
<Tooltip title="Random category and difficulty test.">
|
||||||
<Button
|
<Button sx={{ backgroundColor: "#6610F2" }} variant="contained">
|
||||||
onClick={handleFeelingLucky}
|
|
||||||
sx={{ backgroundColor: "#6610F2" }}
|
|
||||||
variant="contained"
|
|
||||||
>
|
|
||||||
Im Feeling Lucky
|
Im Feeling Lucky
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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;
|
|
||||||
4
src/components/shared/toastify.ts
Normal file
4
src/components/shared/toastify.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const notifySuccess = (msg: string) => toast.success(msg);
|
||||||
|
export const notifyError = (msg: string) => toast.error(msg);
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -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/auth/useCurrentUser";
|
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||||
import { useLogin } from "../hooks/auth/useLogin";
|
import { useLogin } from "../hooks/useLogin";
|
||||||
import type {
|
import type {
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getCategories } from "../../api/categoryApi";
|
|
||||||
|
|
||||||
export const useCategories = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["categories"],
|
|
||||||
queryFn: getCategories,
|
|
||||||
});
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getHitCounts } from "../../api/metricsService";
|
|
||||||
|
|
||||||
export const useHitcounts = (page: number) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["hitcounts", page],
|
|
||||||
queryFn: () => getHitCounts(page),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getLogs } from "../../api/logsApi";
|
|
||||||
|
|
||||||
export const useLogs = (page: number) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["logs", page],
|
|
||||||
queryFn: () => getLogs(page),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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")
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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),
|
|
||||||
});
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getUserTests } from "../../api/testApi";
|
|
||||||
|
|
||||||
export const useGetUserTests = () => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user-tests"],
|
|
||||||
queryFn: () => getUserTests(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getUserTestById } from "../../api/testApi";
|
|
||||||
|
|
||||||
export const useUserTestById = (id: number) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["current-test"],
|
|
||||||
queryFn: () => getUserTestById(id),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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: activateAccount,
|
mutationFn: (token: string) => activateAccount(token),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
@ -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({
|
||||||
@ -1,12 +1,13 @@
|
|||||||
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: loginRequest,
|
mutationFn: (payload: LoginPayload) => loginRequest(payload),
|
||||||
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
localStorage.setItem("access_token", data.access_token);
|
localStorage.setItem("access_token", data.access_token);
|
||||||
@ -1,12 +1,19 @@
|
|||||||
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: registrationRequest,
|
mutationFn: ({
|
||||||
|
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");
|
||||||
@ -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: requestPasswordReset,
|
mutationFn: (email: string) => requestPasswordReset(email),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
},
|
},
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { resetPassword } from "../../api/authApi";
|
import type { ResetPasswordPayload } from "../components/shared/types/AuthTypes";
|
||||||
|
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: resetPassword,
|
mutationFn: (data: ResetPasswordPayload) => resetPassword(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
},
|
},
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -2,7 +2,6 @@ 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 (
|
||||||
@ -10,7 +9,6 @@ const MainLayout = () => {
|
|||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
<Header />
|
<Header />
|
||||||
<StyledDistance />
|
<StyledDistance />
|
||||||
<Cholecounter/>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</LayoutWrapper>
|
</LayoutWrapper>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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/auth/useActivateAccount";
|
import { useActivateAccount } from "../../hooks/useActivateAccount";
|
||||||
import { CircularProgress, Box, Typography } from "@mui/material";
|
import { CircularProgress, Box, Typography } from "@mui/material";
|
||||||
|
|
||||||
const ActivateAccountPage = () => {
|
const ActivateAccountPage = () => {
|
||||||
|
|||||||
@ -1,166 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -7,15 +7,11 @@ 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")({
|
||||||
|
|||||||
@ -1,101 +1,29 @@
|
|||||||
import {
|
import { Typography } from "@mui/material";
|
||||||
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 { data: categories } = useCategories();
|
const myQuestion = {
|
||||||
const { user } = useAuth();
|
title: "Example Question",
|
||||||
const [selectedCategory, setSelectedCategory] = useState<number | "all">(
|
category: "Math",
|
||||||
"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.",
|
||||||
);
|
difficulty: 3,
|
||||||
const questionsQuery = useQuestions({
|
author: "David"
|
||||||
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 align="center" variant="h2">Welcome To HoshiAI!</Typography>
|
<Typography variant="h2">Welcome To HoshiAI!</Typography>
|
||||||
<Typography variant="subtitle1">The best place to learn!</Typography>
|
<Typography variant="subtitle1">The best place to learn!</Typography>
|
||||||
</WelcomeContainer>
|
</WelcomeContainer>
|
||||||
|
<StartTestForm />
|
||||||
{user && (
|
<Typography sx={{mb:"36px"}} variant="h3">Browse Questions</Typography>
|
||||||
<>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,25 +1,11 @@
|
|||||||
import { Box, Button, Tab, Tabs, Typography } from "@mui/material";
|
import { Box, Button, 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>
|
||||||
@ -41,52 +27,15 @@ 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("/dashboard")}
|
onClick={() => navigate("/admin")}
|
||||||
>
|
>
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,287 +0,0 @@
|
|||||||
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;
|
|
||||||
0
src/pages/QuestionsPage/QuestionsPage.styles.ts
Normal file
0
src/pages/QuestionsPage/QuestionsPage.styles.ts
Normal file
@ -1,168 +1,10 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
||||||
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 = () => {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
return(
|
||||||
const [deleteQuestionId, setDeleteQuestionId] = useState<number | null>(null);
|
<StartTestForm/>
|
||||||
const { data, isLoading, isError } = useQuestions({ page: currentPage });
|
)
|
||||||
const deleteMutation = useDeleteQuestion();
|
}
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
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;
|
export default QuestionsPage;
|
||||||
@ -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/auth/useRegistration";
|
import { useRegistration } from "../../hooks/useRegistration";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const RegistrationPage = () => {
|
const RegistrationPage = () => {
|
||||||
|
|||||||
@ -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/auth/useRequestPasswordReset";
|
import { useRequestPasswordReset } from "../../hooks/useRequestPasswordReset";
|
||||||
|
|
||||||
const RequestResetPage = () => {
|
const RequestResetPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|||||||
@ -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/auth/useResetPassword";
|
import { useResetPassword } from "../../hooks/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";
|
||||||
|
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -9,23 +9,6 @@ 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([
|
||||||
{
|
{
|
||||||
@ -64,76 +47,12 @@ 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;
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
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}`;
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user