Compare commits
10 Commits
2b714e3a78
...
8d81c12fbe
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d81c12fbe | |||
| 00d49635d8 | |||
| bf919daaf8 | |||
| 69382f2f4c | |||
| b7313488a4 | |||
| 4ee508f01b | |||
| 9121f49a9b | |||
| 6d9fa9061d | |||
| cba24b05c9 | |||
| 01446071e5 |
@ -10,7 +10,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>test-ai</title>
|
<title>HoshiAI</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"react-toastify": "^11.0.5"
|
"react-toastify": "^11.0.5"
|
||||||
},
|
},
|
||||||
@ -3797,6 +3798,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qr.js": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@ -3845,6 +3852,19 @@
|
|||||||
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-qr-code": {
|
||||||
|
"version": "2.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
|
||||||
|
"integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"qr.js": "0.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"react-toastify": "^11.0.5"
|
"react-toastify": "^11.0.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { ToastContainer } from "react-toastify";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
const baseURL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
baseURL: "http://127.0.0.1:8000",
|
baseURL,
|
||||||
timeout: 30 * 1000,
|
timeout: 30 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
32
src/api/categoryApi.ts
Normal file
32
src/api/categoryApi.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { CategoryType } from "../components/shared/types/TestTypes";
|
||||||
|
import axiosInstance from "./axiosInstance";
|
||||||
|
|
||||||
|
interface CategoriesResponse {
|
||||||
|
data: CategoryType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCategories = async () => {
|
||||||
|
const res = await axiosInstance.get<CategoriesResponse>("/api/categories");
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategoryById = async (id: number) => {
|
||||||
|
const res = await axiosInstance.get<{data:CategoryType}>(`/api/categories/${id}`);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCategory = async (name: string) => {
|
||||||
|
const res = await axiosInstance.post(`/api/categories`, { name });
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCategory = async (id: number, name: string) => {
|
||||||
|
const res = await axiosInstance.put(`/api/categories/${id}`, { name });
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCategory = async (id: number) => {
|
||||||
|
const res = await axiosInstance.delete(`/api/categories/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
24
src/api/logsApi.ts
Normal file
24
src/api/logsApi.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import axiosInstance from "./axiosInstance";
|
||||||
|
|
||||||
|
export type LogType = {
|
||||||
|
id: number;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LogsResponse {
|
||||||
|
data: LogType[];
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLogs = async (page = 1) => {
|
||||||
|
const res = await axiosInstance.get<LogsResponse>("/api/logs", {
|
||||||
|
params: { page },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
39
src/api/metricsService.ts
Normal file
39
src/api/metricsService.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import axiosInstance from "./axiosInstance";
|
||||||
|
|
||||||
|
export const hitApi = async (url: string): Promise<void> => {
|
||||||
|
await axiosInstance.post("/api/hit", { url });
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HitCountType {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
device_type: string;
|
||||||
|
user_agent: string;
|
||||||
|
country: string | null;
|
||||||
|
url: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HitCountsResponse {
|
||||||
|
data: HitCountType[];
|
||||||
|
links: {
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
prev: string | null;
|
||||||
|
next: string | null;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHitCounts = async (page = 1) => {
|
||||||
|
const res = await axiosInstance.get<HitCountsResponse>("/api/hitcounts", {
|
||||||
|
params: { page },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
69
src/api/questionsApi.ts
Normal file
69
src/api/questionsApi.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import type { Variant } from "../components/shared/types/QuestionTypes";
|
||||||
|
import axiosInstance from "./axiosInstance";
|
||||||
|
|
||||||
|
type GetQuestionsParams = {
|
||||||
|
page?: number;
|
||||||
|
id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuestionPayload = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: "single" | "multiple" | "text";
|
||||||
|
difficulty: number;
|
||||||
|
variants: Variant[];
|
||||||
|
correct_answers: number[] | string[];
|
||||||
|
category_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenaiPayload = {
|
||||||
|
type: "single" | "multiple" | "text";
|
||||||
|
category_id: number;
|
||||||
|
language: string;
|
||||||
|
difficulty: number;
|
||||||
|
promt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenaiGenerateResponse = {
|
||||||
|
question: QuestionPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQuestions = async ({ page = 1, id }: GetQuestionsParams) => {
|
||||||
|
const params: Record<string, any> = { page };
|
||||||
|
if (id) params.category_id = id;
|
||||||
|
|
||||||
|
const { data } = await axiosInstance.get("/api/questions", { params });
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQuestionById = async (id: number) => {
|
||||||
|
const response = await axiosInstance.get(`/api/questions/${id}`);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createQuestion = async (payload: QuestionPayload) => {
|
||||||
|
const res = await axiosInstance.post("/api/questions", payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateQuestion = async (id: number, payload: QuestionPayload) => {
|
||||||
|
const res = await axiosInstance.put(`/api/questions/${id}`, payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteQuestion = async (id: number) => {
|
||||||
|
const res = await axiosInstance.delete(`/api/questions/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openaiGenerateQuestion = async (
|
||||||
|
payload: OpenaiPayload
|
||||||
|
): Promise<QuestionPayload> => {
|
||||||
|
const res = await axiosInstance.post<OpenaiGenerateResponse>(
|
||||||
|
'/api/questions/openai-generate',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.data.question;
|
||||||
|
};
|
||||||
129
src/api/testApi.ts
Normal file
129
src/api/testApi.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
type TestType,
|
||||||
|
type PaginatedTests,
|
||||||
|
type StartTestPayload,
|
||||||
|
type SubmitAnswerPayload,
|
||||||
|
type UserTestType,
|
||||||
|
} from "../components/shared/types/TestTypes";
|
||||||
|
import axiosInstance from "./axiosInstance";
|
||||||
|
|
||||||
|
export type UserTestsResponse = {
|
||||||
|
data: UserTestType[];
|
||||||
|
links: {
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
prev: string | null;
|
||||||
|
next: string | null;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
from: number | null;
|
||||||
|
last_page: number;
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
to: number | null;
|
||||||
|
total: number;
|
||||||
|
links: {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestPayload = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
closed_at: string | null;
|
||||||
|
category_id: number;
|
||||||
|
questions: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startTest = async (data: StartTestPayload) => {
|
||||||
|
const res = await axiosInstance.post<{ data: UserTestType }>(
|
||||||
|
"/api/user-tests",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startTestById = async (id: number) => {
|
||||||
|
const res = await axiosInstance.post<{ data: UserTestType }>(
|
||||||
|
"/api/user-tests/by-test",
|
||||||
|
{ test_id: id }
|
||||||
|
);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeUserTest = async (userTestId: number) => {
|
||||||
|
const res = await axiosInstance.post<{ message: string }>(
|
||||||
|
`/api/user-tests/${userTestId}/complete`
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserTestById = async (userTestId: number) => {
|
||||||
|
const res = await axiosInstance.get<{ data: UserTestType }>(
|
||||||
|
`/api/user-tests/${userTestId}`
|
||||||
|
);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserTests = async () => {
|
||||||
|
const res = await axiosInstance.get<{ data: UserTestType[] }>(
|
||||||
|
"/api/user-tests/me"
|
||||||
|
);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitAnswer = async (data: SubmitAnswerPayload) => {
|
||||||
|
const res = await axiosInstance.post<{ message: string }>(
|
||||||
|
`/api/user-test-answers/${data.answerId}/submit`,
|
||||||
|
{ answer: data.answer }
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTests = async (categoryId?: number, page = 1) => {
|
||||||
|
const params: Record<string, number> = { page };
|
||||||
|
if (categoryId !== undefined) params.category_id = categoryId;
|
||||||
|
|
||||||
|
const res = await axiosInstance.get<PaginatedTests>("/api/tests", { params });
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTestById = async (id: number) => {
|
||||||
|
const res = await axiosInstance.get<{ data: TestType }>(`/api/tests/${id}`);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllUserTests = async (
|
||||||
|
page = 1,
|
||||||
|
test_id?: number,
|
||||||
|
question_id?: number
|
||||||
|
) => {
|
||||||
|
const res = await axiosInstance.get<UserTestsResponse>("/api/user-tests", {
|
||||||
|
params: { page, test_id, question_id },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserTest = async (id: number) => {
|
||||||
|
const res = await axiosInstance.delete(`/api/user-tests/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTest = async (payload: TestPayload) => {
|
||||||
|
const res = await axiosInstance.post("/api/tests", payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTest = async (id: number, payload: TestPayload) => {
|
||||||
|
const res = await axiosInstance.put(`/api/tests/${id}`, payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTest = async (id: number) => {
|
||||||
|
const res = await axiosInstance.delete(`/api/tests/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
59
src/api/usersApi.ts
Normal file
59
src/api/usersApi.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import type { User } from "../components/shared/types/AuthTypes";
|
||||||
|
import axiosInstance from "./axiosInstance";
|
||||||
|
|
||||||
|
export type UsersResponse = {
|
||||||
|
data: User[];
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserPayload {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUsers = async (page = 1) => {
|
||||||
|
const res = await axiosInstance.get<UsersResponse>("/api/users", {
|
||||||
|
params: { page },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserById = async (id: number) => {
|
||||||
|
const res = await axiosInstance.get(`/api/users/${id}`);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (id: number) => {
|
||||||
|
const res = await axiosInstance.delete(`/api/users/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = async (payload: UserPayload) => {
|
||||||
|
const res = await axiosInstance.post<User>("/api/users",{
|
||||||
|
username: payload.username,
|
||||||
|
email: payload.email,
|
||||||
|
email_verified_at: "2025-11-12T10:13:48.000000Z",
|
||||||
|
password: payload.password,
|
||||||
|
type: payload.type,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (id: number, payload: UserPayload) => {
|
||||||
|
const res = await axiosInstance.put<User>(`/api/users/${id}`, {
|
||||||
|
username: payload.username,
|
||||||
|
email: payload.email,
|
||||||
|
password: payload.password,
|
||||||
|
type: payload.type,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 310 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
89
src/components/AdminSidebar/AdminSidebar.tsx
Normal file
89
src/components/AdminSidebar/AdminSidebar.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
import PeopleIcon from "@mui/icons-material/People";
|
||||||
|
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||||
|
import QuizIcon from "@mui/icons-material/Quiz";
|
||||||
|
import CategoryIcon from "@mui/icons-material/Category";
|
||||||
|
import ArticleIcon from "@mui/icons-material/Article";
|
||||||
|
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
|
||||||
|
import FmdBadIcon from "@mui/icons-material/FmdBad";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
type MenuItem = {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawerWidth = 240;
|
||||||
|
|
||||||
|
const adminMenu: MenuItem[] = [
|
||||||
|
{ label: "Users", icon: <PeopleIcon />, to: "/dashboard/users" },
|
||||||
|
{ label: "Questions", icon: <HelpOutlineIcon />, to: "/dashboard/questions" },
|
||||||
|
{ label: "Tests", icon: <QuizIcon />, to: "/dashboard/tests" },
|
||||||
|
{
|
||||||
|
label: "User Tests",
|
||||||
|
icon: <AssignmentIndIcon />,
|
||||||
|
to: "/dashboard/user-tests",
|
||||||
|
},
|
||||||
|
{ label: "Categories", icon: <CategoryIcon />, to: "/dashboard/categories" },
|
||||||
|
{ label: "Hitcounts", icon: <FmdBadIcon />, to: "/dashboard/hitcounts" },
|
||||||
|
{ label: "Logs", icon: <ArticleIcon />, to: "/dashboard/logs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const creatorMenu: MenuItem[] = [
|
||||||
|
{ label: "Questions", icon: <HelpOutlineIcon />, to: "/dashboard/questions" },
|
||||||
|
{ label: "Tests", icon: <QuizIcon />, to: "/dashboard/tests" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AdminSidebar = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const menuItems =
|
||||||
|
user?.type === "admin"
|
||||||
|
? adminMenu
|
||||||
|
: user?.type === "creator"
|
||||||
|
? creatorMenu
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: drawerWidth,
|
||||||
|
flexShrink: 0,
|
||||||
|
|
||||||
|
[`& .MuiDrawer-paper`]: {
|
||||||
|
width: drawerWidth,
|
||||||
|
top: 70,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.label}
|
||||||
|
component={NavLink}
|
||||||
|
to={item.to}
|
||||||
|
sx={{
|
||||||
|
"&.active": {
|
||||||
|
bgcolor: "action.selected",
|
||||||
|
"& .MuiListItemIcon-root": {
|
||||||
|
color: "primary.main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
142
src/components/Answers/Answers.tsx
Normal file
142
src/components/Answers/Answers.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Box, Button, Typography } from "@mui/material";
|
||||||
|
import { arraysEqual } from "../../utils/functions";
|
||||||
|
|
||||||
|
interface Variant {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LearningAnswersProps {
|
||||||
|
variants?: Variant[];
|
||||||
|
correctAnswers: number[] | string[];
|
||||||
|
type: "single" | "multiple" | "text";
|
||||||
|
userAnswers?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LearningAnswers = ({
|
||||||
|
variants = [],
|
||||||
|
correctAnswers = [],
|
||||||
|
type,
|
||||||
|
userAnswers,
|
||||||
|
}: LearningAnswersProps) => {
|
||||||
|
const [showAnswer, setShowAnswer] = useState(false);
|
||||||
|
|
||||||
|
const hasAnswered = userAnswers !== null && userAnswers !== undefined;
|
||||||
|
const isEmptyAnswer = hasAnswered && userAnswers.length === 0;
|
||||||
|
const isCorrect = hasAnswered
|
||||||
|
? arraysEqual(correctAnswers as number[], userAnswers as number[])
|
||||||
|
: false;
|
||||||
|
const normalizedUserAnswers = userAnswers ?? [];
|
||||||
|
|
||||||
|
const getUserAnswerText = () => {
|
||||||
|
if (!normalizedUserAnswers.length) return "";
|
||||||
|
const selectedVariants = variants.filter((v) =>
|
||||||
|
normalizedUserAnswers.includes(v.id)
|
||||||
|
);
|
||||||
|
return selectedVariants.map((v) => v.text).join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
{hasAnswered ? (
|
||||||
|
isEmptyAnswer ? (
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
You 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;
|
||||||
15
src/components/Cholecounter/Cholecounter.tsx
Normal file
15
src/components/Cholecounter/Cholecounter.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { hitApi } from "../../api/metricsService";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
const Cholecounter = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hitApi(window.location.href);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Cholecounter;
|
||||||
150
src/components/GenerateQuestionModal/GenerateQuestionModal.tsx
Normal file
150
src/components/GenerateQuestionModal/GenerateQuestionModal.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
CircularProgress,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { useOpenaiGenerateQuestion } from "../../hooks/questions/useOpenaiGenerateQuestion";
|
||||||
|
import type { QuestionPayload } from "../../api/questionsApi";
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onGenerated: (question: QuestionPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenerateQuestionModal = ({ open, onClose, onGenerated }: Props) => {
|
||||||
|
const generateMutation = useOpenaiGenerateQuestion();
|
||||||
|
|
||||||
|
const [type, setType] = useState<"single" | "multiple" | "text">("single");
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [language, setLanguage] = useState<"en" | "sr" | "hu" | "ru">("en");
|
||||||
|
const [difficulty, setDifficulty] = useState(5);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
if (!categoryId || !prompt.trim()) return;
|
||||||
|
|
||||||
|
generateMutation.mutate(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
category_id: Number(categoryId),
|
||||||
|
language,
|
||||||
|
difficulty,
|
||||||
|
promt: prompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
onGenerated(data);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Generate Question (AI)</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setType(e.target.value as "single" | "multiple" | "text")
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="single">Single</MenuItem>
|
||||||
|
<MenuItem value="multiple">Multiple</MenuItem>
|
||||||
|
<MenuItem value="text">Text</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* <TextField
|
||||||
|
label="Category ID"
|
||||||
|
type="number"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/> */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Language"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLanguage(e.target.value as "en" | "sr" | "hu" | "ru")
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="sr">Serbian</MenuItem>
|
||||||
|
<MenuItem value="hu">Hungarian</MenuItem>
|
||||||
|
<MenuItem value="ru">Russian</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Difficulty (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 goToAdmin = () => navigate("/admin");
|
const goToDashboard = () => navigate("/dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar sx={{ backgroundColor: "#4b2981" }} position="static">
|
<AppBar sx={{ backgroundColor: "#4b2981" }} position="static">
|
||||||
@ -95,20 +95,27 @@ function Header() {
|
|||||||
>
|
>
|
||||||
<Typography textAlign="center">Profile</Typography>
|
<Typography textAlign="center">Profile</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{user.type === "admin" && (
|
{(user.type === "admin" || user.type === "creator") && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleCloseNavMenu();
|
handleCloseNavMenu();
|
||||||
goToAdmin();
|
goToDashboard();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography textAlign="center">
|
<Typography textAlign="center">Dashboard</Typography>
|
||||||
Admin Dashboard
|
|
||||||
</Typography>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseNavMenu();
|
||||||
|
navigate("/tests");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography textAlign="center">Tests</Typography>
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -150,16 +157,25 @@ function Header() {
|
|||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Button>
|
</Button>
|
||||||
{user.type === "admin" && (
|
{(user.type === "admin" || user.type === "creator") && (
|
||||||
<Button
|
<Button
|
||||||
onClick={goToAdmin}
|
onClick={goToDashboard}
|
||||||
sx={{ my: 2, color: "white", display: "block" }}
|
sx={{ my: 2, color: "white", display: "block" }}
|
||||||
>
|
>
|
||||||
Admin Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
sx={{ my: 2, color: "white", display: "block" }}
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseNavMenu();
|
||||||
|
navigate("/tests");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tests
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
18
src/components/ProtectedRoute/AdminProtectedRoute.tsx
Normal file
18
src/components/ProtectedRoute/AdminProtectedRoute.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import Container from "../shared/Container";
|
||||||
|
import Forbidden from "../../pages/Unauthorized/Forbidden";
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) return <Container>Loading...</Container>;
|
||||||
|
|
||||||
|
if (user?.type === "user" || user?.type === "banned") return <Forbidden />;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@ -1,25 +1,33 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export const QuestionWrapper = styled("div")({
|
export const QuestionWrapper = styled(Link)({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "15px",
|
padding: "15px",
|
||||||
border: "3px solid #4B2981",
|
border: "1px solid #ccc",
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
|
marginBottom: "10px",
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const QuestionTitle = styled("div")({
|
export const QuestionTitle = styled(Box)({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: "10px"
|
marginBottom: "10px",
|
||||||
|
"@media (max-width:600px)": {
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const QuestionMetadata = styled("div")({
|
export const QuestionMetadata = styled("div")({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
color: "#4c4c4c",
|
color: "#4c4c4c",
|
||||||
marginTop: "10px"
|
marginTop: "10px",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AuthorMeta = styled("div")({
|
export const AuthorMeta = styled("div")({
|
||||||
|
|||||||
@ -7,22 +7,15 @@ import {
|
|||||||
} from "./Question.styles";
|
} from "./Question.styles";
|
||||||
import CategoryIcon from "@mui/icons-material/Category";
|
import CategoryIcon from "@mui/icons-material/Category";
|
||||||
import PersonIcon from "@mui/icons-material/Person";
|
import PersonIcon from "@mui/icons-material/Person";
|
||||||
|
import type { QuestionType } from "../shared/types/QuestionTypes";
|
||||||
|
|
||||||
type Question = {
|
interface QuestionProps {
|
||||||
title: string;
|
question: QuestionType;
|
||||||
category: string;
|
}
|
||||||
description: string;
|
|
||||||
difficulty: number;
|
|
||||||
author: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QuestionProps = {
|
|
||||||
question: Question;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Question = ({ question }: QuestionProps) => {
|
const Question = ({ question }: QuestionProps) => {
|
||||||
return (
|
return (
|
||||||
<QuestionWrapper>
|
<QuestionWrapper to={`/questions/${question.id}`}>
|
||||||
<QuestionTitle>
|
<QuestionTitle>
|
||||||
<Typography sx={{ mr: "10px" }} variant="h5">
|
<Typography sx={{ mr: "10px" }} variant="h5">
|
||||||
{question.title}
|
{question.title}
|
||||||
@ -31,17 +24,20 @@ const Question = ({ question }: QuestionProps) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
icon={<CategoryIcon />}
|
icon={<CategoryIcon />}
|
||||||
color="primary"
|
color="primary"
|
||||||
label={`${question.category}`}
|
label={question.category.name}
|
||||||
/>
|
/>
|
||||||
</QuestionTitle>
|
</QuestionTitle>
|
||||||
|
|
||||||
<Typography variant="body1">{question.description}</Typography>
|
<Typography variant="body1">{question.description}</Typography>
|
||||||
|
|
||||||
<QuestionMetadata>
|
<QuestionMetadata>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
Difficulty: {question.difficulty}
|
Difficulty: {question.difficulty}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<AuthorMeta>
|
<AuthorMeta>
|
||||||
<PersonIcon />
|
<PersonIcon />
|
||||||
<Typography variant="body1">{question.author}</Typography>
|
<Typography variant="body1">{question.author.username}</Typography>
|
||||||
</AuthorMeta>
|
</AuthorMeta>
|
||||||
</QuestionMetadata>
|
</QuestionMetadata>
|
||||||
</QuestionWrapper>
|
</QuestionWrapper>
|
||||||
|
|||||||
@ -9,49 +9,92 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { StartTestWrapper } from "./StartTestForm.styles";
|
import { StartTestWrapper } from "./StartTestForm.styles";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { notifyError, notifySuccess } from "../shared/toastify";
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
import type { Category } from "../shared/types/QuestionTypes";
|
||||||
|
import { useStartTest } from "../../hooks/tests/useStartTest";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const StartTestForm = () => {
|
const StartTestForm = () => {
|
||||||
const [category, setCategory] = useState("");
|
const [category, setCategory] = useState("");
|
||||||
const [minDifficulty, setMinDifficulty] = useState(1);
|
const [minDifficulty, setMinDifficulty] = useState(1);
|
||||||
const [maxDifficulty, setMaxDifficulty] = useState(10);
|
const [maxDifficulty, setMaxDifficulty] = useState(10);
|
||||||
|
const { data: categories, isLoading, isError } = useCategories();
|
||||||
|
const startTestMutation = useStartTest();
|
||||||
|
|
||||||
|
const handleStartTest = () => {
|
||||||
|
if (!category) {
|
||||||
|
toast.error("Please select a category!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTestMutation.mutate({
|
||||||
|
category_id: Number(category),
|
||||||
|
min_difficulty: minDifficulty,
|
||||||
|
max_difficulty: maxDifficulty,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFeelingLucky = () => {
|
||||||
|
if (!categories || categories.length === 0) {
|
||||||
|
toast.error("No categories available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomCategory =
|
||||||
|
categories[Math.floor(Math.random() * categories.length)];
|
||||||
|
|
||||||
|
const minDifficulty = 1;
|
||||||
|
const maxDifficulty = 10;
|
||||||
|
|
||||||
|
startTestMutation.mutate({
|
||||||
|
category_id: randomCategory.id,
|
||||||
|
min_difficulty: minDifficulty,
|
||||||
|
max_difficulty: maxDifficulty,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangeMinDifficulty = (e: SelectChangeEvent<number>) => {
|
const handleChangeMinDifficulty = (e: SelectChangeEvent<number>) => {
|
||||||
const value = Number(e.target.value);
|
const value = Number(e.target.value);
|
||||||
if (value <= maxDifficulty) {
|
if (value <= maxDifficulty) {
|
||||||
setMinDifficulty(value);
|
setMinDifficulty(value);
|
||||||
} else {
|
} else {
|
||||||
notifyError("Min difficulty should be smaller than max difficulty!")
|
toast.error("Min difficulty should be smaller than max difficulty!");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangeMaxDifficulty = (e: SelectChangeEvent<number>) => {
|
const handleChangeMaxDifficulty = (e: SelectChangeEvent<number>) => {
|
||||||
const value = Number(e.target.value);
|
const value = Number(e.target.value);
|
||||||
if (value >= minDifficulty) {
|
if (value >= minDifficulty) {
|
||||||
setMaxDifficulty(value);
|
setMaxDifficulty(value);
|
||||||
} else {
|
} else {
|
||||||
notifyError("Max difficulty should be bigger than min difficulty!")
|
toast.error("Max difficulty should be bigger than min difficulty!");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<StartTestWrapper>
|
<StartTestWrapper>
|
||||||
<FormControl sx={{ minWidth: "300px" }} variant="filled">
|
<FormControl sx={{ minWidth: "300px" }} variant="filled">
|
||||||
<InputLabel id="category-select-label">Category</InputLabel>
|
<InputLabel id="category-select-label">Category</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
|
sx={{ maxWidth: "270px" }}
|
||||||
labelId="category-select-label"
|
labelId="category-select-label"
|
||||||
id="category-select"
|
id="category-select"
|
||||||
value={category}
|
value={category}
|
||||||
label="Category"
|
label="Category"
|
||||||
onChange={(e) => setCategory(e.target.value as string)}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
>
|
>
|
||||||
<MenuItem value={10}>Physics</MenuItem>
|
{isLoading && <MenuItem disabled>Loading...</MenuItem>}
|
||||||
<MenuItem value={20}>Microcontrollers</MenuItem>
|
{isError && <MenuItem disabled>Error loading categories</MenuItem>}
|
||||||
<MenuItem value={30}>English</MenuItem>
|
{categories?.map((cat: Category) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl sx={{ minWidth: "200px" }} variant="filled">
|
<FormControl sx={{ minWidth: "200px" }} variant="filled">
|
||||||
<InputLabel id="min-difficulty-select-label">Min Difficulty</InputLabel>
|
<InputLabel id="min-difficulty-select-label">Min Difficulty</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
|
sx={{ maxWidth: "270px" }}
|
||||||
labelId="min-difficulty-select-label"
|
labelId="min-difficulty-select-label"
|
||||||
id="min-difficulty-select"
|
id="min-difficulty-select"
|
||||||
value={minDifficulty}
|
value={minDifficulty}
|
||||||
@ -69,6 +112,7 @@ const StartTestForm = () => {
|
|||||||
<FormControl sx={{ minWidth: "200px" }} variant="filled">
|
<FormControl sx={{ minWidth: "200px" }} variant="filled">
|
||||||
<InputLabel id="max-difficulty-select-label">Max Difficulty</InputLabel>
|
<InputLabel id="max-difficulty-select-label">Max Difficulty</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
|
sx={{ maxWidth: "270px" }}
|
||||||
labelId="max-difficulty-select-label"
|
labelId="max-difficulty-select-label"
|
||||||
id="max-difficulty-select"
|
id="max-difficulty-select"
|
||||||
value={maxDifficulty}
|
value={maxDifficulty}
|
||||||
@ -83,10 +127,16 @@ const StartTestForm = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Tooltip title="Start a test based on selected parameters.">
|
<Tooltip title="Start a test based on selected parameters.">
|
||||||
<Button onClick={() => notifySuccess("Started")} variant="contained">Start Test</Button>
|
<Button onClick={handleStartTest} variant="contained">
|
||||||
|
Start Test
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Random category and difficulty test.">
|
<Tooltip title="Random category and difficulty test.">
|
||||||
<Button sx={{ backgroundColor: "#6610F2" }} variant="contained">
|
<Button
|
||||||
|
onClick={handleFeelingLucky}
|
||||||
|
sx={{ backgroundColor: "#6610F2" }}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
Im Feeling Lucky
|
Im Feeling Lucky
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
87
src/components/TestCard/TestCard.tsx
Normal file
87
src/components/TestCard/TestCard.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Box, Button, Chip, Typography } from "@mui/material";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { formatDate } from "../../utils/functions";
|
||||||
|
import type { UserTestType } from "../shared/types/TestTypes";
|
||||||
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
|
|
||||||
|
interface TestCardProps {
|
||||||
|
test: UserTestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestCard = ({ test }: TestCardProps) => {
|
||||||
|
let title: string | undefined = "User Test";
|
||||||
|
|
||||||
|
|
||||||
|
if (test.test_id) {
|
||||||
|
const { data } = useGetTestById(test.test_id);
|
||||||
|
title = data?.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusLabel = "";
|
||||||
|
let statusColor: "primary" | "success" | "error" = "primary";
|
||||||
|
|
||||||
|
if (test.is_completed) {
|
||||||
|
statusLabel = "Completed";
|
||||||
|
statusColor = "success";
|
||||||
|
} else if (!test.is_available) {
|
||||||
|
statusLabel = "Expired";
|
||||||
|
statusColor = "error";
|
||||||
|
} else {
|
||||||
|
statusLabel = "Active";
|
||||||
|
statusColor = "primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 2,
|
||||||
|
mb: 2,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">{title}</Typography>
|
||||||
|
{test.closed_at && (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
Expires at: {formatDate(test.closed_at)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{test.is_completed && test.score !== undefined && (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
Score: {test.score}%
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Chip label={statusLabel} color={statusColor} />
|
||||||
|
{test.is_available && !test.is_completed && (
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={`/tests/${test.id}`}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Continue Test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{test.is_completed && (
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={`/tests/${test.id}`}
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
View Results
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestCard;
|
||||||
103
src/components/TestListCard/TestListCard.tsx
Normal file
103
src/components/TestListCard/TestListCard.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
|
||||||
|
import CategoryIcon from "@mui/icons-material/Category";
|
||||||
|
import type { TestType } from "../shared/types/TestTypes";
|
||||||
|
import { useStartTestById } from "../../hooks/tests/useStartTestById";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
interface TestCardProps {
|
||||||
|
test: TestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestListCard = ({ test }: TestCardProps) => {
|
||||||
|
const { mutate: startTest } = useStartTestById();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const title = test.title;
|
||||||
|
|
||||||
|
let statusLabel = "";
|
||||||
|
let statusColor: "primary" | "success" | "error" = "primary";
|
||||||
|
|
||||||
|
if (!test.is_available) {
|
||||||
|
statusLabel = "Expired";
|
||||||
|
statusColor = "error";
|
||||||
|
} else {
|
||||||
|
statusLabel = "Active";
|
||||||
|
statusColor = "primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 2,
|
||||||
|
mb: 2,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: {
|
||||||
|
xs: "column",
|
||||||
|
sm: "column",
|
||||||
|
md: "row",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 1,
|
||||||
|
flexDirection: { xs: "column", sm: "row" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="View test details" placement="top" arrow>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="primary"
|
||||||
|
sx={{ cursor: "pointer", color: "#000" }}
|
||||||
|
onClick={() => navigate(`/tests/view/${test.id}`)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
<Chip
|
||||||
|
icon={<CategoryIcon />}
|
||||||
|
label={test.category.name}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{test.description && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
|
{test.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
display="block"
|
||||||
|
mt={1}
|
||||||
|
>
|
||||||
|
Closes: {new Date(test.closed_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 1 }}>
|
||||||
|
<Chip label={statusLabel} color={statusColor} />
|
||||||
|
{test.is_available && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => startTest(test.id)}
|
||||||
|
>
|
||||||
|
Start Test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestListCard;
|
||||||
41
src/components/TestQrModal/TestQrModal.tsx
Normal file
41
src/components/TestQrModal/TestQrModal.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import QRCode from "react-qr-code";
|
||||||
|
import { Modal, Box, Button } from "@mui/material";
|
||||||
|
|
||||||
|
import type { TestType } from "../shared/types/TestTypes";
|
||||||
|
|
||||||
|
interface QrProps {
|
||||||
|
open: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
test: TestType
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestQrModal = ({ open, onClose, test }: QrProps) => {
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
bgcolor: "white",
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QRCode value={String(test.id)} size={200} />
|
||||||
|
<Button onClick={onClose} sx={{ mt: 2 }}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestQrModal;
|
||||||
98
src/components/TestQuestion/TestQuestion.tsx
Normal file
98
src/components/TestQuestion/TestQuestion.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { UserTestAnswer } from "../shared/types/TestTypes";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface TestQuestionProps {
|
||||||
|
qa: UserTestAnswer;
|
||||||
|
onSubmit: (answer: (string | number)[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestQuestion = ({ qa, onSubmit }: TestQuestionProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { question, answer: initialAnswer } = qa;
|
||||||
|
const [selectedAnswers, setSelectedAnswers] = useState<(string | number)[]>(
|
||||||
|
initialAnswer ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedAnswers(initialAnswer ?? []);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["current-test"] });
|
||||||
|
}, [initialAnswer, qa.question.id]);
|
||||||
|
|
||||||
|
const handleCheckboxChange = (variantId: number) => {
|
||||||
|
if (question.type === "single") {
|
||||||
|
setSelectedAnswers([variantId]);
|
||||||
|
} else {
|
||||||
|
setSelectedAnswers((prev) =>
|
||||||
|
prev.includes(variantId)
|
||||||
|
? prev.filter((x) => x !== variantId)
|
||||||
|
: [...prev, variantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextChange = (value: string) => {
|
||||||
|
setSelectedAnswers([value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSubmit(selectedAnswers);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
mb: 3,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">{question.title}</Typography>
|
||||||
|
{question.description && (
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
{question.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.type === "text" && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={selectedAnswers[0] ?? ""}
|
||||||
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(question.type === "single" || question.type === "multiple") &&
|
||||||
|
question.variants.map((variant) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={variant.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedAnswers.includes(variant.id)}
|
||||||
|
onChange={() => handleCheckboxChange(variant.id)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={variant.text}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSubmit}>
|
||||||
|
Submit Answer
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestQuestion;
|
||||||
70
src/components/UserTestRow/UserTestRow.tsx
Normal file
70
src/components/UserTestRow/UserTestRow.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { TableRow, TableCell, Button } from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { UserTestType } from "../../components/shared/types/TestTypes";
|
||||||
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
|
import { formatDate } from "../../utils/functions";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
userTest: UserTestType;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserTestRow = ({ userTest, onDelete }: Props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const testId = userTest.test_id;
|
||||||
|
let title = "User Test";
|
||||||
|
let author = "—";
|
||||||
|
|
||||||
|
if (testId) {
|
||||||
|
const { data } = useGetTestById(testId);
|
||||||
|
title = data?.title ?? "User Test";
|
||||||
|
author = data?.author.username ?? "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{userTest.id}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{title}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{userTest.user?.username ?? "Unknown"}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{author}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{userTest.score !== undefined ? userTest.score : "—"}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{userTest.is_completed ? "Yes" : "No"}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{formatDate(userTest.created_at)}</TableCell>
|
||||||
|
<TableCell>{formatDate(userTest.closed_at)}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
onClick={() => navigate(`/tests/${userTest.id}`)}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
{user?.type === "admin" && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onDelete(userTest.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserTestRow;
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { toast } from "react-toastify";
|
|
||||||
|
|
||||||
export const notifySuccess = (msg: string) => toast.success(msg);
|
|
||||||
export const notifyError = (msg: string) => toast.error(msg);
|
|
||||||
26
src/components/shared/types/QuestionTypes.ts
Normal file
26
src/components/shared/types/QuestionTypes.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export type Variant = {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Author = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuestionType = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: "single" | "multiple" | "text";
|
||||||
|
difficulty: number;
|
||||||
|
variants: Variant[];
|
||||||
|
correct_answers: number[] | string[];
|
||||||
|
category: Category;
|
||||||
|
author: Author;
|
||||||
|
};
|
||||||
88
src/components/shared/types/TestTypes.ts
Normal file
88
src/components/shared/types/TestTypes.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import type { User } from "./AuthTypes";
|
||||||
|
import type { QuestionType } from "./QuestionTypes";
|
||||||
|
|
||||||
|
export type CategoryType = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
created_at?: string;
|
||||||
|
questions_count?: number;
|
||||||
|
user_tests_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StartTestPayload {
|
||||||
|
category_id: number;
|
||||||
|
min_difficulty: number;
|
||||||
|
max_difficulty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserTestAnswer = {
|
||||||
|
id: number;
|
||||||
|
user_test_id: number;
|
||||||
|
question_id: number;
|
||||||
|
question: QuestionType;
|
||||||
|
answer: (string | number)[] | null;
|
||||||
|
user_id: number;
|
||||||
|
is_correct: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestType = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
category_id: number;
|
||||||
|
category: CategoryType;
|
||||||
|
questions?: QuestionType[];
|
||||||
|
is_available: boolean;
|
||||||
|
author_id: number;
|
||||||
|
author: User;
|
||||||
|
closed_at: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserTestType = {
|
||||||
|
id: number;
|
||||||
|
test_id: number | null;
|
||||||
|
test?: TestType;
|
||||||
|
user_id: number;
|
||||||
|
user?: User;
|
||||||
|
closed_at: string | null;
|
||||||
|
is_completed?: boolean;
|
||||||
|
score?: number;
|
||||||
|
is_available: boolean;
|
||||||
|
answers?: UserTestAnswer[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmitAnswerPayload = {
|
||||||
|
answerId: number;
|
||||||
|
answer: (string | number)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginatedTests = {
|
||||||
|
data: TestType[];
|
||||||
|
links: {
|
||||||
|
first: string | null;
|
||||||
|
last: string | null;
|
||||||
|
prev: string | null;
|
||||||
|
next: string | null;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
from: number | null;
|
||||||
|
last_page: number;
|
||||||
|
links: {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
page: number | null;
|
||||||
|
active: boolean;
|
||||||
|
}[];
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
to: number | null;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useCurrentUser } from "../hooks/useCurrentUser";
|
import { useCurrentUser } from "../hooks/auth/useCurrentUser";
|
||||||
import { useLogin } from "../hooks/useLogin";
|
import { useLogin } from "../hooks/auth/useLogin";
|
||||||
import type {
|
import type {
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { activateAccount } from "../api/authApi";
|
import { activateAccount } from "../../api/authApi";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export const useActivateAccount = () => {
|
export const useActivateAccount = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (token: string) => activateAccount(token),
|
mutationFn: activateAccount,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
@ -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,13 +1,12 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { loginRequest } from "../api/authApi";
|
import { loginRequest } from "../../api/authApi";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import type { LoginPayload } from "../components/shared/types/AuthTypes";
|
|
||||||
|
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: LoginPayload) => loginRequest(payload),
|
mutationFn: loginRequest,
|
||||||
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
localStorage.setItem("access_token", data.access_token);
|
localStorage.setItem("access_token", data.access_token);
|
||||||
@ -1,19 +1,12 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { registrationRequest } from "../api/authApi";
|
import { registrationRequest } from "../../api/authApi";
|
||||||
import type { RegistrationPayload } from "../components/shared/types/AuthTypes";
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export const useRegistration = () => {
|
export const useRegistration = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: registrationRequest,
|
||||||
username,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
password_confirmation,
|
|
||||||
}: RegistrationPayload) =>
|
|
||||||
registrationRequest({ username, email, password, password_confirmation }),
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { requestPasswordReset } from "../api/authApi";
|
import { requestPasswordReset } from "../../api/authApi";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const useRequestPasswordReset = () => {
|
export const useRequestPasswordReset = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (email: string) => requestPasswordReset(email),
|
mutationFn: requestPasswordReset,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
},
|
},
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import type { ResetPasswordPayload } from "../components/shared/types/AuthTypes";
|
import { resetPassword } from "../../api/authApi";
|
||||||
import { resetPassword } from "../api/authApi";
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const useResetPassword = () => {
|
export const useResetPassword = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: ResetPasswordPayload) => resetPassword(data),
|
mutationFn: resetPassword,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
},
|
},
|
||||||
8
src/hooks/categories/useCategories.ts
Normal file
8
src/hooks/categories/useCategories.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getCategories } from "../../api/categoryApi";
|
||||||
|
|
||||||
|
export const useCategories = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: getCategories,
|
||||||
|
});
|
||||||
17
src/hooks/categories/useCreateCategory.ts
Normal file
17
src/hooks/categories/useCreateCategory.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createCategory } from "../../api/categoryApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useCreateCategory = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => createCategory(name),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
toast.success("Category Created");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
src/hooks/categories/useDeleteCategory.ts
Normal file
17
src/hooks/categories/useDeleteCategory.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deleteCategory } from "../../api/categoryApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useDeleteCategory = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteCategory(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
toast.success("Category Deleted");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
10
src/hooks/categories/useGetCategoryById.ts
Normal file
10
src/hooks/categories/useGetCategoryById.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getCategoryById } from "../../api/categoryApi";
|
||||||
|
|
||||||
|
export const useCategoryById = (id?: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["categories", id],
|
||||||
|
queryFn: () => getCategoryById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/categories/useUpdateCategory.ts
Normal file
18
src/hooks/categories/useUpdateCategory.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { updateCategory } from "../../api/categoryApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useUpdateCategory = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: number; name: string }) =>
|
||||||
|
updateCategory(id, name),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
toast.success("Category Updated");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
9
src/hooks/hitcounts/useHitcounts.ts
Normal file
9
src/hooks/hitcounts/useHitcounts.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getHitCounts } from "../../api/metricsService";
|
||||||
|
|
||||||
|
export const useHitcounts = (page: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["hitcounts", page],
|
||||||
|
queryFn: () => getHitCounts(page),
|
||||||
|
});
|
||||||
|
};
|
||||||
9
src/hooks/logsHook/useLogs.ts
Normal file
9
src/hooks/logsHook/useLogs.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getLogs } from "../../api/logsApi";
|
||||||
|
|
||||||
|
export const useLogs = (page: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["logs", page],
|
||||||
|
queryFn: () => getLogs(page),
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/questions/useCreateQuestion.ts
Normal file
18
src/hooks/questions/useCreateQuestion.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createQuestion, type QuestionPayload } from "../../api/questionsApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useCreateQuestion = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: QuestionPayload) => createQuestion(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
toast.success("Question Created");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/questions/useDeleteQuestion.ts
Normal file
18
src/hooks/questions/useDeleteQuestion.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deleteQuestion } from "../../api/questionsApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useDeleteQuestion = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteQuestion(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
toast.success("Question Deleted");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
10
src/hooks/questions/useOpenaiGenerateQuestion.ts
Normal file
10
src/hooks/questions/useOpenaiGenerateQuestion.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { openaiGenerateQuestion } from "../../api/questionsApi";
|
||||||
|
import type { OpenaiPayload } from "../../api/questionsApi";
|
||||||
|
import type { QuestionPayload } from "../../api/questionsApi";
|
||||||
|
|
||||||
|
export const useOpenaiGenerateQuestion = () => {
|
||||||
|
return useMutation<QuestionPayload, Error, OpenaiPayload>({
|
||||||
|
mutationFn: openaiGenerateQuestion,
|
||||||
|
});
|
||||||
|
};
|
||||||
10
src/hooks/questions/useQuestionById.ts
Normal file
10
src/hooks/questions/useQuestionById.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getQuestionById } from "../../api/questionsApi";
|
||||||
|
|
||||||
|
export const useQuestionById = (id?: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["single-question", id],
|
||||||
|
queryFn: () => getQuestionById(Number(id!)),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
15
src/hooks/questions/useQuestions.ts
Normal file
15
src/hooks/questions/useQuestions.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getQuestions } from "../../api/questionsApi";
|
||||||
|
|
||||||
|
type UseQuestionsParams = {
|
||||||
|
page?: number;
|
||||||
|
id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useQuestions = ({ page = 1, id }: UseQuestionsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["questions", { page, id }],
|
||||||
|
queryFn: () => getQuestions({ page, id }),
|
||||||
|
staleTime: 1000 * 60,
|
||||||
|
});
|
||||||
|
};
|
||||||
19
src/hooks/questions/useUpdateQuestion.ts
Normal file
19
src/hooks/questions/useUpdateQuestion.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { updateQuestion, type QuestionPayload } from "../../api/questionsApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useUpdateQuestion = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: QuestionPayload }) =>
|
||||||
|
updateQuestion(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
toast.success("Question Updated");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/tests/useCompleteTest.ts
Normal file
18
src/hooks/tests/useCompleteTest.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { completeUserTest } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useCompleteTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: completeUserTest,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["current-test"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/tests/useCreateTest.ts
Normal file
18
src/hooks/tests/useCreateTest.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createTest } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useCreateTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createTest,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests"] });
|
||||||
|
toast.success("Test Created");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/tests/useDeleteTest.ts
Normal file
18
src/hooks/tests/useDeleteTest.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deleteTest } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useDeleteTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteTest(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests"] });
|
||||||
|
toast.success("Test deleted");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message ?? "Failed to delete test");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
15
src/hooks/tests/useDeteleUserTest.ts
Normal file
15
src/hooks/tests/useDeteleUserTest.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deleteUserTest } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useDeleteUserTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteUserTest(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all-user-tests"] });
|
||||||
|
toast.success("User Test deleted")
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
12
src/hooks/tests/useGetAllUserTests.ts
Normal file
12
src/hooks/tests/useGetAllUserTests.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getAllUserTests } from "../../api/testApi";
|
||||||
|
|
||||||
|
export const useGetAllUserTests = (
|
||||||
|
page: number,
|
||||||
|
test_id?: number,
|
||||||
|
question_id?: number
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["all-user-tests", page],
|
||||||
|
queryFn: () => getAllUserTests(page, test_id, question_id),
|
||||||
|
});
|
||||||
11
src/hooks/tests/useGetTestById.ts
Normal file
11
src/hooks/tests/useGetTestById.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getTestById } from "../../api/testApi";
|
||||||
|
import type { TestType } from "../../components/shared/types/TestTypes";
|
||||||
|
|
||||||
|
export const useGetTestById = (id?: number) => {
|
||||||
|
return useQuery<TestType>({
|
||||||
|
queryKey: ["tests", id],
|
||||||
|
queryFn: () => getTestById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
15
src/hooks/tests/useGetTests.ts
Normal file
15
src/hooks/tests/useGetTests.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getTests } from "../../api/testApi";
|
||||||
|
|
||||||
|
type UseTestsParams = {
|
||||||
|
page?: number;
|
||||||
|
categoryId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetTests = ({ page = 1, categoryId }: UseTestsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["tests", { page, categoryId }],
|
||||||
|
queryFn: () => getTests(categoryId, page),
|
||||||
|
staleTime: 1000 * 60,
|
||||||
|
});
|
||||||
|
};
|
||||||
9
src/hooks/tests/useGetUserTests.ts
Normal file
9
src/hooks/tests/useGetUserTests.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getUserTests } from "../../api/testApi";
|
||||||
|
|
||||||
|
export const useGetUserTests = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["user-tests"],
|
||||||
|
queryFn: () => getUserTests(),
|
||||||
|
});
|
||||||
|
};
|
||||||
20
src/hooks/tests/useStartTest.ts
Normal file
20
src/hooks/tests/useStartTest.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { startTest } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export const useStartTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: startTest,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
|
||||||
|
toast.success("Test Started");
|
||||||
|
navigate(`/tests/${data.id}`);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
20
src/hooks/tests/useStartTestById.ts
Normal file
20
src/hooks/tests/useStartTestById.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { startTestById } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export const useStartTestById = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => startTestById(id),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
|
||||||
|
toast.success("Test Started");
|
||||||
|
navigate(`/tests/${data.id}`);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
src/hooks/tests/useSubmitAnswer.ts
Normal file
17
src/hooks/tests/useSubmitAnswer.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { submitAnswer } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useSubmitAnswer = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: submitAnswer,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["current-test"] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Something went wrong");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
23
src/hooks/tests/useUpdateTest.ts
Normal file
23
src/hooks/tests/useUpdateTest.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { updateTest, type TestPayload } from "../../api/testApi";
|
||||||
|
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useUpdateTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: TestPayload }) =>
|
||||||
|
updateTest(id, payload),
|
||||||
|
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests", variables.id] });
|
||||||
|
toast.success("Test updated");
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message ?? "Failed to update test");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
9
src/hooks/tests/useUserTestById.ts
Normal file
9
src/hooks/tests/useUserTestById.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getUserTestById } from "../../api/testApi";
|
||||||
|
|
||||||
|
export const useUserTestById = (id: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["current-test"],
|
||||||
|
queryFn: () => getUserTestById(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
17
src/hooks/users/useCreateUser.ts
Normal file
17
src/hooks/users/useCreateUser.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createUser, type UserPayload } from "../../api/usersApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useCreateUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UserPayload) => createUser(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast.success("User Created");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
src/hooks/users/useDeleteUser.ts
Normal file
17
src/hooks/users/useDeleteUser.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deleteUser } from "../../api/usersApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useDeleteUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteUser(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast.success("User Deleted!");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/users/useUpdateUser.ts
Normal file
18
src/hooks/users/useUpdateUser.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { updateUser, type UserPayload } from "../../api/usersApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useUpdateUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: UserPayload }) =>
|
||||||
|
updateUser(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast.success("User Updated");
|
||||||
|
},
|
||||||
|
onError: (error) =>{
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
10
src/hooks/users/useUserById.ts
Normal file
10
src/hooks/users/useUserById.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getUserById } from "../../api/usersApi";
|
||||||
|
|
||||||
|
export const useUserById = (id?: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["user", id],
|
||||||
|
queryFn: () => getUserById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
10
src/hooks/users/useUsers.ts
Normal file
10
src/hooks/users/useUsers.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getUsers } from "../../api/usersApi";
|
||||||
|
|
||||||
|
export const useUsers = (page = 1) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["users", page],
|
||||||
|
queryFn: () => getUsers(page),
|
||||||
|
staleTime: 1000 * 60,
|
||||||
|
});
|
||||||
|
};
|
||||||
20
src/layouts/AdminLayout/AdminLayout.tsx
Normal file
20
src/layouts/AdminLayout/AdminLayout.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { AdminSidebar } from "../../components/AdminSidebar/AdminSidebar";
|
||||||
|
import { LayoutWrapper } from "../MainLayout/Layout.styles";
|
||||||
|
import Header from "../../components/Header/Header";
|
||||||
|
import { StyledDistance } from "../../components/shared/StyledDistance";
|
||||||
|
import Cholecounter from "../../components/Cholecounter/Cholecounter";
|
||||||
|
|
||||||
|
const AdminLayout = () => {
|
||||||
|
return (
|
||||||
|
<LayoutWrapper>
|
||||||
|
<Header />
|
||||||
|
<StyledDistance />
|
||||||
|
<AdminSidebar />
|
||||||
|
<Cholecounter/>
|
||||||
|
<Outlet />
|
||||||
|
</LayoutWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminLayout;
|
||||||
@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom";
|
|||||||
import Header from "../../components/Header/Header";
|
import Header from "../../components/Header/Header";
|
||||||
import { LayoutWrapper } from "./Layout.styles";
|
import { LayoutWrapper } from "./Layout.styles";
|
||||||
import { StyledDistance } from "../../components/shared/StyledDistance";
|
import { StyledDistance } from "../../components/shared/StyledDistance";
|
||||||
|
import Cholecounter from "../../components/Cholecounter/Cholecounter";
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
return (
|
return (
|
||||||
@ -9,6 +10,7 @@ const MainLayout = () => {
|
|||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
<Header />
|
<Header />
|
||||||
<StyledDistance />
|
<StyledDistance />
|
||||||
|
<Cholecounter/>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</LayoutWrapper>
|
</LayoutWrapper>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useActivateAccount } from "../../hooks/useActivateAccount";
|
import { useActivateAccount } from "../../hooks/auth/useActivateAccount";
|
||||||
import { CircularProgress, Box, Typography } from "@mui/material";
|
import { CircularProgress, Box, Typography } from "@mui/material";
|
||||||
|
|
||||||
const ActivateAccountPage = () => {
|
const ActivateAccountPage = () => {
|
||||||
|
|||||||
166
src/pages/AdminTestsPage/AdminTestsPage.tsx
Normal file
166
src/pages/AdminTestsPage/AdminTestsPage.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
|
||||||
|
import type { TestType } from "../../components/shared/types/TestTypes";
|
||||||
|
import { useGetTests } from "../../hooks/tests/useGetTests";
|
||||||
|
import { useDeleteTest } from "../../hooks/tests/useDeleteTest";
|
||||||
|
import { formatDate } from "../../utils/functions";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
|
const AdminTestsPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [deleteTestId, setDeleteTestId] = useState<number | null>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useGetTests({ page: currentPage });
|
||||||
|
const deleteMutation = useDeleteTest();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleViewTest = (id: number) => {
|
||||||
|
navigate(`/tests/view/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTest = (id: number) => {
|
||||||
|
navigate(`/dashboard/tests/${id}/update`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTest = () => {
|
||||||
|
if (deleteTestId !== null) {
|
||||||
|
deleteMutation.mutate(deleteTestId, {
|
||||||
|
onSuccess: () => setDeleteTestId(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading tests.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h4">Tests</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => navigate("/dashboard/tests/create")}
|
||||||
|
>
|
||||||
|
Create Test
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Title</TableCell>
|
||||||
|
<TableCell>Category</TableCell>
|
||||||
|
<TableCell>Author</TableCell>
|
||||||
|
<TableCell>Available</TableCell>
|
||||||
|
<TableCell>Created At</TableCell>
|
||||||
|
<TableCell>Closed At</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((test: TestType) => (
|
||||||
|
<TableRow key={test.id}>
|
||||||
|
<TableCell>{test.id}</TableCell>
|
||||||
|
<TableCell>{test.title}</TableCell>
|
||||||
|
<TableCell>{test.category?.name}</TableCell>
|
||||||
|
<TableCell>{test.author?.username}</TableCell>
|
||||||
|
<TableCell>{test.is_available ? "Yes" : "No"}</TableCell>
|
||||||
|
<TableCell>{formatDate(test.created_at)}</TableCell>
|
||||||
|
<TableCell>{formatDate(test.closed_at)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleViewTest(test.id)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(user?.type === "admin" || user?.id == test.author_id) && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => handleUpdateTest(test.id)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteTestId(test.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data?.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteTestId !== null}
|
||||||
|
onClose={() => setDeleteTestId(null)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Confirm Delete</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
Are you sure you want to delete this test?
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTestId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteTest}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminTestsPage;
|
||||||
114
src/pages/AdminUserTestsPage/AdminUserTestsPage.tsx
Normal file
114
src/pages/AdminUserTestsPage/AdminUserTestsPage.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
|
||||||
|
import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest";
|
||||||
|
|
||||||
|
import UserTestRow from "../../components/UserTestRow/UserTestRow";
|
||||||
|
|
||||||
|
const UserTestsPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useGetAllUserTests(currentPage);
|
||||||
|
const deleteMutation = useDeleteUserTest();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (deleteId !== null) {
|
||||||
|
deleteMutation.mutate(deleteId, {
|
||||||
|
onSuccess: () => setDeleteId(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading user tests.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h4">User Tests</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Test</TableCell>
|
||||||
|
<TableCell>Test Taker</TableCell>
|
||||||
|
<TableCell>Test Author</TableCell>
|
||||||
|
<TableCell>Score</TableCell>
|
||||||
|
<TableCell>Completed</TableCell>
|
||||||
|
<TableCell>Created At</TableCell>
|
||||||
|
<TableCell>Closed At</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((ut) => (
|
||||||
|
<UserTestRow
|
||||||
|
key={ut.id}
|
||||||
|
userTest={ut}
|
||||||
|
onDelete={(id) => setDeleteId(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data?.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{
|
||||||
|
mt: 3,
|
||||||
|
mb: 3,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={deleteId !== null} onClose={() => setDeleteId(null)}>
|
||||||
|
<DialogTitle>Confirm Delete</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
Are you sure you want to delete this user test?
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserTestsPage;
|
||||||
136
src/pages/CategoriesPage/CategoriesPage.tsx
Normal file
136
src/pages/CategoriesPage/CategoriesPage.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
import { useDeleteCategory } from "../../hooks/categories/useDeleteCategory";
|
||||||
|
|
||||||
|
const CategoriesPage = () => {
|
||||||
|
const [deleteCategoryId, setDeleteCategoryId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useCategories();
|
||||||
|
const deleteMutation = useDeleteCategory();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleCreateCategory = () => {
|
||||||
|
navigate("/dashboard/categories/create");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCategory = (id: number) => {
|
||||||
|
navigate(`/dashboard/categories/${id}/update`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = () => {
|
||||||
|
if (deleteCategoryId !== null) {
|
||||||
|
deleteMutation.mutate(deleteCategoryId, {
|
||||||
|
onSuccess: () => setDeleteCategoryId(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading categories.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h4">Categories</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleCreateCategory}
|
||||||
|
>
|
||||||
|
Create Category
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Questions</TableCell>
|
||||||
|
<TableCell>User Tests</TableCell>
|
||||||
|
<TableCell>Created At</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{data?.map((category) => (
|
||||||
|
<TableRow key={category.id}>
|
||||||
|
<TableCell>{category.id}</TableCell>
|
||||||
|
<TableCell>{category.name}</TableCell>
|
||||||
|
<TableCell>{category.questions_count ?? "-"}</TableCell>
|
||||||
|
<TableCell>{category.user_tests_count ?? "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.created_at
|
||||||
|
? new Date(category.created_at).toLocaleDateString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
onClick={() => handleUpdateCategory(category.id)}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDeleteCategoryId(category.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteCategoryId !== null}
|
||||||
|
onClose={() => setDeleteCategoryId(null)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Confirm Delete</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
Are you sure you want to delete this category?
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteCategoryId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteCategory}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoriesPage;
|
||||||
77
src/pages/CategoryForm/CategoryForm.tsx
Normal file
77
src/pages/CategoryForm/CategoryForm.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { useCreateCategory } from "../../hooks/categories/useCreateCategory";
|
||||||
|
import { useUpdateCategory } from "../../hooks/categories/useUpdateCategory";
|
||||||
|
import { useCategoryById } from "../../hooks/categories/useGetCategoryById";
|
||||||
|
|
||||||
|
const CategoryForm = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isUpdate = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const { data: category, isLoading } = useCategoryById(
|
||||||
|
id ? Number(id) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const createCategoryMutation = useCreateCategory();
|
||||||
|
const updateCategoryMutation = useUpdateCategory();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (category) {
|
||||||
|
setName(category.name);
|
||||||
|
}
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
if (isUpdate && isLoading) return <CircularProgress />;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isUpdate && id) {
|
||||||
|
updateCategoryMutation.mutate(
|
||||||
|
{ id: Number(id), name },
|
||||||
|
{ onSuccess: () => navigate("/dashboard/categories") }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createCategoryMutation.mutate(name, {
|
||||||
|
onSuccess: () => navigate("/dashboard/categories"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 500, mx: "auto", mt: 5 }}>
|
||||||
|
<Typography variant="h5" mb={3}>
|
||||||
|
{isUpdate ? "Update Category" : "Create Category"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="contained" color="primary" type="submit">
|
||||||
|
{isUpdate ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryForm;
|
||||||
|
|
||||||
71
src/pages/HitcountsPage/HitcountsPage.tsx
Normal file
71
src/pages/HitcountsPage/HitcountsPage.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
} from "@mui/material";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useHitcounts } from "../../hooks/hitcounts/useHitcounts";
|
||||||
|
|
||||||
|
const HitCountsPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const { data, isLoading, isError } = useHitcounts(currentPage);
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading hitcounts.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h4">Hit Counts</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>IP</TableCell>
|
||||||
|
<TableCell>Device</TableCell>
|
||||||
|
<TableCell>User Agent</TableCell>
|
||||||
|
<TableCell>Country</TableCell>
|
||||||
|
<TableCell>URL</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((hit) => (
|
||||||
|
<TableRow key={hit.id}>
|
||||||
|
<TableCell>{hit.id}</TableCell>
|
||||||
|
<TableCell>{hit.ip}</TableCell>
|
||||||
|
<TableCell>{hit.device_type}</TableCell>
|
||||||
|
<TableCell>{hit.user_agent}</TableCell>
|
||||||
|
<TableCell>{hit.country || "-"}</TableCell>
|
||||||
|
<TableCell>{hit.url}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data?.meta.last_page || 1}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HitCountsPage;
|
||||||
@ -7,11 +7,15 @@ export const WelcomeContainer = styled("div")({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "10px",
|
padding: "10px",
|
||||||
marginBottom: "36px"
|
marginBottom: "36px",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ButtonGroup = styled("div")({
|
export const ButtonGroup = styled("div")({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
marginTop: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
export const IndexWrapper = styled("div")({
|
export const IndexWrapper = styled("div")({
|
||||||
|
|||||||
@ -1,29 +1,101 @@
|
|||||||
import { Typography } from "@mui/material";
|
import {
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
type SelectChangeEvent,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import { WelcomeContainer } from "./IndexPage.styles";
|
import { WelcomeContainer } from "./IndexPage.styles";
|
||||||
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
||||||
import Question from "../../components/Question/Question";
|
import Question from "../../components/Question/Question";
|
||||||
|
import { useQuestions } from "../../hooks/questions/useQuestions";
|
||||||
|
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
|
||||||
const IndexPage = () => {
|
const IndexPage = () => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const myQuestion = {
|
const { data: categories } = useCategories();
|
||||||
title: "Example Question",
|
const { user } = useAuth();
|
||||||
category: "Math",
|
const [selectedCategory, setSelectedCategory] = useState<number | "all">(
|
||||||
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ultricies, urna eu aliquet tincidunt, augue turpis gravida risus, sit amet posuere neque tellus sit amet justo. Curabitur non facilisis orci, vitae eleifend est. Cras fermentum, velit at scelerisque varius, mauris justo cursus lacus, ac porttitor ante metus sit amet nibh. Vivamus imperdiet, diam vel efficitur euismod, urna odio pharetra ipsum, vitae sollicitudin neque enim ut tortor. Donec gravida orci.",
|
"all"
|
||||||
difficulty: 3,
|
);
|
||||||
author: "David"
|
const questionsQuery = useQuestions({
|
||||||
|
page,
|
||||||
|
id: selectedCategory === "all" ? undefined : selectedCategory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const questions = questionsQuery.data?.data ?? [];
|
||||||
|
const loading = questionsQuery.isLoading;
|
||||||
|
const error = questionsQuery.error?.message ?? null;
|
||||||
|
const meta = questionsQuery.data?.meta ?? null;
|
||||||
|
|
||||||
|
const handleCategoryChange = (event: SelectChangeEvent<number | "all">) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setSelectedCategory(value === "all" ? "all" : Number(value));
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<WelcomeContainer>
|
<WelcomeContainer>
|
||||||
<Typography variant="h2">Welcome To HoshiAI!</Typography>
|
<Typography align="center" variant="h2">Welcome To HoshiAI!</Typography>
|
||||||
<Typography variant="subtitle1">The best place to learn!</Typography>
|
<Typography variant="subtitle1">The best place to learn!</Typography>
|
||||||
</WelcomeContainer>
|
</WelcomeContainer>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<Typography align="center" sx={{ mb: "36px" }} variant="h3">
|
||||||
|
Start a Test
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<StartTestForm />
|
<StartTestForm />
|
||||||
<Typography sx={{mb:"36px"}} variant="h3">Browse Questions</Typography>
|
</>
|
||||||
<Question question={myQuestion} />
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
67
src/pages/LogsPage/LogsPage.tsx
Normal file
67
src/pages/LogsPage/LogsPage.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
} from "@mui/material";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useLogs } from "../../hooks/logsHook/useLogs";
|
||||||
|
|
||||||
|
const LogsPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const { data, isLoading, isError } = useLogs(currentPage);
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading logs.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h4" mb={3}>
|
||||||
|
Activity Logs
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Description</TableCell>
|
||||||
|
<TableCell>Created At</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell>{log.id}</TableCell>
|
||||||
|
<TableCell>{log.description}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(log.created_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data?.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogsPage;
|
||||||
@ -1,11 +1,25 @@
|
|||||||
import { Box, Button, Typography } from "@mui/material";
|
import { Box, Button, Tab, Tabs, Typography } from "@mui/material";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useGetUserTests } from "../../hooks/tests/useGetUserTests";
|
||||||
|
import TestCard from "../../components/TestCard/TestCard";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { data: tests, isLoading, error } = useGetUserTests();
|
||||||
|
|
||||||
|
const [tabIndex, setTabIndex] = useState(0);
|
||||||
|
|
||||||
|
const activeTests = tests?.filter((t) => t.is_available && !t.is_completed);
|
||||||
|
const completedTests = tests?.filter((t) => t.is_completed);
|
||||||
|
const expiredTests = tests?.filter((t) => !t.is_available && !t.is_completed);
|
||||||
|
|
||||||
|
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTabIndex(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
@ -30,12 +44,49 @@ const ProfilePage = () => {
|
|||||||
sx={{ marginLeft: "10px" }}
|
sx={{ marginLeft: "10px" }}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={() => navigate("/admin")}
|
onClick={() => navigate("/dashboard")}
|
||||||
>
|
>
|
||||||
Admin Dashboard
|
Admin Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Typography variant="h5" mb={2}>
|
||||||
|
Your Tests
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Tabs value={tabIndex} onChange={handleTabChange}>
|
||||||
|
<Tab label={`Active (${activeTests?.length || 0})`} />
|
||||||
|
<Tab label={`Completed (${completedTests?.length || 0})`} />
|
||||||
|
<Tab label={`Expired (${expiredTests?.length || 0})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
{isLoading && <Typography>Loading tests...</Typography>}
|
||||||
|
{error && (
|
||||||
|
<Typography color="error">Failed to load tests.</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tabIndex === 0 &&
|
||||||
|
(activeTests?.length ? (
|
||||||
|
activeTests.map((t) => <TestCard key={t.id} test={t} />)
|
||||||
|
) : (
|
||||||
|
<Typography>No active tests</Typography>
|
||||||
|
))}
|
||||||
|
{tabIndex === 1 &&
|
||||||
|
(completedTests?.length ? (
|
||||||
|
completedTests.map((t) => <TestCard key={t.id} test={t} />)
|
||||||
|
) : (
|
||||||
|
<Typography>No completed tests</Typography>
|
||||||
|
))}
|
||||||
|
{tabIndex === 2 &&
|
||||||
|
(expiredTests?.length ? (
|
||||||
|
expiredTests.map((t) => <TestCard key={t.id} test={t} />)
|
||||||
|
) : (
|
||||||
|
<Typography>No expired tests</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
287
src/pages/QuestionForm/QuestionForm.tsx
Normal file
287
src/pages/QuestionForm/QuestionForm.tsx
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Paper,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Add, Remove } from "@mui/icons-material";
|
||||||
|
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
import { useCreateQuestion } from "../../hooks/questions/useCreateQuestion";
|
||||||
|
import { useUpdateQuestion } from "../../hooks/questions/useUpdateQuestion";
|
||||||
|
|
||||||
|
import type { QuestionPayload } from "../../api/questionsApi";
|
||||||
|
import type { Variant } from "../../components/shared/types/QuestionTypes";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useQuestionById } from "../../hooks/questions/useQuestionById";
|
||||||
|
import GenerateQuestionModal from "../../components/GenerateQuestionModal/GenerateQuestionModal";
|
||||||
|
|
||||||
|
const QuestionForm = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isUpdate = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [type, setType] = useState<"single" | "multiple" | "text">("single");
|
||||||
|
const [difficulty, setDifficulty] = useState(1);
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
|
||||||
|
const [variants, setVariants] = useState<Variant[]>([{ id: 1, text: "" }]);
|
||||||
|
const [correctAnswers, setCorrectAnswers] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const [textAnswer, setTextAnswer] = useState("");
|
||||||
|
const [openAiModal, setOpenAiModal] = useState(false);
|
||||||
|
const [generatedQuestion, setGeneratedQuestion] =
|
||||||
|
useState<QuestionPayload | null>(null);
|
||||||
|
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { data: question, isLoading: isLoadingQuestion } = useQuestionById(
|
||||||
|
isUpdate ? Number(id) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = useCreateQuestion();
|
||||||
|
const updateMutation = useUpdateQuestion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (question || generatedQuestion) {
|
||||||
|
const q = question ?? generatedQuestion;
|
||||||
|
setTitle(q.title);
|
||||||
|
setDescription(q.description ?? "");
|
||||||
|
setType(q.type);
|
||||||
|
setDifficulty(q.difficulty);
|
||||||
|
setCategoryId(q.category_id);
|
||||||
|
if (q.type === "text") {
|
||||||
|
setTextAnswer(q.correct_answers[0] as string);
|
||||||
|
} else {
|
||||||
|
setVariants(q.variants.length ? q.variants : [{ id: 1, text: "" }]);
|
||||||
|
|
||||||
|
setCorrectAnswers(q.correct_answers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [question, generatedQuestion]);
|
||||||
|
|
||||||
|
if (isUpdate && isLoadingQuestion) return <CircularProgress />;
|
||||||
|
|
||||||
|
const handleVariantChange = (index: number, value: string) => {
|
||||||
|
const newVariants = [...variants];
|
||||||
|
newVariants[index].text = value;
|
||||||
|
setVariants(newVariants);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addVariant = () => {
|
||||||
|
setVariants((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Math.max(0, ...prev.map((v) => v.id)) + 1,
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVariant = (index: number) => {
|
||||||
|
const removedId = variants[index].id;
|
||||||
|
|
||||||
|
setVariants((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setCorrectAnswers((prev) => prev.filter((id) => id !== removedId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCorrectAnswer = (variantId: number) => {
|
||||||
|
if (type === "single") {
|
||||||
|
setCorrectAnswers([variantId]);
|
||||||
|
} else {
|
||||||
|
setCorrectAnswers((prev) =>
|
||||||
|
prev.includes(variantId)
|
||||||
|
? prev.filter((id) => id !== variantId)
|
||||||
|
: [...prev, variantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
if (!title || !categoryId) return false;
|
||||||
|
if (type !== "text") {
|
||||||
|
if (variants.length < 2) return false;
|
||||||
|
if (correctAnswers.length === 0) return false;
|
||||||
|
if (type === "single" && correctAnswers.length !== 1) return false;
|
||||||
|
} else {
|
||||||
|
if (!textAnswer.trim()) return false;
|
||||||
|
}
|
||||||
|
if (difficulty < 1 || difficulty > 10) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const payload: QuestionPayload = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
difficulty,
|
||||||
|
category_id: Number(categoryId),
|
||||||
|
variants: type === "text" ? [] : variants,
|
||||||
|
correct_answers: type === "text" ? [textAnswer]: correctAnswers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUpdate && id) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: Number(id), payload },
|
||||||
|
{ onSuccess: () => navigate("/dashboard/questions") }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload, {
|
||||||
|
onSuccess: () => navigate("/dashboard/questions"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h5" mb={3}>
|
||||||
|
{isUpdate ? "Update Question" : "Create Question"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setType(e.target.value as "single" | "multiple" | "text")
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="single">Single Choice</MenuItem>
|
||||||
|
<MenuItem value="multiple">Multiple Choice</MenuItem>
|
||||||
|
<MenuItem value="text">Text Answer</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Difficulty (1-10)"
|
||||||
|
type="number"
|
||||||
|
value={difficulty}
|
||||||
|
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{type === "text" ? (
|
||||||
|
<TextField
|
||||||
|
label="Correct Answer"
|
||||||
|
value={textAnswer}
|
||||||
|
onChange={(e) => setTextAnswer(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Variants & Correct Answer
|
||||||
|
</Typography>
|
||||||
|
{variants.map((v, i) => (
|
||||||
|
<Paper
|
||||||
|
key={v.id}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
value={v.text}
|
||||||
|
onChange={(e) => handleVariantChange(i, e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={correctAnswers.includes(v.id)}
|
||||||
|
onChange={() => toggleCorrectAnswer(v.id)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Correct"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton onClick={() => removeVariant(i)}>
|
||||||
|
<Remove />
|
||||||
|
</IconButton>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
<Button variant="outlined" startIcon={<Add />} onClick={addVariant}>
|
||||||
|
Add Variant
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="contained" type="submit">
|
||||||
|
{isUpdate ? "Update Question" : "Create Question"}
|
||||||
|
</Button>
|
||||||
|
{!isUpdate && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ml: 2 }}
|
||||||
|
onClick={() => setOpenAiModal(true)}
|
||||||
|
>
|
||||||
|
Generate with AI
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
<GenerateQuestionModal
|
||||||
|
open={openAiModal}
|
||||||
|
onClose={() => setOpenAiModal(false)}
|
||||||
|
onGenerated={(q) => setGeneratedQuestion(q)}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuestionForm;
|
||||||
@ -1,10 +1,168 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useQuestions } from "../../hooks/questions/useQuestions";
|
||||||
|
import { useDeleteQuestion } from "../../hooks/questions/useDeleteQuestion";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
const QuestionsPage = () => {
|
const QuestionsPage = () => {
|
||||||
return(
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
<StartTestForm/>
|
const [deleteQuestionId, setDeleteQuestionId] = useState<number | null>(null);
|
||||||
)
|
const { data, isLoading, isError } = useQuestions({ page: currentPage });
|
||||||
|
const deleteMutation = useDeleteQuestion();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
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/useRegistration";
|
import { useRegistration } from "../../hooks/auth/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/useRequestPasswordReset";
|
import { useRequestPasswordReset } from "../../hooks/auth/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/useResetPassword";
|
import { useResetPassword } from "../../hooks/auth/useResetPassword";
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import { Box, Button, TextField, Typography } from "@mui/material";
|
import { Box, Button, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
|||||||
28
src/pages/SingleQuestionPage/SingleQuestionPage.tsx
Normal file
28
src/pages/SingleQuestionPage/SingleQuestionPage.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Container } from "@mui/material";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useQuestionById } from "../../hooks/questions/useQuestionById";
|
||||||
|
import Question from "../../components/Question/Question";
|
||||||
|
import LearningAnswers from "../../components/Answers/Answers";
|
||||||
|
|
||||||
|
const SingleQuestionPage = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { data: question, isLoading, error } = useQuestionById(Number(id));
|
||||||
|
|
||||||
|
if (isLoading) return <Container>Loading...</Container>;
|
||||||
|
if (error) return <Container>Error: {error.message}</Container>;
|
||||||
|
if (!question) return <Container>No question found</Container>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container sx={{ mt: 4 }}>
|
||||||
|
<Question question={question} />
|
||||||
|
|
||||||
|
<LearningAnswers
|
||||||
|
variants={question.variants}
|
||||||
|
correctAnswers={question.correct_answers}
|
||||||
|
type={question.type as "single" | "text"}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SingleQuestionPage;
|
||||||
128
src/pages/SingleTestPage/SingleTestPage.tsx
Normal file
128
src/pages/SingleTestPage/SingleTestPage.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import LearningAnswers from "../../components/Answers/Answers";
|
||||||
|
import NotFoundPage from "../NotFoundPage/NotFoundPage";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
|
||||||
|
import UserTestRow from "../../components/UserTestRow/UserTestRow";
|
||||||
|
import TestQrModal from "../../components/TestQrModal/TestQrModal";
|
||||||
|
|
||||||
|
export const SingleTestPage = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const { data: tests } = useGetAllUserTests(currentPage, Number(id));
|
||||||
|
|
||||||
|
const [qrOpen, setQrOpen] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
const { data: test } = useGetTestById(Number(id));
|
||||||
|
if (!test) {
|
||||||
|
return <NotFoundPage />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h2">{test.title}</Typography>
|
||||||
|
|
||||||
|
{(user?.type === "admin" || user?.type === "creator") && (
|
||||||
|
<Button variant="outlined" onClick={() => setQrOpen(true)}>
|
||||||
|
Show QR
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{test?.questions?.map((q) => (
|
||||||
|
<Box key={q.id} sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h6">{q.title}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
{q.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<LearningAnswers
|
||||||
|
type={q.type as "single" | "multiple" | "text"}
|
||||||
|
variants={q.variants}
|
||||||
|
correctAnswers={q.correct_answers}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{(user?.type == "admin" || user?.type == "creator") && (
|
||||||
|
<span>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h4">{test.title} results</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Test</TableCell>
|
||||||
|
<TableCell>Test Taker</TableCell>
|
||||||
|
<TableCell>Test Author</TableCell>
|
||||||
|
<TableCell>Score</TableCell>
|
||||||
|
<TableCell>Completed</TableCell>
|
||||||
|
<TableCell>Created At</TableCell>
|
||||||
|
<TableCell>Closed At</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{tests?.data && tests.data.length > 0 ? (
|
||||||
|
tests.data.map((ut) => (
|
||||||
|
<UserTestRow
|
||||||
|
key={ut.id}
|
||||||
|
userTest={ut}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} align="center" sx={{ py: 3 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No results found for this test.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={tests?.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{
|
||||||
|
mt: 3,
|
||||||
|
mb: 3,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<TestQrModal open={qrOpen} onClose={() => setQrOpen(false)} test={test} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
193
src/pages/TestForm/TestForm.tsx
Normal file
193
src/pages/TestForm/TestForm.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
|
import { useQuestions } from "../../hooks/questions/useQuestions";
|
||||||
|
import { useCreateTest } from "../../hooks/tests/useCreateTest";
|
||||||
|
import { useUpdateTest } from "../../hooks/tests/useUpdateTest";
|
||||||
|
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
|
||||||
|
const TestForm = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isUpdate = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [closedAt, setClosedAt] = useState("");
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [selectedQuestions, setSelectedQuestions] = useState<number[]>([]);
|
||||||
|
const [questionsPage, setQuestionsPage] = useState(1);
|
||||||
|
|
||||||
|
const { data: test, isLoading: isLoadingTest } = useGetTestById(
|
||||||
|
isUpdate ? Number(id) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { data: questions, isLoading: isLoadingQuestions } = useQuestions({
|
||||||
|
page: questionsPage,
|
||||||
|
id: categoryId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useCreateTest();
|
||||||
|
const updateMutation = useUpdateTest();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (test) {
|
||||||
|
setTitle(test.title);
|
||||||
|
setDescription(test.description ?? "");
|
||||||
|
setClosedAt(test.closed_at);
|
||||||
|
setCategoryId(test.category_id);
|
||||||
|
setSelectedQuestions(test.questions?.map((q) => q.id) ?? []);
|
||||||
|
}
|
||||||
|
}, [test]);
|
||||||
|
|
||||||
|
if (isUpdate && isLoadingTest) return <CircularProgress />;
|
||||||
|
|
||||||
|
const toggleQuestion = (id: number) => {
|
||||||
|
setSelectedQuestions((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((q) => q !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
closed_at: closedAt || null,
|
||||||
|
category_id: Number(categoryId),
|
||||||
|
questions: selectedQuestions,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUpdate && id) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: Number(id), payload },
|
||||||
|
{ onSuccess: () => navigate("/dashboard/tests") }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload, {
|
||||||
|
onSuccess: () => navigate("/dashboard/tests"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h5" mb={3}>
|
||||||
|
{isUpdate ? "Update Test" : "Create Test"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="datetime-local"
|
||||||
|
label="Closed At"
|
||||||
|
value={closedAt}
|
||||||
|
onChange={(e) => setClosedAt(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCategoryId(Number(e.target.value));
|
||||||
|
setQuestionsPage(1);
|
||||||
|
setSelectedQuestions([]);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{categoryId && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" mb={1}>
|
||||||
|
Questions
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
{isLoadingQuestions ? (
|
||||||
|
<CircularProgress />
|
||||||
|
) : (
|
||||||
|
questions?.data.map((q: QuestionType) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={q.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedQuestions.includes(q.id)}
|
||||||
|
onChange={() => toggleQuestion(q.id)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={q.title}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!categoryId && (
|
||||||
|
<Typography variant="h6" mb={1}>
|
||||||
|
Please select category to be able to select questions
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
page={questionsPage}
|
||||||
|
count={questions?.meta.last_page}
|
||||||
|
onChange={(_, page) => setQuestionsPage(page)}
|
||||||
|
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "start" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="contained" type="submit">
|
||||||
|
{isUpdate ? "Update Test" : "Create Test"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestForm;
|
||||||
148
src/pages/TestPage/TestPage.tsx
Normal file
148
src/pages/TestPage/TestPage.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useUserTestById } from "../../hooks/tests/useUserTestById";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Box, Button, Pagination, Typography } from "@mui/material";
|
||||||
|
import TestQuestion from "../../components/TestQuestion/TestQuestion";
|
||||||
|
import { useSubmitAnswer } from "../../hooks/tests/useSubmitAnswer";
|
||||||
|
import { useCompleteTest } from "../../hooks/tests/useCompleteTest";
|
||||||
|
import LearningAnswers from "../../components/Answers/Answers";
|
||||||
|
import { formatDate } from "../../utils/functions";
|
||||||
|
import NotFoundPage from "../NotFoundPage/NotFoundPage";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
|
export const TestPage = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: test, isLoading, error } = useUserTestById(Number(id));
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState(1);
|
||||||
|
const submitAnswerMutation = useSubmitAnswer();
|
||||||
|
const completeTestMutation = useCompleteTest();
|
||||||
|
const allAnswered = test?.answers?.every((ans) => ans.answer !== null);
|
||||||
|
|
||||||
|
const handleCompleteTest = () => {
|
||||||
|
if (test) completeTestMutation.mutate(test.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h6" color="textSecondary">
|
||||||
|
Loading your test, please wait...
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) return <NotFoundPage />;
|
||||||
|
|
||||||
|
if (!test) return <NotFoundPage />;
|
||||||
|
|
||||||
|
if (!test.is_available && !test.is_completed && user?.type == 'user')
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h6" color="textSecondary">
|
||||||
|
This test is no longer available.
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (test.is_completed) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h2" sx={{ mb: 3 }}>
|
||||||
|
Results
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" color="secondary" sx={{ mb: 3 }}>
|
||||||
|
Your score: {test.score}%
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{test?.answers?.map((ans) => (
|
||||||
|
<Box key={ans.question_id} sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h6">{ans.question.title}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
{ans.question.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<LearningAnswers
|
||||||
|
type={ans.question.type as "single" | "multiple" | "text"}
|
||||||
|
variants={ans.question.variants}
|
||||||
|
correctAnswers={ans.question.correct_answers}
|
||||||
|
userAnswers={ans.answer as number[]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const questionsAndAnswers = test.answers;
|
||||||
|
const currentQA = questionsAndAnswers?.[currentQuestion - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h2">{test.test?.title ?? "User Test"}</Typography>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Expires at: {test.closed_at ? formatDate(test.closed_at) : "N/A"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{currentQA ? (
|
||||||
|
<TestQuestion
|
||||||
|
qa={questionsAndAnswers[currentQuestion - 1]}
|
||||||
|
onSubmit={(ans) =>
|
||||||
|
submitAnswerMutation.mutate({
|
||||||
|
answerId: questionsAndAnswers[currentQuestion - 1].id,
|
||||||
|
answer: ans,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography>No question found.</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
mt: 2,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pagination
|
||||||
|
count={questionsAndAnswers?.length}
|
||||||
|
shape="rounded"
|
||||||
|
color="primary"
|
||||||
|
page={currentQuestion}
|
||||||
|
onChange={(_, page) => setCurrentQuestion(page)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentQuestion == questionsAndAnswers?.length && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCompleteTest}
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
disabled={!allAnswered}
|
||||||
|
sx={{ mt: 15, fontSize: "32px" }}
|
||||||
|
>
|
||||||
|
Complete test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentQuestion == questionsAndAnswers?.length && !allAnswered && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="error"
|
||||||
|
sx={{ fontWeight: 500, textAlign: "center", mt: 2 }}
|
||||||
|
>
|
||||||
|
You must answer all questions before completing the test
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
src/pages/TestsPage/TestsPage.tsx
Normal file
83
src/pages/TestsPage/TestsPage.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Pagination from "@mui/material/Pagination";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useGetTests } from "../../hooks/tests/useGetTests";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import TestListCard from "../../components/TestListCard/TestListCard";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
type SelectChangeEvent,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
|
||||||
|
const TestsPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<number | "all">(
|
||||||
|
"all"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { data, isLoading, isError } = useGetTests({
|
||||||
|
categoryId: selectedCategory === "all" ? undefined : selectedCategory,
|
||||||
|
page: currentPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCategoryChange = (event: SelectChangeEvent<number | "all">) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setSelectedCategory(value === "all" ? "all" : Number(value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError) return <Alert severity="error">Failed to load tests</Alert>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h2">Curated tests</Typography>
|
||||||
|
<Box p={2}>
|
||||||
|
<FormControl sx={{ mb: 2, minWidth: 200 }}>
|
||||||
|
<InputLabel id="category-select-label">Category</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="category-select-label"
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={handleCategoryChange}
|
||||||
|
label="Category"
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All</MenuItem>
|
||||||
|
{categories?.map((category) => (
|
||||||
|
<MenuItem key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading tests...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div>Failed to load tests</div>
|
||||||
|
) : (
|
||||||
|
data?.data.map((test) => <TestListCard key={test.id} test={test} />)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.meta?.last_page && data.meta.last_page > 1 && (
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestsPage;
|
||||||
19
src/pages/Unauthorized/Forbidden.tsx
Normal file
19
src/pages/Unauthorized/Forbidden.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
export default function Forbidden() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
flexDirection="column"
|
||||||
|
minHeight="80vh"
|
||||||
|
>
|
||||||
|
<Typography variant="h2" color="error" mb={2}>
|
||||||
|
403
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5">Forbidden</Typography>
|
||||||
|
<Typography>You do not have access to this page.</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/pages/UserForm/UserForm.tsx
Normal file
113
src/pages/UserForm/UserForm.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useUserById } from "../../hooks/users/useUserById";
|
||||||
|
import { useCreateUser } from "../../hooks/users/useCreateUser";
|
||||||
|
import { useUpdateUser } from "../../hooks/users/useUpdateUser";
|
||||||
|
|
||||||
|
const UserForm = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isUpdate = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [type, setType] = useState("user");
|
||||||
|
|
||||||
|
const { data: user, isLoading: isLoadingUser } = useUserById(
|
||||||
|
id ? Number(id) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const createUserMutation = useCreateUser();
|
||||||
|
const updateUserMutation = useUpdateUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setUsername(user.username);
|
||||||
|
setEmail(user.email);
|
||||||
|
setType(user.type);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (isUpdate && isLoadingUser) return <CircularProgress />;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = { username, email, type, ...(password && { password }) };
|
||||||
|
|
||||||
|
if (isUpdate && id) {
|
||||||
|
updateUserMutation.mutate(
|
||||||
|
{ id: Number(id), payload },
|
||||||
|
{ onSuccess: () => navigate("/dashboard/users") }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createUserMutation.mutate(payload, {
|
||||||
|
onSuccess: () => navigate("/dashboard/users"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 500, mx: "auto", mt: 5 }}>
|
||||||
|
<Typography variant="h5" mb={3}>
|
||||||
|
{isUpdate ? "Update User" : "Create User"}
|
||||||
|
</Typography>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
placeholder={isUpdate ? "Leave blank to keep current password" : ""}
|
||||||
|
required={!isUpdate}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="user">User</MenuItem>
|
||||||
|
<MenuItem value="admin">Admin</MenuItem>
|
||||||
|
<MenuItem value="creator">Creator</MenuItem>
|
||||||
|
<MenuItem value="banned">Banned</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<Button variant="contained" color="primary" type="submit">
|
||||||
|
{isUpdate ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserForm;
|
||||||
141
src/pages/UsersPage/UsersPage.tsx
Normal file
141
src/pages/UsersPage/UsersPage.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useUsers } from "../../hooks/users/useUsers";
|
||||||
|
import { useDeleteUser } from "../../hooks/users/useDeleteUser";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const UsersPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
|
||||||
|
const { data, isLoading, isError } = useUsers(currentPage);
|
||||||
|
const deleteMutation = useDeleteUser();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleCreateUser = () => {
|
||||||
|
navigate(`/dashboard/users/create`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUser = (id: number) => {
|
||||||
|
navigate(`/dashboard/users/${id}/update`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = () => {
|
||||||
|
if (deleteUserId !== null) {
|
||||||
|
deleteMutation.mutate(deleteUserId, {
|
||||||
|
onSuccess: () => setDeleteUserId(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading users.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h4">Users</Typography>
|
||||||
|
<Button variant="contained" color="primary" onClick={handleCreateUser}>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Username</TableCell>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Created At</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.id}</TableCell>
|
||||||
|
<TableCell>{user.username}</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>{user.type}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleUpdateUser(user.id)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDeleteUserId(user.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data?.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteUserId !== null}
|
||||||
|
onClose={() => setDeleteUserId(null)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Confirm Delete</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
Are you sure you want to delete this user?
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteUserId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteUser}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersPage;
|
||||||
@ -9,6 +9,23 @@ import ActivateAccountPage from "../pages/ActivateAccountPage/ActivateAccountPag
|
|||||||
import RequestResetPage from "../pages/RequestResetPage/RequestResetPage";
|
import RequestResetPage from "../pages/RequestResetPage/RequestResetPage";
|
||||||
import NotFoundPage from "../pages/NotFoundPage/NotFoundPage";
|
import NotFoundPage from "../pages/NotFoundPage/NotFoundPage";
|
||||||
import ResetPasswordPage from "../pages/ResetPasswordPage/ResetPasswordPage";
|
import ResetPasswordPage from "../pages/ResetPasswordPage/ResetPasswordPage";
|
||||||
|
import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage";
|
||||||
|
import { TestPage } from "../pages/TestPage/TestPage";
|
||||||
|
import TestsPage from "../pages/TestsPage/TestsPage";
|
||||||
|
import AdminLayout from "../layouts/AdminLayout/AdminLayout";
|
||||||
|
import UsersPage from "../pages/UsersPage/UsersPage";
|
||||||
|
import UserForm from "../pages/UserForm/UserForm";
|
||||||
|
import CategoriesPage from "../pages/CategoriesPage/CategoriesPage";
|
||||||
|
import CategoryForm from "../pages/CategoryForm/CategoryForm";
|
||||||
|
import LogsPage from "../pages/LogsPage/LogsPage";
|
||||||
|
import HitCountsPage from "../pages/HitcountsPage/HitcountsPage";
|
||||||
|
import UserTestsPage from "../pages/AdminUserTestsPage/AdminUserTestsPage";
|
||||||
|
import { SingleTestPage } from "../pages/SingleTestPage/SingleTestPage";
|
||||||
|
import AdminTestsPage from "../pages/AdminTestsPage/AdminTestsPage";
|
||||||
|
import TestForm from "../pages/TestForm/TestForm";
|
||||||
|
import QuestionsPage from "../pages/QuestionsPage/QuestionsPage";
|
||||||
|
import QuestionForm from "../pages/QuestionForm/QuestionForm";
|
||||||
|
import { AdminProtectedRoute } from "../components/ProtectedRoute/AdminProtectedRoute";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -47,12 +64,76 @@ const router = createBrowserRouter([
|
|||||||
path: "/auth/reset-password/:token",
|
path: "/auth/reset-password/:token",
|
||||||
element: <ResetPasswordPage />,
|
element: <ResetPasswordPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/questions/:id",
|
||||||
|
element: <SingleQuestionPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tests/:id",
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TestPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ path: "/tests/view/:id", element: <SingleTestPage /> },
|
||||||
|
|
||||||
|
{
|
||||||
|
path: "/tests",
|
||||||
|
element: <TestsPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: <NotFoundPage />,
|
element: <NotFoundPage />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
element: (
|
||||||
|
<AdminProtectedRoute>
|
||||||
|
<AdminLayout />
|
||||||
|
</AdminProtectedRoute>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <QuestionsPage /> },
|
||||||
|
{
|
||||||
|
path: "users",
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <UsersPage /> },
|
||||||
|
{ path: "create", element: <UserForm /> },
|
||||||
|
{ path: ":id/update", element: <UserForm /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "questions",
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <QuestionsPage /> },
|
||||||
|
{ path: "create", element: <QuestionForm /> },
|
||||||
|
{ path: ":id/update", element: <QuestionForm /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "tests",
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <AdminTestsPage /> },
|
||||||
|
{ path: "create", element: <TestForm /> },
|
||||||
|
{ path: ":id/update", element: <TestForm /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "user-tests", element: <UserTestsPage /> },
|
||||||
|
{
|
||||||
|
path: "categories",
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <CategoriesPage /> },
|
||||||
|
{ path: "create", element: <CategoryForm /> },
|
||||||
|
{ path: ":id/update", element: <CategoryForm /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "logs", element: <LogsPage /> },
|
||||||
|
{ path: "hitcounts", element: <HitCountsPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
29
src/utils/functions.ts
Normal file
29
src/utils/functions.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export function arraysEqual(a: number[], b: number[]): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
|
||||||
|
a = [...a].sort((x, y) => x - y);
|
||||||
|
b = [...b].sort((x, y) => x - y);
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDate = (dateString: string | undefined | null) => {
|
||||||
|
if (!dateString) return "N/A";
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
const pad = (num: number) => num.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
const day = pad(date.getDate());
|
||||||
|
const month = pad(date.getMonth() + 1);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = pad(date.getHours());
|
||||||
|
const minutes = pad(date.getMinutes());
|
||||||
|
|
||||||
|
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user