diff --git a/.vscode/settings.json b/.vscode/settings.json index a4531e8..2b4cf8f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "cSpell.words": [ + "Jalaali", + "jalali", "snackbars" ] } \ No newline at end of file diff --git a/GenerateSwaggerLib.ps1 b/GenerateSwaggerLib.ps1 index 269379a..389f469 100644 --- a/GenerateSwaggerLib.ps1 +++ b/GenerateSwaggerLib.ps1 @@ -2,7 +2,6 @@ param ( [string]$SwaggerJsonPath = "./swagger.json", [string]$OutputPath = "./src/api" - # [string]$OutputFileName = "examSphereApi.ts" ) # & npx openapi-generator-cli generate -i path/to/your/swagger.json -g typescript-axios -o src/api diff --git a/package-lock.json b/package-lock.json index 65de44f..6f86d7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "axios": "^1.7.4", + "js-sha256": "^0.11.0", "moment-jalaali": "^0.10.1", "notistack": "^3.0.1", "react": "^18.3.1", @@ -12325,6 +12326,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-sha256": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", + "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 676eef3..85b2ba2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "axios": "^1.7.4", + "js-sha256": "^0.11.0", "moment-jalaali": "^0.10.1", "notistack": "^3.0.1", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 57502ab..ac3838a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,118 +15,121 @@ import CreateCoursePage from './pages/createCoursePage'; import CourseInfoPage from './pages/courseInfoPage'; import SearchCoursePage from './pages/searchCoursePage'; import CreateExamPage from './pages/createExamPage'; +import { switchAppTranslation } from './translations/translationSwitcher'; +import ExamInfoPage from './pages/examInfoPage'; const App: React.FC = () => { - const [isLoggedIn, setIsLoggedIn] = useState(apiClient.isLoggedIn()); - const [isLoading, setIsLoading] = useState(true); + const [isLoggedIn, setIsLoggedIn] = useState(apiClient.isLoggedIn()); + const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - const checkAuth = async () => { - if (apiClient.isLoggedIn()) { - try { - const response = await apiClient.getCurrentUserInfo(); - console.log(`logged in as ${response.user_id} | ${response.full_name}`); - setIsLoggedIn(true); - } catch (error) { - apiClient.clearTokens(); - setIsLoggedIn(false); - } - } + useEffect(() => { + const checkAuth = async () => { + if (apiClient.isLoggedIn()) { + try { + const response = await apiClient.getCurrentUserInfo(); + console.log(`logged in as ${response.user_id} | ${response.full_name}`); + setIsLoggedIn(true); + } catch (error) { + apiClient.clearTokens(); + setIsLoggedIn(false); + } + } - setIsLoading(false); - }; + setIsLoading(false); + }; - checkAuth(); - }, []); + checkAuth(); + }, []); + + switchAppTranslation(apiClient.userLanguage ?? 'en'); + if (isLoading) { + return ( + + + + {CurrentAppTranslation.LoadingText} + + + {CurrentAppTranslation.PleaseWaitText} + + + ); + } - if (isLoading) { return ( - - - - {CurrentAppTranslation.LoadingText} - - - Please wait while we load the content for you. - - + + + } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + : } + /> + } /> + + ); - } - - return ( - - - } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - } /> - - - ); }; export default App; \ No newline at end of file diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx new file mode 100644 index 0000000..ed1822d --- /dev/null +++ b/src/AppLayout.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import AppFooter from './components/footers/AppFooter'; +import { SupportedTranslations, switchAppTranslation } from './translations/translationSwitcher'; +import apiClient from './apiClient'; + +const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [currentAppLanguage, setCurrentAppLanguage] = React.useState('en'); + + return ( + + + {children} + + { + console.log(`Switching from ${currentAppLanguage} to ${lang}`); + // doing this, so react re-renders the app with the new language + setCurrentAppLanguage(lang); + const userLang = lang as SupportedTranslations; + switchAppTranslation(userLang); + await apiClient.setAppLanguage(userLang); + }} /> + + ); +}; + +export default AppLayout; \ No newline at end of file diff --git a/src/api/api.ts b/src/api/api.ts index aa7dfca..0e0bcd5 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1042,6 +1042,153 @@ export interface EditCourseV1200Response { */ 'success'?: boolean; } +/** + * + * @export + * @interface EditExamData + */ +export interface EditExamData { + /** + * + * @type {number} + * @memberof EditExamData + */ + 'course_id'?: number; + /** + * + * @type {number} + * @memberof EditExamData + */ + 'duration'?: number; + /** + * + * @type {number} + * @memberof EditExamData + */ + 'exam_date'?: number; + /** + * + * @type {string} + * @memberof EditExamData + */ + 'exam_description'?: string; + /** + * + * @type {number} + * @memberof EditExamData + */ + 'exam_id'?: number; + /** + * + * @type {string} + * @memberof EditExamData + */ + 'exam_title'?: string; + /** + * + * @type {boolean} + * @memberof EditExamData + */ + 'is_public'?: boolean; + /** + * + * @type {string} + * @memberof EditExamData + */ + 'price'?: string; +} +/** + * + * @export + * @interface EditExamResult + */ +export interface EditExamResult { + /** + * + * @type {number} + * @memberof EditExamResult + */ + 'course_id'?: number; + /** + * + * @type {string} + * @memberof EditExamResult + */ + 'created_at'?: string; + /** + * + * @type {string} + * @memberof EditExamResult + */ + 'created_by'?: string; + /** + * + * @type {number} + * @memberof EditExamResult + */ + 'duration'?: number; + /** + * + * @type {string} + * @memberof EditExamResult + */ + 'exam_date'?: string; + /** + * + * @type {string} + * @memberof EditExamResult + */ + 'exam_description'?: string; + /** + * + * @type {number} + * @memberof EditExamResult + */ + 'exam_id'?: number; + /** + * + * @type {string} + * @memberof EditExamResult + */ + 'exam_title'?: string; + /** + * + * @type {boolean} + * @memberof EditExamResult + */ + 'is_public'?: boolean; + /** + * + * @type {string} + * @memberof EditExamResult + */ + 'price'?: string; +} +/** + * + * @export + * @interface EditExamV1200Response + */ +export interface EditExamV1200Response { + /** + * + * @type {EndpointError} + * @memberof EditExamV1200Response + */ + 'error'?: EndpointError; + /** + * + * @type {EditExamResult} + * @memberof EditExamV1200Response + */ + 'result'?: EditExamResult; + /** + * + * @type {boolean} + * @memberof EditExamV1200Response + */ + 'success'?: boolean; +} /** * * @export @@ -3449,6 +3596,49 @@ export const ExamApiAxiosParamCreator = function (configuration?: Configuration) + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(data, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Allows the user to edit an exam. + * @summary Edit an exam + * @param {string} authorization Authorization token + * @param {EditExamData} data Data needed to edit an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editExamV1: async (authorization: string, data: EditExamData, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'authorization' is not null or undefined + assertParamExists('editExamV1', 'authorization', authorization) + // verify required parameter 'data' is not null or undefined + assertParamExists('editExamV1', 'data', data) + const localVarPath = `/api/v1/exam/edit`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (authorization != null) { + localVarHeaderParameter['Authorization'] = String(authorization); + } + + + localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -3757,6 +3947,20 @@ export const ExamApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ExamApi.createExamV1']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Allows the user to edit an exam. + * @summary Edit an exam + * @param {string} authorization Authorization token + * @param {EditExamData} data Data needed to edit an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async editExamV1(authorization: string, data: EditExamData, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.editExamV1(authorization, data, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExamApi.editExamV1']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Allows the user to get information about an exam. * @summary Get information about an exam @@ -3873,6 +4077,17 @@ export const ExamApiFactory = function (configuration?: Configuration, basePath? createExamV1(authorization: string, data: CreateExamData, options?: any): AxiosPromise { return localVarFp.createExamV1(authorization, data, options).then((request) => request(axios, basePath)); }, + /** + * Allows the user to edit an exam. + * @summary Edit an exam + * @param {string} authorization Authorization token + * @param {EditExamData} data Data needed to edit an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editExamV1(authorization: string, data: EditExamData, options?: any): AxiosPromise { + return localVarFp.editExamV1(authorization, data, options).then((request) => request(axios, basePath)); + }, /** * Allows the user to get information about an exam. * @summary Get information about an exam @@ -3975,6 +4190,19 @@ export class ExamApi extends BaseAPI { return ExamApiFp(this.configuration).createExamV1(authorization, data, options).then((request) => request(this.axios, this.basePath)); } + /** + * Allows the user to edit an exam. + * @summary Edit an exam + * @param {string} authorization Authorization token + * @param {EditExamData} data Data needed to edit an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExamApi + */ + public editExamV1(authorization: string, data: EditExamData, options?: RawAxiosRequestConfig) { + return ExamApiFp(this.configuration).editExamV1(authorization, data, options).then((request) => request(this.axios, this.basePath)); + } + /** * Allows the user to get information about an exam. * @summary Get information about an exam diff --git a/src/apiClient.ts b/src/apiClient.ts index e97e80a..b5d3468 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -1,4 +1,5 @@ +import { sha256 } from 'js-sha256'; import { UserApi, Configuration as APIConfiguration, @@ -32,8 +33,12 @@ import { CreateExamData, CreateExamResult, ExamApi, + EditExamData, + EditExamResult, + GetExamInfoResult, } from './api'; import { canParseAsNumber } from './utils/textUtils'; +import { SupportedTranslations } from './translations/translationSwitcher'; class ExamSphereAPIClient extends UserApi { /** The Client's RID parameter. Automatically generated on startup. */ @@ -52,6 +57,7 @@ class ExamSphereAPIClient extends UserApi { /** The currently logged-in user's role. */ public role?: UserRole; + public userLanguage?: SupportedTranslations = 'en'; private topicApi: TopicApi; private courseApi: CourseApi; @@ -73,12 +79,19 @@ class ExamSphereAPIClient extends UserApi { * @returns the generated Client RID. */ private generateClientRId(): string { + let rid = this.readItem('ExamSphere_clientRId'); + if (rid) { + return rid; + } + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const length = Math.floor(Math.random() * (16 - 8 + 1)) + 8; let result = ''; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * characters.length)); } + + this.storeItem('ExamSphere_clientRId', result); return result; } @@ -107,8 +120,25 @@ class ExamSphereAPIClient extends UserApi { * or security, we can add it in this method. */ private storeTokens(): void { - localStorage.setItem('ExamSphere_accessToken', this.accessToken!); - localStorage.setItem('ExamSphere_refreshToken', this.refreshToken!); + this.storeItem('ExamSphere_accessToken', this.accessToken!); + this.storeItem('ExamSphere_refreshToken', this.refreshToken!); + } + + private hash_key(value: string): string { + return sha256(value); + } + + /** + * Stores an item in the local storage. + * @param key The key of the item to store. + * @param value The value of the item to store. + */ + private storeItem(key: string, value: string): void { + localStorage.setItem(this.hash_key(key), value); + } + + private readItem(key: string): string | undefined { + return localStorage.getItem(this.hash_key(key)) ?? undefined; } /** @@ -127,8 +157,19 @@ class ExamSphereAPIClient extends UserApi { * or security, we can add it in this method. */ private readTokens(): void { - this.accessToken = localStorage.getItem('ExamSphere_accessToken') ?? undefined; - this.refreshToken = localStorage.getItem('ExamSphere_refreshToken') ?? undefined + this.accessToken = this.readItem('ExamSphere_accessToken'); + this.refreshToken = this.readItem('ExamSphere_refreshToken'); + this.userLanguage = this.readItem('ExamSphere_userLanguage') as SupportedTranslations ?? 'en'; + } + + /** + * Switches the language of the app. + * @todo Make this method send the user settings to backend. + * @param lang The language to switch to. + */ + public async setAppLanguage(lang: SupportedTranslations): Promise { + this.userLanguage = lang; + this.storeItem('ExamSphere_userLanguage', lang); } /** @@ -217,6 +258,21 @@ class ExamSphereAPIClient extends UserApi { return userInfo } + public async getExamInfo(examId: number): Promise { + if (!this.isLoggedIn()) { + throw new Error("Not logged in"); + } + + let examInfo = (await this.examApi.getExamInfoV1(`Bearer ${this.accessToken}`, examId))?.data.result; + if (!examInfo) { + // we shouldn't reach here, because if there is an error somewhere, + // it should have already been thrown by the API client + throw new Error("Failed to get exam info"); + } + + return examInfo; + } + public async getCourseInfo(courseId: number): Promise { if (!this.isLoggedIn()) { throw new Error("Not logged in"); @@ -238,7 +294,7 @@ class ExamSphereAPIClient extends UserApi { * @returns True if the field can be edited, false otherwise. */ public canUserFieldBeEdited(fieldName: string): boolean { - return fieldName !== "user_id" && + return fieldName !== "user_id" && fieldName !== "role" && fieldName !== "course_id"; } @@ -283,6 +339,26 @@ class ExamSphereAPIClient extends UserApi { return editCourseResult; } + public async editExam(examData: EditExamData): Promise { + if (!this.isLoggedIn()) { + throw new Error("Not logged in"); + } + + examData.exam_id = parseInt(examData.exam_id as any); + if (isNaN(examData.exam_id)) { + throw new Error("Invalid exam ID"); + } + + let editExamResult = (await this.examApi.editExamV1(`Bearer ${this.accessToken}`, examData))?.data.result; + if (!editExamResult) { + // we shouldn't reach here, because if there is an error somewhere, + // it should have already been thrown by the API client + throw new Error("Failed to edit exam"); + } + + return editExamResult; + } + public async confirmAccount(confirmData: ConfirmAccountData): Promise { let confirmResult = (await this.confirmAccountV1(confirmData))?.data.result; if (confirmResult === undefined) { @@ -432,6 +508,14 @@ class ExamSphereAPIClient extends UserApi { throw new Error("Not logged in"); } + if (typeof data.course_id !== 'number') { + if (canParseAsNumber(data.course_id)) { + data.course_id = parseInt(data.course_id as any); + } else { + throw new Error("Invalid course ID"); + } + } + let createExamResult = (await this.examApi.createExamV1(`Bearer ${this.accessToken}`, data))?.data.result; if (!createExamResult) { // we shouldn't reach here, because if there is an error somewhere, @@ -461,6 +545,10 @@ class ExamSphereAPIClient extends UserApi { return fieldName === "role"; } + public isFieldDate(fieldName: string): boolean { + return fieldName.endsWith("_at") || fieldName.endsWith("_date"); + } + /** * Checks if the current logged-in user is the owner of the platform. * @returns True if the user is the owner of the platform. @@ -498,7 +586,7 @@ class ExamSphereAPIClient extends UserApi { } public canCreateTargetRole(targetRole: UserRole): boolean { - if (targetRole === UserRole.UserRoleOwner || + if (targetRole === UserRole.UserRoleOwner || UserRole.UserRoleUnknown) { return false; } diff --git a/src/components/containers/dashboardContainer.tsx b/src/components/containers/dashboardContainer.tsx index 0fffd3b..cd73d36 100644 --- a/src/components/containers/dashboardContainer.tsx +++ b/src/components/containers/dashboardContainer.tsx @@ -25,7 +25,7 @@ const DashboardContainer: React.FC = ({ ...props }) => padding: '20px', overflowX: 'hidden', position: 'relative', - height:'100vh', + minHeight:'100vh', backgroundColor:'#f5f5f5', ...props.style } diff --git a/src/components/containers/loginContainer.tsx b/src/components/containers/loginContainer.tsx index 2177f10..3be5cb5 100644 --- a/src/components/containers/loginContainer.tsx +++ b/src/components/containers/loginContainer.tsx @@ -5,7 +5,7 @@ const LoginContainer = styled.div` display: flex; justify-content: center; align-items: center; - height: 100vh; + min-height: 100vh; background-image: url(${backgroundImage}); background-size: cover; background-position: center; diff --git a/src/components/date/ModernDatePicker.tsx b/src/components/date/ModernDatePicker.tsx index 025fac5..e119201 100644 --- a/src/components/date/ModernDatePicker.tsx +++ b/src/components/date/ModernDatePicker.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { TextField, ThemeProvider, createTheme } from '@mui/material'; -import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import moment from 'moment-jalaali'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { AdapterMomentJalaali } from '@mui/x-date-pickers/AdapterMomentJalaali'; +import { MobileDateTimePicker } from '@mui/x-date-pickers'; +import { AppCalendarType } from '../../utils/AppCalendarTypes'; const theme = createTheme({ palette: { @@ -14,19 +15,47 @@ const theme = createTheme({ }, }); -const ModernDateTimePicker: React.FC = () => { - moment.loadPersian({ dialect: 'persian-modern' }); - const [selectedDateTime, setSelectedDateTime] = React.useState(null); +interface ModernDateTimePickerProps { + label: string; + value?: Date | number; + onChange: (newValue: any) => void; + dateType?: AppCalendarType; + disablePast?: boolean; + disabled?: boolean; +} + +moment.loadPersian({ dialect: 'persian-modern' }); + +const ModernDateTimePicker: React.FC = ({ ...props }) => { + let momentValue: moment.Moment | null = null; + if (props.value) { + const valueType = typeof props.value; + if (valueType === 'number') { + momentValue = moment.unix(props.value as number); + } else { + momentValue = moment(props.value); + } + } + const [selectedDateTime, setSelectedDateTime] = React.useState(momentValue); + + const adapterType = props.dateType === 'jalali' ? AdapterMomentJalaali : AdapterDateFns; return ( - {/* */} - - + + } + } + label={props.label} value={selectedDateTime as any} onChange={(newValue) => { setSelectedDateTime(newValue); + props.onChange(newValue); }} /> diff --git a/src/components/footers/AppFooter.tsx b/src/components/footers/AppFooter.tsx new file mode 100644 index 0000000..94823b4 --- /dev/null +++ b/src/components/footers/AppFooter.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Box, Container, IconButton, Typography, Select, MenuItem } from '@mui/material'; +import { Telegram, GitHub, Twitter } from '@mui/icons-material'; +import { CurrentAppTranslation } from '../../translations/appTranslation'; + +interface AppFooterProps { + setAppLanguage: (lang: string) => void; +} + +const AppFooter: React.FC = ({ ...props }) => { + const handleLanguageChange = (event: React.ChangeEvent<{ value: any }>) => { + props.setAppLanguage(event.target.value); + }; + + return ( + + + + + + + + + + + + + + + + + + © {new Date().getFullYear()} {CurrentAppTranslation.CopyrightText} + + + + ); +}; + +export default AppFooter; \ No newline at end of file diff --git a/src/components/menus/selectMenu.tsx b/src/components/menus/selectMenu.tsx index a8169a9..80a1392 100644 --- a/src/components/menus/selectMenu.tsx +++ b/src/components/menus/selectMenu.tsx @@ -14,6 +14,7 @@ interface SelectMenuProps { name: string; labelId?: string; labelText: string; + disabled?: boolean; } const SelectMenu: React.FC = ({ ...props }) => { @@ -32,6 +33,7 @@ const SelectMenu: React.FC = ({ ...props }) => { label={props.labelText} name={props.name} onChange={(e) => props.onChange(e as any)} + disabled={props.disabled} > {props.options.map((option, index) => ( {option} diff --git a/src/components/rendering/RenderAllFields.tsx b/src/components/rendering/RenderAllFields.tsx index e07e557..aada634 100644 --- a/src/components/rendering/RenderAllFields.tsx +++ b/src/components/rendering/RenderAllFields.tsx @@ -1,15 +1,124 @@ import apiClient from '../../apiClient'; import { CurrentAppTranslation } from '../../translations/appTranslation'; -import { TextField } from '@mui/material'; +import { Checkbox, FormControlLabel, Grid, TextField, Typography } from '@mui/material'; import SelectMenu from '../../components/menus/selectMenu'; +import ModernDateTimePicker from '../date/ModernDatePicker'; +interface RenderAllFieldsProps { + data: any; + handleInputChange: any; + isEditing?: boolean; + disablePast?: boolean; + excludedFields?: string[]; + noEditFields?: string[]; +} + +const RenderAllFields = (props: RenderAllFieldsProps) => { + const { data, handleInputChange } = props; -const RenderAllFields = (data: any, handleInputChange: any) => { // we will be using Object.keys to get all the keys in the data object return Object.keys(data).map((field) => { + if (props.excludedFields?.includes(field)) { + return null; + } + + const typeName = typeof data[field as keyof (typeof data)]; + const isEditing = (props.isEditing ?? true) && !props.noEditFields?.includes(field); + + if (apiClient.isFieldDate(field)) { + if (!isEditing) { + return ( + + + + {`${CurrentAppTranslation[field as keyof (typeof CurrentAppTranslation)]}: `} + + {new Date((data[field] * 1000) as number).toLocaleDateString('en-US', { + weekday: 'long', // "Monday" + year: 'numeric', // "2003" + month: 'long', // "July" + day: 'numeric' // "26" + })} + + + ) + } + return ( + { + handleInputChange({ + target: { + name: field, + value: newValue + } + }); + }} + /> + ) + } + + if (typeName === 'boolean') { + if (!isEditing) { + return ( + + + + {`${CurrentAppTranslation[field as keyof (typeof CurrentAppTranslation)]}: `} + + {data[field] ? CurrentAppTranslation.YesText : CurrentAppTranslation.NoText} + + + ) + } + + return ( + { + handleInputChange({ + target: { + name: field, + value: e.target.checked + } + }); + }} + color="primary" + /> + } + label={CurrentAppTranslation[field as keyof (typeof CurrentAppTranslation)]} + /> + ); + } + // check if type of field is enum if (apiClient.isFieldEnum(field)) { + if (!isEditing) { + return ( + + + + {`${CurrentAppTranslation[field as keyof (typeof CurrentAppTranslation)]}: `} + + {CurrentAppTranslation[data[field] as keyof (typeof CurrentAppTranslation)]} + + + ) + } + return ( { name={field} value={data[field as keyof (typeof data)] ?? ''} onChange={handleInputChange} + disabled={!isEditing} options={Object.values(typeof field).filter( enumValue => enumValue !== undefined && enumValue !== '')} /> ); } - const typeName = typeof data[field as keyof (typeof data)]; // check if type of key is string if (typeName === 'string' || typeName === 'number') { + if (!isEditing) { + return ( + + + + {`${CurrentAppTranslation[field as keyof (typeof CurrentAppTranslation)]}: `} + + {data[field]} + + + ) + } + return ( - + + ); diff --git a/src/pages/confirmAccountRedirectPage.tsx b/src/pages/confirmAccountRedirectPage.tsx index 948fce0..847f2e5 100644 --- a/src/pages/confirmAccountRedirectPage.tsx +++ b/src/pages/confirmAccountRedirectPage.tsx @@ -8,6 +8,7 @@ import { CurrentAppTranslation } from '../translations/appTranslation'; import useAppSnackbar from '../components/snackbars/useAppSnackbars'; import { extractErrorDetails } from '../utils/errorUtils'; import { getTextInputTypeFromFieldName } from '../utils/textUtils'; +import { autoSetWindowTitle } from '../utils/commonUtils'; interface ConfirmationRequiredFields { user_id: string; @@ -36,9 +37,7 @@ const ConfirmAccountRedirectPage = () => { const snackbar = useAppSnackbar(); useEffect(() => { - if (window.location.pathname === '/confirmAccountRedirect') { - document.title = CurrentAppTranslation.ConfirmAccountText; - } + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleChange = (e: any) => { diff --git a/src/pages/createCoursePage.tsx b/src/pages/createCoursePage.tsx index 303a5e6..be40b62 100644 --- a/src/pages/createCoursePage.tsx +++ b/src/pages/createCoursePage.tsx @@ -10,7 +10,7 @@ import { CurrentAppTranslation } from '../translations/appTranslation'; import useAppSnackbar from '../components/snackbars/useAppSnackbars'; import { extractErrorDetails } from '../utils/errorUtils'; import RenderAllFields from '../components/rendering/RenderAllFields'; -import { getFieldOf } from '../utils/commonUtils'; +import { autoSetWindowTitle, getFieldOf } from '../utils/commonUtils'; const CreateCoursePage: React.FC = () => { @@ -43,9 +43,7 @@ const CreateCoursePage: React.FC = () => { }; useEffect(() => { - if (window.location.pathname === '/createCourse') { - document.title = CurrentAppTranslation.CreateCourseText; - } + autoSetWindowTitle(); } , []); // eslint-disable-line react-hooks/exhaustive-deps return ( @@ -53,7 +51,12 @@ const CreateCoursePage: React.FC = () => { {CurrentAppTranslation.CreateNewCourseText} - {RenderAllFields(createCourseData, handleInputChange)} + {RenderAllFields({ + data: createCourseData, + handleInputChange :handleInputChange, + disablePast: false, + isEditing: true + })} {CurrentAppTranslation.CreateCourseButtonText} diff --git a/src/pages/createExamPage.tsx b/src/pages/createExamPage.tsx index 0f14b48..cd674ec 100644 --- a/src/pages/createExamPage.tsx +++ b/src/pages/createExamPage.tsx @@ -10,7 +10,8 @@ import { CurrentAppTranslation } from '../translations/appTranslation'; import useAppSnackbar from '../components/snackbars/useAppSnackbars'; import { extractErrorDetails } from '../utils/errorUtils'; import RenderAllFields from '../components/rendering/RenderAllFields'; -import ModernDateTimePicker from '../components/date/ModernDatePicker'; +import { getUTCUnixTimestamp } from '../utils/timeUtils'; +import { autoSetWindowTitle, getFieldOf } from '../utils/commonUtils'; const CreateExamPage: React.FC = () => { @@ -26,15 +27,22 @@ const CreateExamPage: React.FC = () => { const snackbar = useAppSnackbar(); useEffect(() => { - if (window.location.pathname === '/createExam') { - document.title = CurrentAppTranslation.CreateExamText; - } + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleInputChange = (e: React.ChangeEvent) => { + let targetValue: any = e.target.value; + if (getFieldOf(targetValue, "_d") instanceof Date) { + targetValue = getFieldOf(targetValue, "_d"); + } + + if (targetValue instanceof Date) { + // convert to UTC + targetValue = getUTCUnixTimestamp(targetValue); + } setCreateExamData({ ...createExamData, - [e.target.name]: e.target.value, + [e.target.name]: targetValue, }); }; @@ -42,8 +50,13 @@ const CreateExamPage: React.FC = () => { e.preventDefault(); try { - await apiClient.createExam(createExamData); - snackbar.success(CurrentAppTranslation.TopicCreatedSuccessfullyText); + let result = await apiClient.createExam(createExamData); + snackbar.success(CurrentAppTranslation.ExamCreatedSuccessfullyText); + + // redirect the user to the exam page in 3 seconds + setTimeout(() => { + window.location.href = `/examInfo?examId=${result.exam_id}`; + }, 3000); } catch (error: any) { const [errCode, errMessage] = extractErrorDetails(error); snackbar.error(`Failed (${errCode}): ${errMessage}`); @@ -55,9 +68,14 @@ const CreateExamPage: React.FC = () => { {CurrentAppTranslation.CreateNewExamText} - {RenderAllFields(createExamData, handleInputChange)} - - {CurrentAppTranslation.CreateCourseButtonText} + {RenderAllFields({ + data: createExamData, + handleInputChange, + isEditing: true, + })} + + {CurrentAppTranslation.CreateCourseButtonText} + diff --git a/src/pages/createTopicPage.tsx b/src/pages/createTopicPage.tsx index 6a1b134..e30fb96 100644 --- a/src/pages/createTopicPage.tsx +++ b/src/pages/createTopicPage.tsx @@ -10,6 +10,7 @@ import { CurrentAppTranslation } from '../translations/appTranslation'; import { TextField } from '@mui/material'; import useAppSnackbar from '../components/snackbars/useAppSnackbars'; import { extractErrorDetails } from '../utils/errorUtils'; +import { autoSetWindowTitle } from '../utils/commonUtils'; const CreateTopicPage: React.FC = () => { const [createTopicData, setUserInfo] = useState({ @@ -34,9 +35,7 @@ const CreateTopicPage: React.FC = () => { }; useEffect(() => { - if (window.location.pathname === '/createTopic') { - document.title = CurrentAppTranslation.CreateTopicText; - } + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/src/pages/createUserPage.tsx b/src/pages/createUserPage.tsx index 731a956..99e3277 100644 --- a/src/pages/createUserPage.tsx +++ b/src/pages/createUserPage.tsx @@ -128,7 +128,7 @@ const CreateUserPage: React.FC = () => { color="primary" /> } - label="Send email confirmation to user" + label={CurrentAppTranslation.SendEmailToUseText} /> {CurrentAppTranslation.CreateUserButtonText} diff --git a/src/pages/dashboardPage.tsx b/src/pages/dashboardPage.tsx index f9a3acc..a54ad9b 100644 --- a/src/pages/dashboardPage.tsx +++ b/src/pages/dashboardPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; import apiClient from '../apiClient'; import DashboardContainer from '../components/containers/dashboardContainer'; -import { CurrentAppTranslation } from '../translations/appTranslation'; +import { autoSetWindowTitle } from '../utils/commonUtils'; const MainContent = styled.div` display: flex; @@ -46,10 +46,7 @@ const DashboardPage: React.FC = () => { useEffect(() => { fetchUserInfo(); - - if (window.location.pathname === '/dashboard') { - document.title = CurrentAppTranslation.DashboardText; - } + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/src/pages/examInfoPage.tsx b/src/pages/examInfoPage.tsx index 7510d41..1168057 100644 --- a/src/pages/examInfoPage.tsx +++ b/src/pages/examInfoPage.tsx @@ -1,41 +1,53 @@ import { useState, useEffect } from 'react'; -import { CircularProgress, Container, Paper, Box, Typography, Avatar, Grid, TextField, Button } from '@mui/material'; +import { CircularProgress, Container, Paper, Box, Typography, Grid, Button } from '@mui/material'; import apiClient from '../apiClient'; -import { EditUserData } from '../api'; +import { EditExamData } from '../api'; import DashboardContainer from '../components/containers/dashboardContainer'; import { CurrentAppTranslation } from '../translations/appTranslation'; import useAppSnackbar from '../components/snackbars/useAppSnackbars'; import { extractErrorDetails } from '../utils/errorUtils'; +import { getFieldOf } from '../utils/commonUtils'; +import { getUTCUnixTimestamp } from '../utils/timeUtils'; +import RenderAllFields from '../components/rendering/RenderAllFields'; -const CourseInfoPage = () => { - const [userData, setUserData] = useState({ - user_id: '', - full_name: '', - email: '', +const ExamInfoPage = () => { + const [examData, setExamData] = useState({ + exam_id: 0, + course_id: 0, + exam_title: '', + exam_description: '', + price: '0T', + duration: 60, + exam_date: 0, + is_public: false, }); const [isEditing, setIsEditing] = useState(false); const [isUserNotFound, setIsUserNotFound] = useState(false); const snackbar = useAppSnackbar(); - useEffect(() => { - fetchUserInfo(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const fetchUserInfo = async () => { - // the user id is passed like /userInfo?userId=123 + const fetchExamInfo = async () => { + // the exam id is passed like /examInfo?examId=123 const urlSearch = new URLSearchParams(window.location.search); - const targetUserId = urlSearch.get('userId'); - if (!targetUserId) { - window.location.href = '/searchUser'; + const targetExamId = parseInt(urlSearch.get('examId') ?? ''); + const isEditingQuery = urlSearch.get('edit'); + if (!targetExamId || isNaN(targetExamId)) { + window.location.href = '/searchExam'; return; } + setIsEditing(isEditingQuery === '1' || isEditingQuery === 'true'); + try { - const result = await apiClient.getUserInfo(targetUserId); - setUserData({ - user_id: result.user_id, - full_name: result.full_name, - email: result.email, + const result = await apiClient.getExamInfo(targetExamId); + setExamData({ + exam_id: result.exam_id, + course_id: result.course_id, + exam_title: result.exam_title, + exam_description: result.exam_description, + price: result.price, + duration: result.duration, + exam_date: getUTCUnixTimestamp(new Date(result.exam_date!)), + is_public: result.is_public, }); } catch (error: any) { const [errCode, errMessage] = extractErrorDetails(error); @@ -45,34 +57,56 @@ const CourseInfoPage = () => { } }; - const handleEdit = () => setIsEditing(!isEditing); + useEffect(() => { + fetchExamInfo(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleEdit = () => { + window.history.pushState( + `examInfo_examId_${examData.exam_id}`, + "Exam Info", + `/examInfo?examId=${encodeURIComponent(examData.exam_id!)}&edit=${isEditing ? '0' : '1'}`, + ); + setIsEditing(!isEditing); + } const handleChange = (e: any) => { - setUserData({ ...userData, [e.target.name]: e.target.value }); + let targetValue: any = e.target.value; + if (getFieldOf(targetValue, "_d") instanceof Date) { + targetValue = getFieldOf(targetValue, "_d"); + } + + if (targetValue instanceof Date) { + // convert to UTC + targetValue = getUTCUnixTimestamp(targetValue); + } + setExamData({ + ...examData, + [e.target.name]: targetValue, + }); }; const handleSave = async () => { try { - const result = await apiClient.editUser(userData); - const updatedUserData = { ...userData }; + const result = await apiClient.editExam(examData); + const updatedUserData: any = { ...examData }; Object.keys(result).forEach(key => { - if (key in userData) { + if (key in examData) { updatedUserData[key as keyof (typeof updatedUserData)] = result[key as keyof (typeof result)]; } }); - setUserData(updatedUserData); + setExamData(updatedUserData); setIsEditing(false); } catch (error: any) { - const errCode = error.response?.data?.error?.code; - const errMessage = error.response?.data?.error?.message; + const [errCode, errMessage] = extractErrorDetails(error); snackbar.error(`Failed (${errCode}) - ${errMessage}`); return; } }; - if (!userData) { + if (!examData) { // maybe return better stuff here in future? return ( @@ -99,27 +133,14 @@ const CourseInfoPage = () => { {isEditing ? CurrentAppTranslation.SaveText : CurrentAppTranslation.EditText} - - {Object.keys(userData).map((field) => ( - - {isEditing && apiClient.canUserFieldBeEdited(field) ? ( - - ) : ( - - - {CurrentAppTranslation[field as keyof (typeof CurrentAppTranslation)]}: - {userData[field as keyof (typeof userData)]} - - )} - - ))} + {RenderAllFields({ + data: examData, + handleInputChange: handleChange, + isEditing: isEditing, + disablePast: true, + noEditFields: ['exam_id'], + })} @@ -127,4 +148,4 @@ const CourseInfoPage = () => { ); }; -export default CourseInfoPage; \ No newline at end of file +export default ExamInfoPage; \ No newline at end of file diff --git a/src/pages/loginPage.tsx b/src/pages/loginPage.tsx index 938287f..c64491f 100644 --- a/src/pages/loginPage.tsx +++ b/src/pages/loginPage.tsx @@ -96,7 +96,11 @@ const Login = () => { width: '100%', marginBottom: '10px' }}> -

{CurrentAppTranslation.WelcomeToPlatformText}

+

+ {CurrentAppTranslation.WelcomeToPlatformText} +

{ if (!courses || courses.length === 0) { @@ -116,9 +117,7 @@ const SearchCoursePage = () => { handleSearch(page); } - if (window.location.pathname === '/searchCourse') { - document.title = CurrentAppTranslation.SearchCoursesText; - } + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/src/pages/searchTopicPage.tsx b/src/pages/searchTopicPage.tsx index 0bfcbff..0ceb0cc 100644 --- a/src/pages/searchTopicPage.tsx +++ b/src/pages/searchTopicPage.tsx @@ -26,6 +26,7 @@ import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/material/styles'; import useAppSnackbar from '../components/snackbars/useAppSnackbars'; import { extractErrorDetails } from '../utils/errorUtils'; +import { autoSetWindowTitle } from '../utils/commonUtils'; interface DeleteDialogueProps { target_topic_id: number; @@ -202,9 +203,7 @@ const SearchTopicPage = () => { handleSearch(); } - if (window.location.pathname === '/searchTopic') { - document.title = CurrentAppTranslation.SearchTopicsText; - } + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/src/pages/searchUserPage.tsx b/src/pages/searchUserPage.tsx index b90dcea..8389e5e 100644 --- a/src/pages/searchUserPage.tsx +++ b/src/pages/searchUserPage.tsx @@ -17,6 +17,7 @@ import apiClient from '../apiClient'; import DashboardContainer from '../components/containers/dashboardContainer'; import { timeAgo } from '../utils/timeUtils'; import { CurrentAppTranslation } from '../translations/appTranslation'; +import { autoSetWindowTitle } from '../utils/commonUtils'; const RenderUsersList = (users: SearchedUserInfo[] | undefined, forEdit: boolean = false) => { if (!users || users.length === 0) { @@ -120,9 +121,7 @@ const SearchUserPage = () => { handleSearch(page); } - if (window.location.pathname === '/searchUser') { - document.title = CurrentAppTranslation.SearchUsersText; - } + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/src/pages/userInfoPage.tsx b/src/pages/userInfoPage.tsx index 10dd642..abd7e89 100644 --- a/src/pages/userInfoPage.tsx +++ b/src/pages/userInfoPage.tsx @@ -64,8 +64,7 @@ const UserInfoPage = () => { setUserData(updatedUserData); setIsEditing(false); } catch (error: any) { - const errCode = error.response?.data?.error?.code; - const errMessage = error.response?.data?.error?.message; + const [errCode, errMessage] = extractErrorDetails(error); snackbar.error(`Failed (${errCode}) - ${errMessage}`); return; } diff --git a/src/translations/appTranslation.ts b/src/translations/appTranslation.ts index 74426fc..a92e0f0 100644 --- a/src/translations/appTranslation.ts +++ b/src/translations/appTranslation.ts @@ -1,12 +1,24 @@ +import { AppCalendarType } from "../utils/AppCalendarTypes"; export class AppTranslationBase { + ShortLang: string = "en"; + + //#region Style Attributes + direction: "ltr" | "rtl" = "ltr"; + textAlign: string = "left"; + float: string = "left"; + + //#endregion //#region common UI translations ExamSphereTitleText: string = "---ExamSphere---"; WelcomeToPlatformText: string = "Welcome to ExamSphere!"; + YesText: string = "Yes"; + NoText: string = "No"; LoginText: string = "Login"; LoadingText: string = "Loading..."; + PleaseWaitText: string = "Please wait while we load the page for you..."; ProfileText: string = "Profile"; DashboardText: string = "Dashboard"; EditProfileText: string = "Edit Profile"; @@ -14,15 +26,18 @@ export class AppTranslationBase { ManageUsersText: string = "Manage Users"; AddUserText: string = "Add User"; SearchUsersText: string = "Search Users"; + SearchUserText: string = "Search Users"; EditUserInfoText: string = "Edit User Info"; ChangeUserPasswordText: string = "Change User Password"; ManageTopicsText: string = "Manage Topics"; AddTopicText: string = "Add Topic"; SearchTopicsText: string = "Search Topics"; + SearchTopicText: string = "Search Topics"; EditTopicsText: string = "Edit Topic"; ManageCoursesText: string = "Manage Courses"; AddCourseText: string = "Add Course"; - SearchCoursesText: string = "Search Courses"; + SearchCoursesText: string = "Search Course"; + SearchCourseText: string = "Search Courses"; EditCourseText: string = "Edit Course"; ManageExamsText: string = "Manage Exams"; AddExamText: string = "Add Exam"; @@ -38,6 +53,8 @@ export class AppTranslationBase { EditText: string = "Edit"; UserInformationText: string = "User Information"; CourseInformationText: string = "Course Information"; + ExamInfoText: string = "Exam Info"; + UserInfoText: string = "User Info"; ConfirmYourAccountText: string = "Confirm Your Account"; ConfirmAccountText: string = "Confirm Account"; CreateNewUserText: string = "Create New User"; @@ -49,6 +66,7 @@ export class AppTranslationBase { CreateExamText: string = "Create Exam"; DeleteTopicButtonText: string = "Delete Topic"; CancelButtonText: string = "Cancel"; + SendEmailToUseText: string = "Send email confirmation to user"; // System messages AreYouSureDeleteTopicText: string = "Are you sure you want to delete this topic?"; @@ -61,16 +79,19 @@ export class AppTranslationBase { CourseNotFoundText: string = "This course doesn't seem to exist..."; UserCreatedSuccessfullyText: string = "User created successfully"; TopicCreatedSuccessfullyText: string = "Topic created successfully"; + ExamCreatedSuccessfullyText: string = "Exam created successfully"; CourseCreatedSuccessfullyText: string = "Course created successfully"; NoResultsFoundText: string = "No results found, try changing your search query"; SearchSomethingForTopicsText: string = "Search a query or enter empty to list all topics"; EnterSearchForEdit: string = "Enter search query to edit the user"; + CopyrightText: string = "ALiwoto. All rights reserved."; //#endregion //#region API response fields translations + setup_completed: string = "Send email confirmation to user"; is_public: string = "Is Public"; exam_date: string = "Exam Date"; duration: string = "Duration (minutes)"; @@ -82,6 +103,7 @@ export class AppTranslationBase { course_name: string = "Course Name"; topic_name: string = "Topic Name"; topic_id: string = "Topic ID"; + exam_id: string = "Exam ID"; user_id: string = "User ID"; new_password: string = "New Password"; repeat_password: string = "Repeat Password"; @@ -99,6 +121,11 @@ export class AppTranslationBase { //#endregion + //#region System Behaviors + CalendarType: AppCalendarType = "gregorian"; + + //#endregion + } export var CurrentAppTranslation = new AppTranslationBase(); diff --git a/src/translations/faTranslation.ts b/src/translations/faTranslation.ts index dd9d91f..cbfda86 100644 --- a/src/translations/faTranslation.ts +++ b/src/translations/faTranslation.ts @@ -1,14 +1,26 @@ +import { AppCalendarType } from "../utils/AppCalendarTypes"; import { AppTranslationBase } from "./appTranslation"; class FaTranslation extends AppTranslationBase { + ShortLang: string = "fa"; + + //#region Style Attributes + direction: "ltr" | "rtl" = "rtl"; + textAlign: string = "right"; + float: string = "right"; + + //#endregion //#region common UI translations - ExamSphereTitleText: string = "---کره ی آزمون---"; - WelcomeToPlatformText: string = "به کره ی آزمون خوش آمدید!"; + ExamSphereTitleText: string = "---پلتفرم آزمون های آنلاین---"; + WelcomeToPlatformText: string = "به پلتفرم آزمون های آنلاین خوش آمدید!"; + YesText: string = "بله"; + NoText: string = "خیر"; LoginText: string = "ورود"; LoadingText: string = "در حال بارگذاری..."; + PleaseWaitText: string = "لطفا منتظر بمانید تا صفحه برای شما بارگذاری شود..."; ProfileText: string = "پروفایل"; DashboardText: string = "داشبورد"; EditProfileText: string = "ویرایش پروفایل"; @@ -16,15 +28,18 @@ class FaTranslation extends AppTranslationBase { ManageUsersText: string = "مدیریت کاربران"; AddUserText: string = "افزودن کاربر"; SearchUsersText: string = "جستجوی کاربران"; + SearchUserText: string = "جستجوی کاربران"; EditUserInfoText: string = "ویرایش اطلاعات کاربر"; ChangeUserPasswordText: string = "تغییر رمز عبور کاربر"; ManageTopicsText: string = "مدیریت موضوعات"; AddTopicText: string = "افزودن موضوع"; SearchTopicsText: string = "جستجوی موضوعات"; + SearchTopicText: string = "جستجوی موضوعات"; EditTopicsText: string = "ویرایش موضوع"; ManageCoursesText: string = "مدیریت دوره ها"; AddCourseText: string = "افزودن دوره"; SearchCoursesText: string = "جستجوی دوره ها"; + SearchCourseText: string = "جستجوی دوره"; EditCourseText: string = "ویرایش دوره"; ManageExamsText: string = "مدیریت آزمون ها"; AddExamText: string = "افزودن آزمون"; @@ -39,6 +54,8 @@ class FaTranslation extends AppTranslationBase { EditText: string = "ویرایش"; UserInformationText: string = "اطلاعات کاربر"; CourseInformationText: string = "اطلاعات دوره"; + ExamInfoText: string = "اطلاعات آزمون"; + UserInfoText: string = "اطلاعات کاربر"; ConfirmYourAccountText: string = "حساب کاربری خود را تایید کنید"; ConfirmAccountText: string = "تایید حساب کاربری"; CreateNewUserText: string = "ایجاد کاربر جدید"; @@ -51,6 +68,7 @@ class FaTranslation extends AppTranslationBase { DeleteTopicButtonText: string = "حذف موضوع"; CreateCourseButtonText: string = "ایجاد دوره"; CancelButtonText: string = "لغو"; + SendEmailToUseText: string = "ارسال ایمیل تایید به کاربر"; // System messages AreYouSureDeleteTopicText: string = "آیا مطمئن هستید که می خواهید این موضوع را حذف کنید؟"; @@ -63,16 +81,19 @@ class FaTranslation extends AppTranslationBase { CourseNotFoundText: string = "این دوره وجود ندارد..."; UserCreatedSuccessfullyText: string = "کاربر با موفقیت ایجاد شد"; TopicCreatedSuccessfullyText: string = "موضوع با موفقیت ایجاد شد"; + ExamCreatedSuccessfullyText: string = "آزمون با موفقیت ایجاد شد"; CourseCreatedSuccessfullyText: string = "دوره با موفقیت ایجاد شد"; NoResultsFoundText: string = "نتیجه ای یافت نشد، تلاش کنید تا جستجوی خود را تغییر دهید"; SearchSomethingForTopicsText: string = "برای جستجو یک کلمه وارد کنید یا خالی بگذارید تا همه موضوعات لیست شوند"; EnterSearchForEdit: string = "برای ویرایش کاربر جستجو کنید"; + CopyrightText: string = "ALiwoto. تمامی حقوق محفوظ است."; //#endregion //#region API response fields translations + setup_completed: string = "ارسال ایمیل تایید به کاربر"; is_public: string = "عمومی"; exam_date: string = "تاریخ آزمون"; duration: string = "مدت زمان (دقیقه)"; @@ -84,6 +105,7 @@ class FaTranslation extends AppTranslationBase { course_name: string = "نام دوره"; topic_name: string = "نام موضوع"; topic_id: string = "شناسه موضوع"; + exam_id: string = "شناسه آزمون"; user_id: string = "شناسه کاربری"; new_password: string = "رمز عبور جدید"; repeat_password: string = "تکرار رمز عبور"; @@ -101,6 +123,10 @@ class FaTranslation extends AppTranslationBase { //#endregion + //#region System Behaviors + CalendarType: AppCalendarType = "jalali"; + + //#endregion }; export default FaTranslation; diff --git a/src/translations/translationSwitcher.ts b/src/translations/translationSwitcher.ts new file mode 100644 index 0000000..caa467f --- /dev/null +++ b/src/translations/translationSwitcher.ts @@ -0,0 +1,19 @@ +import { AppTranslationBase, setAppTranslation } from './appTranslation'; +import FaTranslation from './faTranslation'; + +export type SupportedTranslations = 'fa' | 'en'; + +export function switchAppTranslation(to: SupportedTranslations) { + switch (to) { + case 'fa': + setAppTranslation(new FaTranslation()); + return; + case 'en': + setAppTranslation(new AppTranslationBase()); + return; + default: + setAppTranslation(new AppTranslationBase()); + return; + } +} + diff --git a/src/utils/AppCalendarTypes.ts b/src/utils/AppCalendarTypes.ts new file mode 100644 index 0000000..389a991 --- /dev/null +++ b/src/utils/AppCalendarTypes.ts @@ -0,0 +1,3 @@ + +export type AppCalendarType = 'gregorian' | 'jalali'; + diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts new file mode 100644 index 0000000..8852f30 --- /dev/null +++ b/src/utils/commonUtils.ts @@ -0,0 +1,44 @@ +import { CurrentAppTranslation } from "../translations/appTranslation"; + + +export function getFieldOf(obj: any, fieldName: string): any { + return obj[fieldName] ?? undefined; +} + +export function autoSetWindowTitle(): void { + switch (window.location.pathname) { + case '/confirmAccountRedirect': + document.title = CurrentAppTranslation.ConfirmAccountText; + break; + case '/createCourse': + document.title = CurrentAppTranslation.CreateCourseText; + break; + case '/createExam': + document.title = CurrentAppTranslation.CreateExamText; + break; + case '/createTopic': + document.title = CurrentAppTranslation.CreateTopicText; + break; + case '/dashboard': + document.title = CurrentAppTranslation.DashboardText; + break; + case '/searchCourse': + document.title = CurrentAppTranslation.SearchCourseText; + break; + case '/searchTopic': + document.title = CurrentAppTranslation.SearchTopicText; + break; + case '/searchUser': + document.title = CurrentAppTranslation.SearchUserText; + break; + case '/examInfo': + document.title = CurrentAppTranslation.ExamInfoText; + break; + case '/userInfo': + document.title = CurrentAppTranslation.UserInfoText; + break; + default: + break; + } +} + diff --git a/src/utils/commonUtils.tsx b/src/utils/commonUtils.tsx deleted file mode 100644 index fefe194..0000000 --- a/src/utils/commonUtils.tsx +++ /dev/null @@ -1,5 +0,0 @@ - - -export function getFieldOf(obj: any, fieldName: string): any { - return obj[fieldName] ?? undefined; -} diff --git a/src/utils/timeUtils.ts b/src/utils/timeUtils.ts index 7a39b58..720ff8c 100644 --- a/src/utils/timeUtils.ts +++ b/src/utils/timeUtils.ts @@ -27,3 +27,14 @@ export const timeAgo = (date: string | number | Date): string => { return `${interval} ${unit}${suffix} ago`; }; +export const getUTCUnixTimestamp = (date: Date): number => { + // Create a new Date object set to the UTC time + const utcDate = new Date(date.toUTCString()); + + // Get the UNIX timestamp (in milliseconds) + const unixTimestamp = utcDate.getTime(); + + // Return the UNIX timestamp in seconds + return Math.floor(unixTimestamp / 1000); + } +