main
alex 2024-09-15 00:25:54 +02:00
parent 9be79b1481
commit c40cba88d4
32 changed files with 1855 additions and 795 deletions

111
package-lock.json generated
View File

@ -25,6 +25,9 @@
"deep-chat-react": "^2.0.1",
"i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.6.1",
"marked": "^14.1.2",
"marked-directive": "^1.0.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^13.5.0",
@ -6043,6 +6046,15 @@
"node": ">= 4.0.0"
}
},
"node_modules/attributes-parser": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/attributes-parser/-/attributes-parser-2.2.3.tgz",
"integrity": "sha512-zjOUWt95la8AdUO+kP1GBOonWrV5jy9NjJP+z9tva/DSA6FIzGKcN/gk3tdqQf/pOeB8dkyd3FCPrjhELMmrkg==",
"license": "MIT",
"dependencies": {
"json-loose": "^1.2.4"
}
},
"node_modules/autolinker": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz",
@ -7209,6 +7221,15 @@
"node": ">=10"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -10569,6 +10590,15 @@
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.6.1.tgz",
"integrity": "sha512-rCilMAnlEQNeKOZY1+x8wLM5IpYOj10guGvEpeC59tNjj6MMreLIjIW8D1RclhD3ifLwn6d/Y9HEM1RUE6DSog==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -13621,6 +13651,15 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-loose": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/json-loose/-/json-loose-1.2.4.tgz",
"integrity": "sha512-lwMWNC5pvVI33rhYWmAsmtICWE2IH7euDY/iIPeMFE5AuzAifYgqQrjqSMzwbrFV6MWPs41XD+CajElHI4cZMQ==",
"license": "MIT",
"dependencies": {
"moo": "^0.5.2"
}
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@ -13978,6 +14017,30 @@
"tmpl": "1.0.5"
}
},
"node_modules/marked": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.2.tgz",
"integrity": "sha512-f3r0yqpz31VXiDB/wj9GaOB0a2PRLQl6vJmXiFrniNwjkKdvakqJRULhjFKJpxOchlCRiG5fcacoUZY5Xa6PEQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/marked-directive": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/marked-directive/-/marked-directive-1.0.7.tgz",
"integrity": "sha512-2OilBJg5kSM0b7ijKlmIne8k1eA1YrNAWasw84fmfihgWuOQJFPmI5GzZd3DgM8PSquAKnYx87Qt5j/2Sw7JNA==",
"license": "MIT",
"dependencies": {
"attributes-parser": "^2.2.3"
},
"peerDependencies": {
"marked": ">=7.0.0"
}
},
"node_modules/mdn-data": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
@ -14176,6 +14239,12 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"license": "BSD-3-Clause"
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -14261,6 +14330,48 @@
"tslib": "^2.0.3"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",

View File

@ -20,6 +20,9 @@
"deep-chat-react": "^2.0.1",
"i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.6.1",
"marked": "^14.1.2",
"marked-directive": "^1.0.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^13.5.0",

View File

@ -0,0 +1,225 @@
{
"common": {
"firstName": "Vorname",
"lastName": "Nachname",
"email": "E-Mail",
"actions": "Aktionen",
"bannerSubtitle": "VERWALTEN",
"messageRequestFailed": "Anfrage fehlgeschlagen. Bitte versuchen Sie es erneut.",
"change": "Ändern",
"cancel": "Abbrechen",
"delete": "Löschen",
"role": "Rolle",
"status": "Status",
"password": "Passwort",
"create": "Erstellen",
"firstNameRules": {
"required": "Bitte geben Sie einen Vornamen ein",
"minLength": "Der Vorname muss mindestens {{minLength}} Zeichen lang sein",
"maxLength": "Der Vorname darf höchstens {{maxLength}} Zeichen lang sein"
},
"lastNameRules": {
"required": "Bitte geben Sie einen Nachnamen ein",
"minLength": "Der Nachname muss mindestens {{minLength}} Zeichen lang sein",
"maxLength": "Der Nachname darf höchstens {{maxLength}} Zeichen lang sein"
},
"emailRules": {
"valid": "Bitte geben Sie eine gültige E-Mail-Adresse ein"
},
"passwordRules": {
"required": "Bitte geben Sie ein Passwort ein",
"minLength": "Das Passwort muss mindestens {{minLength}} Zeichen lang sein",
"maxLength": "Das Passwort darf höchstens {{maxLength}} Zeichen lang sein"
}
},
"sideMenu": {
"overview": "ÜBERSICHT",
"board": "Board",
"lessons": "Lektionen",
"organization": "ORGANISATION",
"team": "Team",
"roles": "Rollen",
"settings": "Einstellungen",
"whatsNew": "Was gibt's Neues",
"suggestFeature": "Feature vorschlagen",
"contactSupport": "Support kontaktieren"
},
"sideMenuEditor": {
"lessonStatus": "Status",
"lessonStatusPublished": "Veröffentlicht",
"lessonStatusDraft": "Entwurf",
"messageLessonStatusSuccessfullyUpdated": "Lektionsstatus erfolgreich aktualisiert"
},
"userProfile": {
"bannerTitle": "Kontoeinstellungen",
"middleCardTitle": "Profil",
"messageProfileSuccessfullyUpdated": "Profil erfolgreich aktualisiert",
"language": "Sprache",
"languageEnglish": "Englisch",
"languageGerman": "Deutsch",
"personalInformation": "Persönliche Informationen"
},
"organizationSettings": {
"bannerTitle": "Einstellungen",
"generalCard": {
"title": "Allgemein",
"primaryColor": "Primärfarbe",
"companyName": "Firmenname",
"messageSettingsSuccessfullyUpdated": "Einstellungen erfolgreich aktualisiert"
},
"mediaCard": {
"title": "Medien",
"logo": "Logo",
"messageLogoSuccessfullyUpdated": "Logo erfolgreich aktualisiert",
"banner": "Banner",
"messageBannerSuccessfullyUpdated": "Banner erfolgreich aktualisiert"
},
"subdomainCard": {
"subdomain": "Subdomain",
"middleCardTitle": "Subdomain",
"subdomainAlreadyTaken": "Diese Subdomain ist bereits vergeben",
"modalChangeSubdomain": {
"title": "Subdomain ändern",
"message": "Durch die Änderung Ihrer Subdomain wird Ihre Organisation unter der neuen Subdomain erreichbar sein. Beachten Sie, dass Sie ausgeloggt und zur neuen Subdomain weitergeleitet werden. Außerdem wird die alte Subdomain für andere Benutzer zur Registrierung freigegeben.",
"messageSubdomainSuccessfullyUpdated": "Subdomain erfolgreich aktualisiert",
"messageRedirect": "Sie werden zur neuen Subdomain weitergeleitet..."
},
"rules": {
"required": "Bitte geben Sie eine Subdomain ein",
"valid": "Bitte geben Sie eine gültige Subdomain ein",
"minLength": "Die Subdomain muss mindestens {{minLength}} Zeichen lang sein",
"maxLength": "Die Subdomain darf höchstens {{maxLength}} Zeichen lang sein"
}
}
},
"roles": {
"bannerTitle": "Rollen",
"roleNames": {
"d0f0fa0d-3f3b-438b-a76f-7febeb8aab57": "Admin",
"b7359e12-359e-423b-b39c-f0d4069adebc": "Editor",
"a1f084ad-d501-4015-b326-4c5c46fd1c5e": "Betrachter"
},
"collapseTitleTeam": "Team",
"collapseTitleRoles": "Rollen",
"roles": [
{
"id": 1,
"category": "Team",
"title": "Neues Teammitglied einladen",
"description": "Berechtigung, ein Mitglied einzuladen. Eine E-Mail wird an das neue Mitglied gesendet."
},
{
"id": 2,
"category": "Team",
"title": "Teammitglied entfernen",
"description": "Berechtigung, ein Mitglied zu entfernen."
},
{
"id": 3,
"category": "Roles",
"title": "Neue Rolle erstellen",
"description": "Berechtigung, ein Mitglied einzuladen. Eine E-Mail wird an das neue Mitglied gesendet."
},
{
"id": 4,
"category": "Roles",
"title": "Rolle löschen",
"description": "Berechtigung, ein Mitglied einzuladen. Eine E-Mail wird an das neue Mitglied gesendet."
}
]
},
"team": {
"bannerTitle": "Team",
"addMemberButton": "Neues Mitglied hinzufügen",
"changeRole": "Rolle ändern",
"popConfirmRoleChange": {
"title": "Rolle ändern zu",
"messageUpdatedRoleSuccessfully": "Rolle erfolgreich aktualisiert"
},
"popConfirmDeleteMember": {
"title": "Löschung des Teammitglieds bestätigen",
"messageDeletedMemberSuccessfully": "Mitglied erfolgreich gelöscht"
}
},
"teamCreateUser": {
"middleCardTitle": "Benutzer erstellen",
"messageUserSuccessfullyCreated": "Benutzer erfolgreich erstellt",
"buttonCreateUser": "Benutzer erstellen"
},
"lessons": {
"bannerTitle": "Lektionen",
"messageLessonSuccessfullyCreated": "Lektion erfolgreich erstellt",
"lessonStatusDrafts": "Entwürfe",
"searchPlaceholder": "Suchen..."
},
"lessonPage": {
"finishLessonButton": "Lektion beenden",
"questions": "Fragen"
},
"lessonQuestions": {
"messageQuestionSuccessfullyCreated": "Frage erfolgreich erstellt",
"askAQuestion": "Frage stellen",
"typeSomething": "Etwas schreiben...",
"submitButton": "Absenden",
"ruleMessageRequired": "Bitte schreiben Sie eine Frage",
"questions": "Fragen",
"reply": "Antworten",
"hide": "Ausblenden",
"ruleReplyRequired": "Bitte schreiben Sie eine Antwort",
"replyPlaceholder": "Antwort schreiben"
},
"lessonsComponents": {
"categories": {
"Common": "Allgemein",
"Media": "Medien"
},
"commonComponents": {
"Header": "Überschrift",
"Text": "Text"
},
"mediaComponents": {
"Image": "Bild",
"YouTube": "YouTube",
"Video": "Video"
}
},
"lessonComponentsConverter": {
"textPlaceholder": "Text hier eingeben...",
"noImageProvided": "Kein Bild bereitgestellt",
"gallery": "Galerie",
"chooseImageFrom": "Bild auswählen aus",
"chooseAnotherImageFrom": "Ein anderes Bild auswählen aus",
"noVideoProvided": "Kein Video bereitgestellt",
"videoId": "Video-ID",
"video": "Video",
"chooseVideoFrom": "Video auswählen aus",
"chooseAnotherVideoFrom": "Ein anderes Video auswählen aus",
"notImplemented": "Nicht implementiert",
"unkownType": "Unbekannter Typ"
},
"header": {
"accountSettings": "Profil",
"logout": "Abmelden"
},
"headerBar": {
"backButton": "Zurück"
},
"myEmpty": {
"noData": "Keine Daten"
},
"myErrorResult": {
"title": "Ein Fehler ist aufgetreten",
"subTitle": "Bitte versuchen Sie es später erneut."
},
"contactSupport": {
"bannerTitle": "Support",
"middleCardTitle": "Support kontaktieren",
"paragraph": "Haben Sie Fragen oder benötigen Sie Hilfe? Kontaktieren Sie uns über die untenstende Email-Adresse und wir werden uns so schnell wie möglich bei Ihnen melden."
},
"whatsNew": {
"bannerTitle": "Was gibt's Neues"
},
"suggestFeature": {
"bannerTitle": "Feature vorschlagen"
}
}

View File

@ -0,0 +1,225 @@
{
"common": {
"firstName": "First Name",
"lastName": "Last Name",
"email": "Email",
"actions": "Actions",
"bannerSubtitle": "MANAGE",
"messageRequestFailed": "Request failed. Please try again.",
"change": "Change",
"cancel": "Cancel",
"delete": "Delete",
"role": "Role",
"status": "Status",
"password": "Password",
"create": "Create",
"firstNameRules": {
"required": "Please enter a first name",
"minLength": "First name must be at least {{minLength}} characters long",
"maxLength": "First name must be at most {{maxLength}} characters long"
},
"lastNameRules": {
"required": "Please enter a last name",
"minLength": "Last name must be at least {{minLength}} characters long",
"maxLength": "Last name must be at most {{maxLength}} characters long"
},
"emailRules": {
"valid": "Please enter a valid email"
},
"passwordRules": {
"required": "Please enter a password",
"minLength": "Password must be at least {{minLength}} characters long",
"maxLength": "Password must be at most {{maxLength}} characters long"
}
},
"sideMenu": {
"overview": "OVERVIEW",
"board": "Board",
"lessons": "Lessons",
"organization": "ORGANIZATION",
"team": "Team",
"roles": "Roles",
"settings": "Settings",
"whatsNew": "What's New",
"suggestFeature": "Suggest a Feature",
"contactSupport": "Contact Support"
},
"sideMenuEditor": {
"lessonStatus": "Status",
"lessonStatusPublished": "Published",
"lessonStatusDraft": "Draft",
"messageLessonStatusSuccessfullyUpdated": "Lesson status successfully updated"
},
"userProfile": {
"bannerTitle": "Account Settings",
"middleCardTitle": "Profile",
"messageProfileSuccessfullyUpdated": "Profile successfully updated",
"language": "Language",
"languageEnglish": "English",
"languageGerman": "German",
"personalInformation": "Personal Information"
},
"organizationSettings": {
"bannerTitle": "Settings",
"generalCard": {
"title": "General",
"primaryColor": "Primary Color",
"companyName": "Company Name",
"messageSettingsSuccessfullyUpdated": "Settings successfully updated"
},
"mediaCard": {
"title": "Media",
"logo": "Logo",
"messageLogoSuccessfullyUpdated": "Logo successfully updated",
"banner": "Banner",
"messageBannerSuccessfullyUpdated": "Banner successfully updated"
},
"subdomainCard": {
"subdomain": "Subdomain",
"middleCardTitle": "Subdomain",
"subdomainAlreadyTaken": "This subdomain already taken",
"modalChangeSubdomain": {
"title": "Change Subdomain",
"message": "Changing your subdomain will make your organization available at the new subdomain. Please note that you will be logged out and redirected to the new subdomain. Also the old subdomain will be available for registration by other users.",
"messageSubdomainSuccessfullyUpdated": "Subdomain successfully updated",
"messageRedirect": "You will be redirected to the new subdomain..."
},
"rules": {
"required": "Please enter a subdomain",
"valid": "Please enter a valid subdomain",
"minLength": "Subdomain must be at least {{minLength}} characters long",
"maxLength": "Subdomain must be at most {{maxLength}} characters long"
}
}
},
"roles": {
"bannerTitle": "Roles",
"roleNames": {
"d0f0fa0d-3f3b-438b-a76f-7febeb8aab57": "Admin",
"b7359e12-359e-423b-b39c-f0d4069adebc": "Editor",
"a1f084ad-d501-4015-b326-4c5c46fd1c5e": "Viewer"
},
"collapseTitleTeam": "Team",
"collapseTitleRoles": "Roles",
"roles": [
{
"id": 1,
"category": "Team",
"title": "Invite new team member",
"description": "Permission to invite a member. An email will be sent to the new member."
},
{
"id": 2,
"category": "Team",
"title": "Remove team member",
"description": "Permission to remove a member."
},
{
"id": 3,
"category": "Roles",
"title": "Create new role",
"description": "Permission to invite a member. An email will be sent to the new member."
},
{
"id": 4,
"category": "Roles",
"title": "Delete role",
"description": "Permission to invite a member. An email will be sent to the new member."
}
]
},
"team": {
"bannerTitle": "Team",
"addMemberButton": "Add new member",
"changeRole": "Change Role",
"popConfirmRoleChange": {
"title": "Change role to",
"messageUpdatedRoleSuccessfully": "Role successfully updated"
},
"popConfirmDeleteMember": {
"title": "Confirm deletion of team member",
"messageDeletedMemberSuccessfully": "Member successfully deleted"
}
},
"teamCreateUser": {
"middleCardTitle": "Create User",
"messageUserSuccessfullyCreated": "User successfully created",
"buttonCreateUser": "Create User"
},
"lessons": {
"bannerTitle": "Lessons",
"messageLessonSuccessfullyCreated": "Lesson successfully created",
"lessonStatusDrafts": "Drafts",
"searchPlaceholder": "Search..."
},
"lessonPage": {
"finishLessonButton": "Finish lesson",
"questions": "Questions"
},
"lessonQuestions": {
"messageQuestionSuccessfullyCreated": "Question successfully created",
"askAQuestion": "Ask a question",
"typeSomething": "Type something...",
"submitButton": "Submit",
"ruleMessageRequired": "Please write a question",
"questions": "Questions",
"reply": "Reply",
"hide": "Hide",
"ruleReplyRequired": "Please write a reply",
"replyPlaceholder": "Write a reply"
},
"lessonsComponents": {
"categories": {
"Common": "Common",
"Media": "Media"
},
"commonComponents": {
"Header": "Header",
"Text": "Text"
},
"mediaComponents": {
"Image": "Image",
"YouTube": "YouTube",
"Video": "Video"
}
},
"lessonComponentsConverter": {
"textPlaceholder": "Input text here...",
"noImageProvided": "No image provided",
"gallery": "Gallery",
"chooseImageFrom": "Choose image from",
"chooseAnotherImageFrom": "Choose another image from",
"noVideoProvided": "No video provided",
"videoId": "Video ID",
"video": "Video",
"chooseVideoFrom": "Choose video from",
"chooseAnotherVideoFrom": "Choose another video from",
"notImplemented": "Not implemented",
"unkownType": "Unknown type"
},
"header": {
"accountSettings": "Profile",
"logout": "Logout"
},
"headerBar": {
"backButton": "Back"
},
"myEmpty": {
"noData": "No data"
},
"myErrorResult": {
"title": "Something went wrong",
"subTitle": "Please try again later."
},
"contactSupport": {
"bannerTitle": "Support",
"middleCardTitle": "Contact Support",
"paragraph": "Do you have any questions or need help? Contact us via the email address below and we will get back to you as soon as possible."
},
"whatsNew": {
"bannerTitle": "What's New"
},
"suggestFeature": {
"bannerTitle": "Suggest a Feature"
}
}

View File

@ -1,23 +1,39 @@
import { Avatar, Dropdown, Flex } from 'antd';
import { isSideMenuCollapsed, setIsSideMenuCollapsed } from '../SideMenu/sideMenuSlice';
import { useDispatch, useSelector } from 'react-redux';
import { EditOutlined, EyeOutlined, LeftOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined, UserOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { darkMode, setDarkMode, setUserAuthenticated } from '../../reducers/appSlice';
import styles from './styles.module.css';
import { Constants } from 'core/utils/utils';
import webSocketService from 'core/services/websocketService';
import { userProfilePictureUrl } from 'core/reducers/appSlice';
import MyUserAvatar from 'shared/components/MyUserAvatar';
import { Button, Dropdown, Flex } from "antd";
import {
isSideMenuCollapsed,
setIsSideMenuCollapsed,
} from "../SideMenu/sideMenuSlice";
import { useDispatch, useSelector } from "react-redux";
import {
EditOutlined,
EyeOutlined,
LeftOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MoonOutlined,
SunOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { darkMode, setDarkMode } from "../../reducers/appSlice";
import styles from "./styles.module.css";
import { Constants } from "core/utils/utils";
import webSocketService from "core/services/websocketService";
import { userProfilePictureUrl } from "core/reducers/appSlice";
import MyUserAvatar from "shared/components/MyUserAvatar";
import { useTranslation } from "react-i18next";
type HeaderBarProps = {
theme?: 'light' | 'dark';
theme?: "light" | "dark";
onView?: () => void;
onEdit?: () => void;
backTo?: string;
sticky?: boolean;
};
export default function HeaderBar(props: HeaderBarProps = { theme: 'light' }) {
export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
const { t } = useTranslation();
const dispatch = useDispatch();
const isCollpased = useSelector(isSideMenuCollapsed);
@ -34,34 +50,54 @@ export default function HeaderBar(props: HeaderBarProps = { theme: 'light' }) {
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
position: props.sticky ? "sticky" : "relative",
top: 0,
zIndex: 999,
}}
>
<Flex align="center" gap={16}>
<div className={props.theme === 'light' ? styles.containerLight : styles.containerDark} style={{ borderRadius: 28, padding: 4 }}>
<div
className={isDarkMode ? styles.containerDark : styles.containerLight}
style={{ borderRadius: 28, padding: 4 }}
>
{isCollpased ? (
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(false))}>
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(false))}
>
<MenuUnfoldOutlined className={styles.icon} />
</div>
) : (
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(true))}>
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(true))}
>
<MenuFoldOutlined className={styles.icon} />
</div>
)}
</div>
{props.backTo && (
<Link to={props.backTo}>
<Flex gap={4}>
<LeftOutlined />
<span>Back</span>
</Flex>
</Link>
<Button
type="link"
style={{ color: isDarkMode ? "#fff" : "#1e1e1e" }}
onClick={() =>
navigate(
props.backTo
? props.backTo
: Constants.ROUTE_PATHS.LESSIONS.ROOT
)
}
icon={<LeftOutlined />}
>
{t("headerBar.backButton")}
</Button>
)}
</Flex>
<Flex
align="center"
className={props.theme === 'light' ? styles.containerLight : styles.containerDark}
className={isDarkMode ? styles.containerDark : styles.containerLight}
style={{
borderRadius: 28,
paddingLeft: 6,
@ -84,41 +120,50 @@ export default function HeaderBar(props: HeaderBarProps = { theme: 'light' }) {
)}
{isDarkMode ? (
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(false))}>
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(false))}
>
<SunOutlined className={styles.icon} />
</div>
) : (
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(true))}>
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(true))}
>
<MoonOutlined className={styles.icon} />
</div>
)}
<Dropdown
overlayStyle={{ minWidth: 150 }}
trigger={['click']}
trigger={["click"]}
menu={{
items: [
{
key: '1',
label: 'Profile',
key: "1",
label: t("header.accountSettings"),
icon: <UserOutlined />,
onClick: () => navigate(Constants.ROUTE_PATHS.ACCOUNT_SETTINGS),
},
{
key: '2',
label: 'Logout',
key: "2",
label: t("header.logout"),
icon: <LogoutOutlined />,
danger: true,
onClick: () => {
webSocketService.disconnect();
window.localStorage.removeItem('session');
window.location.href = '/';
window.localStorage.removeItem("session");
window.location.href = "/";
},
},
],
}}
>
<div>
<MyUserAvatar size={34} profilePictureUrl={profilePictureUrl ? profilePictureUrl : ''} />
<MyUserAvatar
size={34}
profilePictureUrl={profilePictureUrl ? profilePictureUrl : ""}
/>
</div>
</Dropdown>
</Flex>

View File

@ -4,7 +4,7 @@
}
.containerDark {
background-color: rgba(0, 0, 0, 0.22);
background-color: rgba(39, 39, 39, 0.4);
color: #fff;
}

View File

@ -46,9 +46,11 @@ import webSocketService, {
import { WebSocketSendMessagesCmds } from "core/utils/webSocket";
import { useMessage } from "core/context/MessageContext";
import styles from "./styles.module.css";
import { useTranslation } from "react-i18next";
export function SideMenuContent() {
const location = useLocation();
const { t } = useTranslation();
const [selectedKeys, setSelectedKeys] = useState("/");
const [openKeys, setOpenKeys] = useState([""]);
@ -65,7 +67,7 @@ export function SideMenuContent() {
let overviewGroup: ItemType<MenuItemType> = {
key: "overviewGroup",
label: "OVERVIEW",
label: t("sideMenu.overview"),
type: "group",
children: [],
};
@ -73,13 +75,13 @@ export function SideMenuContent() {
if (overviewGroup.children) {
overviewGroup.children.push({
key: Constants.ROUTE_PATHS.BOARD,
label: "Board",
label: t("sideMenu.board"),
icon: <FundProjectionScreenOutlined />,
});
overviewGroup.children.push({
key: Constants.ROUTE_PATHS.LESSIONS.ROOT,
label: "Lessons",
label: t("sideMenu.lessons"),
icon: <SnippetsOutlined />,
});
}
@ -90,7 +92,7 @@ export function SideMenuContent() {
let organizationGroup: ItemType<MenuItemType> = {
key: "organizationGroup",
label: "ORGANIZATION",
label: t("sideMenu.organization"),
type: "group",
children: [],
};
@ -98,19 +100,19 @@ export function SideMenuContent() {
if (organizationGroup.children) {
organizationGroup.children.push({
key: Constants.ROUTE_PATHS.ORGANIZATION_TEAM,
label: "Team",
label: t("sideMenu.team"),
icon: <TeamOutlined />,
});
organizationGroup.children.push({
key: Constants.ROUTE_PATHS.ORGANIZATION_ROLES,
label: "Roles",
label: t("sideMenu.roles"),
icon: <ControlOutlined />,
});
organizationGroup.children.push({
key: Constants.ROUTE_PATHS.ORGANIZATION_SETTINGS,
label: "Settings",
label: t("sideMenu.settings"),
icon: <SettingOutlined />,
});
}
@ -127,7 +129,7 @@ export function SideMenuContent() {
items.push({
key: Constants.ROUTE_PATHS.WHATS_NEW,
label: "What's New",
label: t("sideMenu.whatsNew"),
icon: <QuestionCircleOutlined />,
});
@ -135,7 +137,7 @@ export function SideMenuContent() {
items.push({
key: Constants.ROUTE_PATHS.SUGGEST_FEATURE,
label: "Suggest a Feature",
label: t("sideMenu.suggestFeature"),
icon: <MessageOutlined />,
});
@ -143,7 +145,7 @@ export function SideMenuContent() {
items.push({
key: Constants.ROUTE_PATHS.CONTACT_SUPPORT,
label: "Contact Support",
label: t("sideMenu.contactSupport"),
icon: <WalletOutlined />,
});
@ -264,6 +266,7 @@ export function SideMenuContent() {
export function SideMenuEditorContent() {
const location = useLocation();
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState<String | null>(null);
const { lessonId } = useParams();
const { success, error } = useMessage();
@ -275,6 +278,10 @@ export function SideMenuEditorContent() {
const [reqUpdateLessonState] = useUpdateLessonStateMutation();
const lessonsComponentsCategories = t("lessonsComponents.categories", {
returnObjects: true,
}) as { [key: string]: string };
useEffect(() => {
// subscribe to the current page
const pathname = location.pathname;
@ -319,7 +326,7 @@ export function SideMenuEditorContent() {
>
{componentsGroups.map((group, i) => (
<div key={i} style={{ paddingTop: 16 }}>
<span>{group.category}</span>
<span>{lessonsComponentsCategories[group.category]}</span>
<Flex
gap={16}
@ -359,29 +366,35 @@ export function SideMenuEditorContent() {
<div style={{ padding: 12 }}>
<Form form={form} layout="vertical">
<Form.Item label="Status" name="state" style={{ marginBottom: 0 }}>
<Form.Item
label={t("sideMenuEditor.lessonStatus")}
name="state"
style={{ marginBottom: 0 }}
>
<Select
style={{ width: "100%" }}
onChange={async (value) => {
console.log("state changed", value, lessonId);
try {
await reqUpdateLessonState({
lessonId: currentLnId,
newState: value,
}).unwrap();
success("Lesson state updated successfully");
success(
t("sideMenuEditor.messageLessonStatusSuccessfullyUpdated")
);
} catch (err) {
console.log("error", err);
error("Failed to update lesson state");
error(t("common.messageRequestFailed"));
}
}}
>
<Select.Option value={LessonState.Published}>
Published
{t("sideMenuEditor.lessonStatusPublished")}
</Select.Option>
<Select.Option value={LessonState.Draft}>
{t("sideMenuEditor.lessonStatusDraft")}
</Select.Option>
<Select.Option value={LessonState.Draft}>Draft</Select.Option>
</Select>
</Form.Item>
</Form>
@ -423,6 +436,7 @@ export function DraggableCreateComponent({
}
function CreateComponent({ component }: { component: Component }) {
const { t } = useTranslation();
const { error } = useMessage();
const dispatch = useDispatch();
@ -431,6 +445,14 @@ function CreateComponent({ component }: { component: Component }) {
const [reqAddLessonContent] = useAddLessonContentMutation();
const lessonsCommonComponents = t("lessonsComponents.commonComponents", {
returnObjects: true,
}) as { [key: string]: string };
const lessonsMediaComponents = t("lessonsComponents.mediaComponents", {
returnObjects: true,
}) as { [key: string]: string };
return (
<Flex
vertical
@ -447,24 +469,32 @@ function CreateComponent({ component }: { component: Component }) {
}}
onClick={async () => {
try {
const defaultData =
component.type < 2
? lessonsCommonComponents[component.name] ||
lessonsMediaComponents[component.name] ||
component.defaultData ||
""
: component.defaultData || "";
const res = await reqAddLessonContent({
lessonId: currentLnId,
type: component.type,
data: component.defaultData || "",
data: defaultData,
}).unwrap();
dispatch(
addLessonContent({
Id: res.Id,
Type: component.type,
Data: component.defaultData || "",
Data: defaultData,
Page: 1,
Position: 1,
})
);
} catch (err) {
console.log("error", err);
error("Failed to add content");
error(t("common.messageRequestFailed"));
}
}}
>
@ -485,11 +515,13 @@ function CreateComponent({ component }: { component: Component }) {
</div>
) : null}
<Typography.Text style={{ fontSize: 12 }}>
{component.name}
{
{
...lessonsCommonComponents,
...lessonsMediaComponents,
}[component.name]
}
</Typography.Text>
</Flex>
);
}
//console.log("insert component", component.type);
//dispatch(addLessonContent(component.type));

View File

@ -4,6 +4,7 @@ export interface Lesson {
Title: string;
ThumbnailUrl: string;
CreatorUserId: string;
QuestionsCount?: number;
CreatedAt: string;
}
@ -17,6 +18,7 @@ export interface LessonSettings {
Title: string;
ThumbnailUrl: string;
State?: LessonState;
QuestionsCount?: number;
}
// used on lesson page and on the lesson editor

View File

@ -1,8 +1,11 @@
import React from "react";
import { FloatButton } from "antd";
import { CommentOutlined } from "@ant-design/icons";
import { DeepChat } from "deep-chat-react";
import { getUserSessionFromLocalStorage } from "core/utils/utils";
import React from 'react';
import { FloatButton } from 'antd';
import { CommentOutlined } from '@ant-design/icons';
import { DeepChat } from 'deep-chat-react';
import { getUserSessionFromLocalStorage } from 'core/utils/utils';
import { Marked, marked } from 'marked';
import { createDirectives, presetDirectiveConfigs } from 'marked-directive';
function AiChat() {
const [visible, setVisible] = React.useState(false);
@ -12,35 +15,82 @@ function AiChat() {
{visible ? (
<div
style={{
position: "fixed",
position: 'fixed',
bottom: 100,
right: 10,
zIndex: 10000,
maxWidth: "95vw",
maxWidth: '95vw',
width: 500,
height: 1000,
maxHeight: "calc(100vh - 165px)",
maxHeight: 'calc(100vh - 165px)',
}}
>
<DeepChat
style={{
width: "100%",
height: "100%",
width: '100%',
height: '100%',
borderRadius: 10,
boxShadow: "0 0 10px rgba(0,0,0,0.1)",
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
bottom: 0,
position: "absolute",
position: 'absolute',
}}
history={[{ text: "Stell mir Fragen :)", role: "ai" }]}
avatars={true}
history={[{ text: 'Stell mir Fragen :)', role: 'ai' }]}
connect={{
url: "/api/chat/v1/prompt/",
method: "POST",
url: '/api/chat/v1/prompt/',
method: 'POST',
headers: {
"X-Authorization": getUserSessionFromLocalStorage() || "",
'X-Authorization': getUserSessionFromLocalStorage() || '',
},
}}
onMessage={async (message) => {
console.log("onMessagee", message);
htmlClassUtilities={{
['aiButtonSource']: {
styles: {
default: {
backgroundColor: '#1890ff',
color: 'white',
padding: '5px 10px',
borderRadius: '5px',
textDecoration: 'none',
margin: '4px',
display: 'inline-block',
transition: 'all 0.3s ease-in-out',
},
hover: {
backgroundColor: '#40a9ff',
transform: 'scale(1.05)',
},
},
},
}}
responseInterceptor={async (response: any) => {
let _response = { ...response };
let text = response.text;
delete _response.text;
// Regular expression to match pattern %URL%x%URL%
//const re = /%URL%(.*?)%URL%/g;
// Replace pattern with an HTML anchor tag
//text = text.replace(re, '@@[Mehr Infos]{href="$1"}');
_response.html = new Marked()
.use(
createDirectives([
...presetDirectiveConfigs,
{
level: 'block',
marker: 'µ',
renderer(token) {
return `<a class="aiButtonSource" ${token.attrs?.toString()}>${token.text}</a>`;
},
},
])
)
.parse(text);
return _response;
}}
></DeepChat>
</div>
@ -49,7 +99,7 @@ function AiChat() {
<FloatButton
icon={<CommentOutlined />}
type="primary"
onClick={() => console.log("onClick")}
onClick={() => console.log('onClick')}
style={{ zIndex: 10000 }}
onClickCapture={() => {
setVisible(!visible);

View File

@ -0,0 +1,19 @@
.aiButtonSource {
background-color: #35f;
color: #fff;
padding: 10px 20px;
margin: 0 0;
display: inline-block;
border-radius: 8px;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease 0s;
}
.aiButtonSource:hover {
background-color: #35f;
color: #fff;
border: 2px solid #35f;
}

View File

@ -1,16 +1,22 @@
import { Descriptions, Typography } from "antd";
import MyMiddleCard from "shared/components/MyMiddleCard";
import MyBanner from "shared/components/MyBanner";
import HeaderBar from "core/components/Header";
import { useTranslation } from "react-i18next";
export default function ContactSupport() {
const { t } = useTranslation();
return (
<>
<MyBanner title="Contact Support" />
<MyBanner
title={t("contactSupport.bannerTitle")}
headerBar={<HeaderBar />}
/>
<MyMiddleCard title="Support">
<MyMiddleCard title={t("contactSupport.middleCardTitle")}>
<Typography.Paragraph>
If you have any questions or need help, please contact us at the
following e-mail address:
{t("contactSupport.paragraph")}
</Typography.Paragraph>
<Descriptions>
<Descriptions.Item label="E-Mail">

View File

@ -21,6 +21,7 @@ import {
setLessonPageCurrentLessonId,
} from "./lessonPageSlice";
import MyCenteredSpin from "shared/components/MyCenteredSpin";
import { useTranslation } from "react-i18next";
const LessonContents: React.FC = () => {
const dispatch = useDispatch();
@ -65,6 +66,8 @@ const LessonContents: React.FC = () => {
};
export default function LessonPage() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
@ -74,6 +77,7 @@ export default function LessonPage() {
theme="light"
backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT}
onEdit={() => navigate(`${location.pathname}/editor`)}
sticky
/>
<MyMiddleCard
@ -86,8 +90,12 @@ export default function LessonPage() {
<LessonContents />
<Flex justify="right">
<Button type="primary" icon={<CheckOutlined />}>
Finish lesson
<Button
type="primary"
icon={<CheckOutlined />}
onClick={() => navigate(Constants.ROUTE_PATHS.LESSIONS.ROOT)}
>
{t("lessonPage.finishLessonButton")}
</Button>
</Flex>
</MyMiddleCard>

View File

@ -1,30 +1,38 @@
import { closestCenter, closestCorners, DndContext, DragEndEvent, DragOverlay, MeasuringStrategy, rectIntersection, useDroppable } from '@dnd-kit/core';
import { verticalListSortingStrategy, SortableContext, rectSwappingStrategy, rectSortingStrategy } from '@dnd-kit/sortable';
import SortableEditorItem from './SortableEditorItem';
import { store } from 'core/store/store';
import { restrictToVerticalAxis, restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers';
import { currentLessonId, onDragHandler } from './lessonPageEditorSlice';
import { LessonContent } from 'core/types/lesson';
import { useUpdateLessonContentPositionMutation } from 'core/services/lessons';
import { useSelector } from 'react-redux';
import React from 'react';
import { Typography } from 'antd';
import { HolderOutlined } from '@ant-design/icons';
import {
closestCorners,
DndContext,
DragEndEvent,
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import {
verticalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import SortableEditorItem from "./SortableEditorItem";
import { store } from "core/store/store";
import { snapCenterToCursor } from "@dnd-kit/modifiers";
import { currentLessonId, onDragHandler } from "./lessonPageEditorSlice";
import { LessonContent } from "core/types/lesson";
import { useUpdateLessonContentPositionMutation } from "core/services/lessons";
import { useSelector } from "react-redux";
import React from "react";
import { HolderOutlined } from "@ant-design/icons";
const Droppable = ({ items }: { items: LessonContent[] }) => {
const droppableID = 'editorComponentArea';
const droppableID = "editorComponentArea";
const { setNodeRef } = useDroppable({ id: droppableID });
const currentLnId = useSelector(currentLessonId);
const [reqUpdateLessonContentPosition] = useUpdateLessonContentPositionMutation();
const [reqUpdateLessonContentPosition] =
useUpdateLessonContentPositionMutation();
const [isDragging, setIsDragging] = React.useState(false);
const itemIDs = items.map((item) => item.Id);
const handleDragEnd = (event: DragEndEvent) => {
console.log('drag end', event);
console.log("drag end", event);
setIsDragging(false);
if (!event.over) return;
@ -61,14 +69,20 @@ const Droppable = ({ items }: { items: LessonContent[] }) => {
}}
onDragEnd={handleDragEnd}
>
<SortableContext id={droppableID} items={itemIDs} strategy={verticalListSortingStrategy}>
<SortableContext
id={droppableID}
items={itemIDs}
strategy={verticalListSortingStrategy}
>
<div ref={setNodeRef}>
{items.map((item) => (
<SortableEditorItem key={`${droppableID}_${item.Id}`} item={item} />
))}
</div>
</SortableContext>
<DragOverlay>{isDragging ? <HolderOutlined style={{ cursor: 'grabbing' }} /> : null}</DragOverlay>
<DragOverlay>
{isDragging ? <HolderOutlined style={{ cursor: "grabbing" }} /> : null}
</DragOverlay>
</DndContext>
);
};

View File

@ -19,9 +19,10 @@ import "./styles.module.css";
import { Converter } from "../converter";
import { useDeleteLessonContentMutation } from "core/services/lessons";
/*
const animateLayoutChanges = (args: any) =>
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true;
*/
const SortableEditorItem = (props: { item: LessonContent }) => {
const lnContent = props.item;
const {

View File

@ -52,6 +52,12 @@ const PreviewCard: React.FC = () => {
dispatch(setLessonThumbnailUrl(data.ThumbnailUrl));
}, [data]);
useEffect(() => {
addWebSocketReconnectListener(refetch);
return () => removeWebSocketReconnectListener(refetch);
}, []);
if (error) return <MyErrorResult />;
return (
@ -150,6 +156,7 @@ export default function LessonPageEditor() {
)
)
}
sticky
/>
<Flex justify="center" style={{ paddingTop: 24 }}>

View File

@ -27,8 +27,11 @@ import {
} from "core/services/websocketService";
import { useCachedUser } from "core/services/cachedUser";
import MyUserAvatar from "shared/components/MyUserAvatar";
import { useTranslation } from "react-i18next";
const CreateQuestionForm: React.FC = () => {
const { t } = useTranslation();
const { lessonId } = useParams();
const [form] = useForm();
@ -42,11 +45,11 @@ const CreateQuestionForm: React.FC = () => {
message: form.getFieldValue("message"),
}).unwrap();
success("Question created successfully");
success(t("lessonQuestions.messageQuestionSuccessfullyCreated"));
form.resetFields();
} catch (err) {
console.error(err);
error("Failed to create question");
error(t("common.messageRequestFailed"));
}
};
@ -58,12 +61,14 @@ const CreateQuestionForm: React.FC = () => {
requiredMark={false}
>
<Form.Item
label="Ask a question"
label={t("lessonQuestions.askAQuestion")}
name="message"
rules={[{ required: true, message: "Please write a question" }]}
rules={[
{ required: true, message: t("lessonQuestions.ruleMessageRequired") },
]}
>
<Input.TextArea
placeholder={"Type something"}
placeholder={t("lessonQuestions.typeSomething")}
autoSize={{ minRows: 2 }}
maxLength={5000}
/>
@ -71,7 +76,7 @@ const CreateQuestionForm: React.FC = () => {
<Form.Item>
<Button type="primary" htmlType="submit" loading={isLoading}>
Submit
{t("lessonQuestions.submitButton")}
</Button>
</Form.Item>
</Form>
@ -79,6 +84,8 @@ const CreateQuestionForm: React.FC = () => {
};
export default function Questions() {
const { t } = useTranslation();
const dispatch = useDispatch();
const { lessonId } = useParams();
@ -108,7 +115,9 @@ export default function Questions() {
return (
<Flex justify="center">
<Flex style={{ width: 800, maxWidth: 800 * 0.9 }} vertical>
<Typography.Title level={3}>Questions</Typography.Title>
<Typography.Title level={3}>
{t("lessonQuestions.questions")}
</Typography.Title>
<CreateQuestionForm />
@ -152,6 +161,7 @@ export function QuestionItem({
replies: LessonQuestion[];
likedQuestions: string[];
}) {
const { t } = useTranslation();
const [showReplies, setShowReplies] = useState(1);
const { success, error } = useMessage();
@ -167,12 +177,12 @@ export function QuestionItem({
message: message,
}).unwrap();
success("Question created successfully");
success(t("lessonQuestions.messageReplySuccessfullyCreated"));
await new Promise((resolve) => setTimeout(resolve, 0));
} catch (err) {
console.error(err);
error("Failed to create question");
error(t("common.messageRequestFailed"));
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
@ -185,7 +195,7 @@ export function QuestionItem({
}).unwrap();
} catch (err) {
console.error(err);
error("Failed to like");
error(t("lessonQuestions.messageRequestFailed"));
}
}
@ -197,7 +207,7 @@ export function QuestionItem({
}).unwrap();
} catch (err) {
console.error(err);
error("Failed to dislike");
error(t("lessonQuestions.messageRequestFailed"));
}
}
@ -258,6 +268,8 @@ export function QuestionUIRaw({
}) {
//const [hasLiked, setHasLiked] = useState(false);
const { t } = useTranslation();
const [replyFormVisible, setReplyFormVisible] = useState(false);
const [replyMessage, setReplyMessage] = useState<null | string>(null);
const [isSendingReply, setIsSendingReply] = useState(false);
@ -324,7 +336,9 @@ export function QuestionUIRaw({
}, 100);
}}
>
{replyFormVisible ? "Hide" : "Reply"}
{replyFormVisible
? t("lessonQuestions.hide")
: t("lessonQuestions.reply")}
</Button>
</Flex>
{replyFormVisible ? (
@ -344,12 +358,17 @@ export function QuestionUIRaw({
>
<Form.Item
name="reply"
rules={[{ required: true, message: "Please write a reply" }]}
rules={[
{
required: true,
message: t("lessonQuestions.ruleReplyRequired"),
},
]}
>
<Input.TextArea
ref={inputRef}
value={replyMessage ? replyMessage : userAt}
placeholder="Write a reply"
placeholder={t("lessonQuestions.ruleReplyRequired")}
onChange={(e) => setReplyMessage(e.target.value)}
autoSize={{ minRows: 2 }}
maxLength={5000}
@ -361,7 +380,7 @@ export function QuestionUIRaw({
loading={isSendingReply}
htmlType="submit"
>
Reply
{t("lessonQuestions.reply")}
</Button>
</Form.Item>
</Form>

View File

@ -1,44 +1,63 @@
import { LessonContent } from 'core/types/lesson';
import { getTypeByName } from './components';
import { Button, Input, Typography, Flex } from 'antd';
import { useUpdateLessonContentMutation } from 'core/services/lessons';
import { useSelector } from 'react-redux';
import { currentLessonId } from './LessonPageEditor/lessonPageEditorSlice';
import { useRef } from 'react';
import MyUpload from 'shared/components/MyUpload';
import { Constants } from 'core/utils/utils';
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
import { darkMode } from 'core/reducers/appSlice';
import { LessonContent } from "core/types/lesson";
import { getTypeByName } from "./components";
import { Button, Input, Typography, Flex } from "antd";
import { useUpdateLessonContentMutation } from "core/services/lessons";
import { useSelector } from "react-redux";
import { currentLessonId } from "./LessonPageEditor/lessonPageEditorSlice";
import { useRef } from "react";
import MyUpload from "shared/components/MyUpload";
import { Constants } from "core/utils/utils";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import {
defaultLayoutIcons,
DefaultVideoLayout,
} from "@vidstack/react/player/layouts/default";
import "@vidstack/react/player/styles/default/theme.css";
import "@vidstack/react/player/styles/default/layouts/video.css";
import { darkMode } from "core/reducers/appSlice";
import { useTranslation } from "react-i18next";
const extractVideoId = (url: string) => {
// regex to extract video id from youtube url
const regex = /(?:https?:\/\/)?(?:www\.)?youtube\.com\/(?:watch\?v=|embed\/|v\/|.+\?v=|.+\/|.+v=)?([^"&?\/\s]{11})/;
const regex =
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/(?:watch\?v=|embed\/|v\/|.+\?v=|.+\/|.+v=)?([^"&?\/\s]{11})/;
const match = url.match(regex);
return match ? match[1] : url;
};
export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edititable'; lessonContent: LessonContent; onEdit?: (newData: string) => void }) {
const lessonId = useSelector(currentLessonId);
export function Converter({
mode,
lessonContent,
onEdit,
}: {
mode: "view" | "edititable";
lessonContent: LessonContent;
onEdit?: (newData: string) => void;
}) {
const { t } = useTranslation();
const debounceRef = useRef<null | NodeJS.Timeout>(null);
const lessonId = useSelector(currentLessonId);
const isDarkMode = useSelector(darkMode);
const [reqUpdateLessonContent] = useUpdateLessonContentMutation();
const debounceRef = useRef<null | NodeJS.Timeout>(null);
switch (lessonContent.Type) {
case getTypeByName('Header'):
if (mode === 'view') {
return <div style={{ fontWeight: 'bold', fontSize: 24, wordBreak: 'break-all' }}>{lessonContent.Data}</div>;
case getTypeByName("Header"):
if (mode === "view") {
return (
<div
style={{ fontWeight: "bold", fontSize: 24, wordBreak: "break-all" }}
>
{lessonContent.Data}
</div>
);
}
return (
<Typography.Title
editable={{
triggerType: 'text' as any,
triggerType: "text" as any,
onChange: (event) => {
onEdit?.(event);
@ -56,24 +75,26 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
level={1}
style={{
margin: 0,
width: '100%',
width: "100%",
}}
>
{lessonContent.Data}
</Typography.Title>
);
case getTypeByName('Text'):
if (mode === 'view') {
const formattedText = lessonContent.Data.split('\n').map((line, index) => <li key={index}>{line || '\u00A0'}</li>);
case getTypeByName("Text"):
if (mode === "view") {
const formattedText = lessonContent.Data.split("\n").map(
(line, index) => <li key={index}>{line || "\u00A0"}</li>
);
return (
<ul
style={{
fontSize: 16,
wordBreak: 'break-all',
wordBreak: "break-all",
padding: 0,
margin: 0,
listStyleType: 'none',
listStyleType: "none",
}}
>
{formattedText}
@ -85,12 +106,10 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
<Input.TextArea
autoSize
variant="borderless"
placeholder="Input text here"
style={{ width: '100%', padding: 0, paddingTop: 4 }}
placeholder={t("lessonComponentsConverter.textPlaceholder")}
style={{ width: "100%", padding: 0, paddingTop: 4 }}
value={lessonContent.Data}
onChange={(event) => {
console.log('edit');
onEdit?.(event.target.value);
if (debounceRef.current) {
@ -111,17 +130,15 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
}}
/>
);
case getTypeByName('Image'):
console.log('image', lessonContent.Data);
if (mode === 'view' && lessonContent.Data === '') {
case getTypeByName("Image"):
if (mode === "view" && lessonContent.Data === "") {
return (
<div
style={{
position: 'relative',
position: "relative",
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
marginTop: 4,
borderRadius: 4,
marginRight: 8,
@ -129,13 +146,13 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
No image provided
{t("lessonComponentsConverter.noImageProvided")}
</div>
</div>
);
@ -146,8 +163,7 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/image`}
onChange={(info) => {
if (info.file.status === 'done') {
console.log('done');
if (info.file.status === "done") {
onEdit?.(info.file.response.Data);
}
}}
@ -156,35 +172,37 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
children: <></>,
}}
>
<Button type="link">Gallery</Button>
<Button type="link">
{t("lessonComponentsConverter.gallery")}
</Button>
</MyUpload>
);
};
if (lessonContent.Data === '') {
if (lessonContent.Data === "") {
return (
<div
style={{
position: 'relative',
position: "relative",
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
borderRadius: 4,
margin: '12px 12px 12px 0',
margin: "12px 12px 12px 0",
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<span>Choose image from</span>
<span>{t("lessonComponentsConverter.chooseImageFrom")}</span>
<GalleryUpload />
</div>
@ -193,60 +211,60 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
}
return (
<Flex vertical style={{ width: '100%' }}>
<img src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`} alt="img" style={{ width: '100%' }} />
<Flex vertical style={{ width: "100%" }}>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}
alt="img"
style={{ width: "100%" }}
/>
{mode === 'edititable' && (
{mode === "edititable" && (
<Flex
style={{
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
marginTop: 4,
borderRadius: 4,
}}
justify="center"
>
<div>
<span>Choose another image from</span>
<span>
{t("lessonComponentsConverter.chooseAnotherImageFrom")}
</span>
<GalleryUpload />
</div>
</Flex>
)}
</Flex>
);
case getTypeByName('YouTube'):
case getTypeByName("YouTube"):
const videoId = extractVideoId(lessonContent.Data);
console.log('videoId', videoId);
return (
<Flex vertical style={{ width: '100%', paddingBottom: 12 }}>
<Flex vertical style={{ width: "100%", paddingBottom: 12 }}>
<iframe
width="100%"
height={mode === 'view' ? 422 : 390}
height={mode === "view" ? 422 : 390}
style={{ border: 0 }}
src={`https://www.youtube.com/embed/${videoId}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
></iframe>
{mode === 'edititable' && (
{mode === "edititable" && (
<>
<Typography.Text>Video ID</Typography.Text>
<Typography.Text>
{t("lessonComponentsConverter.videoId")}
</Typography.Text>
<Input
placeholder="https://www.youtube.com/watch?v=Robzc9p1l50 or Robzc9p1l50"
value={lessonContent.Data}
onChange={(event) => {
console.warn('edit', event.target.value, videoId);
onEdit?.(event.target.value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (debounceRef.current) clearTimeout(debounceRef.current);
if (event.target.value === '') {
return;
}
if (event.target.value === "") return;
debounceRef.current = setTimeout(() => {
try {
@ -265,15 +283,15 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
)}
</Flex>
);
case getTypeByName('Video'):
if (mode === 'view' && lessonContent.Data === '') {
case getTypeByName("Video"):
if (mode === "view" && lessonContent.Data === "") {
return (
<div
style={{
position: 'relative',
position: "relative",
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
marginTop: 4,
borderRadius: 4,
marginRight: 8,
@ -281,13 +299,13 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
No video provided
{t("lessonComponentsConverter.noVideoProvided")}
</div>
</div>
);
@ -298,42 +316,41 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/video`}
onChange={(info) => {
if (info.file.status === 'done') {
console.log('done');
if (info.file.status === "done") {
onEdit?.(info.file.response.Data);
}
}}
accept={Constants.ACCEPTED_VIDEO_FILE_TYPES}
>
<Button type="link">Video</Button>
<Button type="link">{t("lessonComponentsConverter.video")}</Button>
</MyUpload>
);
};
if (lessonContent.Data === '') {
if (lessonContent.Data === "") {
return (
<div
style={{
position: 'relative',
position: "relative",
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
borderRadius: 4,
margin: '12px 12px 12px 0',
margin: "12px 12px 12px 0",
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<span>Choose video from</span>
<span>{t("lessonComponentsConverter.chooseVideoFrom")}</span>
<VideoUpload />
</div>
@ -342,18 +359,21 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
}
return (
<Flex vertical style={{ width: '100%' }}>
<MediaPlayer load="idle" src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}>
<Flex vertical style={{ width: "100%" }}>
<MediaPlayer
load="idle"
src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
{mode === 'edititable' && (
{mode === "edititable" && (
<Flex
style={{
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
margin: '4px 0',
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
margin: "4px 0",
borderRadius: 4,
height: 48,
}}
@ -361,7 +381,9 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
align="center"
>
<div>
<span>Choose another video from</span>
<span>
{t("lessonComponentsConverter.chooseAnotherVideoFrom")}
</span>
<VideoUpload />
</div>
</Flex>
@ -369,12 +391,20 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
</Flex>
);
case getTypeByName('Iframe'):
return <div style={{ fontSize: 14, width: '100%' }}>Not implemented</div>;
case getTypeByName('Banner'):
return <div style={{ fontSize: 14, width: '100%' }}>Not implemented</div>;
case getTypeByName("Iframe"):
return (
<div style={{ fontSize: 14, width: "100%" }}>
{t("lessonComponentsConverter.notImplemented")}
</div>
);
case getTypeByName("Banner"):
return (
<div style={{ fontSize: 14, width: "100%" }}>
{t("lessonComponentsConverter.notImplemented")}
</div>
);
default:
return <div>Unknown type</div>;
return <div>{t("lessonComponentsConverter.unkownType")}</div>;
}
}

View File

@ -16,15 +16,23 @@ import LessonPreviewCard from "shared/components/MyLessonPreviewCard";
import { useMessage } from "core/context/MessageContext";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { lessons, setLessons } from "./lessonsSlice";
import {
lessons,
searchFilter,
setLessons,
setSearchFilter,
} from "./lessonsSlice";
import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import MyCenteredSpin from "shared/components/MyCenteredSpin";
import { useTranslation } from "react-i18next";
const CreateLessonButton: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [createLesson, { isLoading }] = useCreateLessonMutation();
const { success, error } = useMessage();
@ -33,7 +41,7 @@ const CreateLessonButton: React.FC = () => {
const res = await createLesson({}).unwrap();
if (res && res.Id) {
success("Lesson created successfully ");
success(t("lessons.messageLessonSuccessfullyCreated"));
navigate(
Constants.ROUTE_PATHS.LESSIONS.PAGE_EDITOR.replace(
@ -44,7 +52,7 @@ const CreateLessonButton: React.FC = () => {
}
} catch (err) {
console.error(err);
error("Failed to create lesson");
error(t("common.messageRequestFailed"));
}
};
@ -54,14 +62,16 @@ const CreateLessonButton: React.FC = () => {
onClick={handleCreateLesson}
loading={isLoading}
>
Create
{t("common.create")}
</Button>
);
};
const LessonList: React.FC = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const dataLessons = useSelector(lessons);
const dataSearchFilter = useSelector(searchFilter);
const { data, error, isLoading, refetch } = useGetLessonsQuery(undefined, {
refetchOnMountOrArgChange: true,
@ -84,8 +94,12 @@ const LessonList: React.FC = () => {
if (!dataLessons || dataLessons.length === 0) return <MyEmpty />;
const publishedItems = dataLessons.filter((item) => item.State === 1);
const unpublishedItems = dataLessons.filter((item) => item.State === 2);
const filteredItems = dataLessons.filter((item) =>
item.Title.toLowerCase().includes(dataSearchFilter.toLowerCase())
);
const publishedItems = filteredItems.filter((item) => item.State === 1);
const unpublishedItems = filteredItems.filter((item) => item.State === 2);
return (
<>
@ -100,6 +114,7 @@ const LessonList: React.FC = () => {
lessonSettings={{
Title: item.Title,
ThumbnailUrl: item.ThumbnailUrl,
QuestionsCount: item.QuestionsCount,
}}
/>
))}
@ -109,7 +124,7 @@ const LessonList: React.FC = () => {
{unpublishedItems.length > 0 && (
<>
<Divider orientation="left" style={{ marginBottom: 0 }}>
Unpublished
{t("lessons.lessonStatusDrafts")}
</Divider>
{unpublishedItems.map((item, index) => (
@ -121,6 +136,7 @@ const LessonList: React.FC = () => {
lessonSettings={{
Title: item.Title,
ThumbnailUrl: item.ThumbnailUrl,
QuestionsCount: item.QuestionsCount,
}}
/>
))}
@ -131,20 +147,29 @@ const LessonList: React.FC = () => {
};
export default function Lessons() {
const onSearch: SearchProps["onSearch"] = (value, _e, info) =>
console.log(info?.source, value);
const { t } = useTranslation();
const dispatch = useDispatch();
useEffect(() => {
return () => {
dispatch(setSearchFilter(""));
};
}, []);
return (
<>
<MyBanner title="Lessons" headerBar={<HeaderBar />} />
<MyBanner title={t("lessons.bannerTitle")} headerBar={<HeaderBar />} />
<MyContainer>
<Flex justify="right" gap={16} style={{ paddingBottom: 16 }}>
<CreateLessonButton />
<Search
placeholder="Search..."
onSearch={onSearch}
placeholder={t("lessons.searchPlaceholder")}
onChange={(e) => {
dispatch(setSearchFilter(e.target.value));
}}
style={{ width: 300 }}
allowClear
/>

View File

@ -10,6 +10,7 @@ export const lessonsSlice = createSlice({
name: "lessons",
initialState: {
lessons: [] as Lesson[],
searchFilter: "",
},
reducers: {
setLessons: (state, action) => {
@ -45,9 +46,13 @@ export const lessonsSlice = createSlice({
lesson.State = action.payload.State;
}
},
setSearchFilter: (state, action) => {
state.searchFilter = action.payload;
},
},
selectors: {
lessons: (state) => state.lessons,
searchFilter: (state) => state.searchFilter,
},
});
@ -57,6 +62,7 @@ export const {
updateLessonPreviewTitle,
updateLessonPreviewThumbnail,
updateLessonState,
setSearchFilter,
} = lessonsSlice.actions;
export const { lessons } = lessonsSlice.selectors;
export const { lessons, searchFilter } = lessonsSlice.selectors;

View File

@ -12,8 +12,12 @@ import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import MyUserAvatar from "shared/components/MyUserAvatar";
import { useTranslation } from "react-i18next";
export default function Roles() {
const { t } = useTranslation();
const { data, error, isLoading, refetch } = useGetRolesQuery(undefined, {
refetchOnMountOrArgChange: true,
});
@ -26,7 +30,11 @@ export default function Roles() {
return (
<>
<MyBanner title="Roles" subtitle="MANAGE" headerBar={<HeaderBar />} />
<MyBanner
title={t("roles.bannerTitle")}
subtitle={t("common.bannerSubtitle")}
headerBar={<HeaderBar />}
/>
<MyContainer
style={{
@ -61,6 +69,7 @@ interface Permission {
}
// test data
const tmpI18nObj = [
{
id: 1,
@ -91,17 +100,28 @@ const tmpI18nObj = [
},
];
/*
export const tmpRoleNames = {
"d0f0fa0d-3f3b-438b-a76f-7febeb8aab57": "Admin",
"b7359e12-359e-423b-b39c-f0d4069adebc": "Moderator",
"a1f084ad-d501-4015-b326-4c5c46fd1c5e": "User",
} as any;
} as any; */
function RoleComponent({ role }: { role: Role }) {
const teamPermissions = tmpI18nObj.filter(
const { t } = useTranslation();
const tmpI18nRoles = t("roles.roles", {
returnObjects: true,
}) as Permission[];
const tmpRoleNames = t("roles.roleNames", {
returnObjects: true,
}) as any;
const teamPermissions = tmpI18nRoles.filter(
(permission) => permission.category === "Team"
);
const rolePermissions = tmpI18nObj.filter(
const rolePermissions = tmpI18nRoles.filter(
(permission) => permission.category === "Roles"
);
@ -130,7 +150,7 @@ function RoleComponent({ role }: { role: Role }) {
items={[
{
key: "1",
label: "Team",
label: t("roles.collapseTitleTeam"),
children: (
<>
{teamPermissions.map((permission, index) => (
@ -141,7 +161,7 @@ function RoleComponent({ role }: { role: Role }) {
},
{
key: "2",
label: "Roles",
label: t("roles.collapseTitleRoles"),
children: (
<>
{rolePermissions.map((permission, index) => (
@ -168,9 +188,13 @@ function RoleComponent({ role }: { role: Role }) {
title={`${user.FirstName} ${user.LastName}`}
placement="top"
>
<Avatar style={{ backgroundColor: "#f56a00" }}>
{user.FirstName[0]}
</Avatar>
<div>
<MyUserAvatar
profilePictureUrl={user.ProfilePictureUrl}
firstName={user.FirstName}
size={32}
/>
</div>
</Tooltip>
))}
</Avatar.Group>

View File

@ -30,6 +30,7 @@ import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import { useTranslation } from "react-i18next";
type GeneralFieldType = {
primaryColor: string | AggregationColor;
@ -37,6 +38,8 @@ type GeneralFieldType = {
};
export default function Settings() {
const { t } = useTranslation();
const { data, error, isLoading, refetch } = useGetOrganizationSettingsQuery(
undefined,
{
@ -54,7 +57,11 @@ export default function Settings() {
return (
<>
<MyBanner title="Settings" subtitle="MANAGE" headerBar={<HeaderBar />} />
<MyBanner
title={t("organizationSettings.bannerTitle")}
subtitle={t("common.bannerSubtitle")}
headerBar={<HeaderBar />}
/>
<GeneralCard data={data} isLoading={isLoading} />
@ -69,6 +76,7 @@ function GeneralCard({
data,
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const { t } = useTranslation();
const [form] = useForm<GeneralFieldType>();
const { success, error: errorMessage } = useMessage();
@ -93,10 +101,12 @@ function GeneralCard({
currentPrimaryColor.current = hexColor;
success("Settings updated successfully!");
success(
t("organizationSettings.generalCard.messageSettingsSuccessfullyUpdated")
);
} catch (error) {
console.error(error);
errorMessage("Failed to update settings!");
errorMessage(t("common.messageRequestFailed"));
}
};
@ -129,7 +139,7 @@ function GeneralCard({
padding: 16,
},
}}
title="General"
title={t("organizationSettings.generalCard.title")}
loading={isLoading}
extra={
<Button
@ -145,7 +155,7 @@ function GeneralCard({
<Flex wrap gap={12}>
<Form.Item<GeneralFieldType>
name="primaryColor"
label="Primary color"
label={t("organizationSettings.generalCard.primaryColor")}
>
<ColorPicker
size="small"
@ -162,7 +172,10 @@ function GeneralCard({
/>
</Form.Item>
<Form.Item<GeneralFieldType> name="companyName" label="Company name">
<Form.Item<GeneralFieldType>
name="companyName"
label={t("organizationSettings.generalCard.companyName")}
>
<Input />
</Form.Item>
</Flex>
@ -174,6 +187,7 @@ function GeneralCard({
function MediaCard({
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const { t } = useTranslation();
const { success } = useMessage();
const dispatch = useDispatch();
@ -182,15 +196,22 @@ function MediaCard({
return (
<Form layout="vertical">
<MyMiddleCard title="Media" loading={isLoading}>
<Form.Item label="Logo">
<MyMiddleCard
title={t("organizationSettings.mediaCard.title")}
loading={isLoading}
>
<Form.Item label={t("organizationSettings.mediaCard.logo")}>
<MyUpload
action="/organization/file/logo"
onChange={(info) => {
if (info.file.status === "done" && info.file.response.Data) {
dispatch(setLogoUrl(info.file.response.Data));
success("Logo updated successfully!");
success(
t(
"organizationSettings.mediaCard.messageLogoSuccessfullyUpdated"
)
);
}
}}
imgCropProps={{
@ -202,7 +223,7 @@ function MediaCard({
src={`${Constants.STATIC_CONTENT_ADDRESS}/${
appLogoUrl || Constants.DEMO_LOGO_URL
}`}
alt="Company Logo"
alt={t("organizationSettings.mediaCard.logo")}
style={{
width: 128,
maxHeight: 128,
@ -214,14 +235,18 @@ function MediaCard({
</MyUpload>
</Form.Item>
<Form.Item label="Banner">
<Form.Item label={t("organizationSettings.mediaCard.banner")}>
<MyUpload
action="/organization/file/banner"
onChange={(info) => {
if (info.file.status === "done" && info.file.response.Data) {
dispatch(setBannerUrl(info.file.response.Data));
success("Banner updated successfully!");
success(
t(
"organizationSettings.mediaCard.messageBannerSuccessfullyUpdated"
)
);
}
}}
imgCropProps={{
@ -233,7 +258,7 @@ function MediaCard({
src={`${Constants.STATIC_CONTENT_ADDRESS}/${
appBannerUrl || Constants.DEMO_BANNER_URL
}`}
alt="Banner"
alt={t("organizationSettings.mediaCard.banner")}
style={{
width: "100%",
height: 228,
@ -256,6 +281,7 @@ function SubdomainCard({
data,
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const { t } = useTranslation();
const [form] = useForm();
const { success, info } = useMessage();
@ -283,12 +309,16 @@ function SubdomainCard({
const { data } = await reqIsSubdomainAvailable(value);
if (!data.Available) {
return Promise.reject("This subdomain is already taken!");
return Promise.reject(
t("organizationSettings.subdomainCard.subdomainAlreadyTaken")
);
}
return Promise.resolve();
} catch (error) {
return Promise.reject("This subdomain is already taken!");
return Promise.reject(
t("organizationSettings.subdomainCard.subdomainAlreadyTaken")
);
}
}
@ -306,17 +336,27 @@ function SubdomainCard({
return (
<>
<Modal
title="Change Subdomain"
title={t(
"organizationSettings.subdomainCard.modalChangeSubdomain.title"
)}
open={isModalOpen}
centered
onCancel={() => setIsModalOpen(false)}
okText="Change"
okText={t("common.change")}
onOk={async () => {
try {
await reqUpdateSubdomain(form.getFieldValue("subdomain"));
success("Subdomain updated successfully!");
info("You will be redirected to the new subdomain!");
success(
t(
"organizationSettings.subdomainCard.modalChangeSubdomain.messageSubdomainSuccessfullyUpdated"
)
);
info(
t(
"organizationSettings.subdomainCard.modalChangeSubdomain.messageRedirect"
)
);
/*
window.location.href = `https://${form.getFieldValue(
"subdomain"
@ -327,16 +367,13 @@ function SubdomainCard({
}}
>
<p>
Changing your subdomain will make your organization available at the
new subdomain. Please note that you will be logged out and redirected
to the new subdomain. Also the old subdomain will be available for
registration by other users.
{t("organizationSettings.subdomainCard.modalChangeSubdomain.message")}
</p>
</Modal>
<Form form={form} layout="vertical" requiredMark={false}>
<MyMiddleCard
title="Subdomain"
title={t("organizationSettings.subdomainCard.middleCardTitle")}
loading={isLoading}
extra={
<Button
@ -348,9 +385,7 @@ function SubdomainCard({
form
.validateFields()
.then(() => setIsModalOpen(true))
.catch(() => {
console.error("Validation failed!");
});
.catch(() => {});
}}
/>
}
@ -358,25 +393,37 @@ function SubdomainCard({
<Flex>
<Form.Item
name="subdomain"
label="Subdomain"
label={t("organizationSettings.subdomainCard.subdomain")}
hasFeedback
validateDebounce={300}
rules={[
{
required: true,
message: "Please input your subdomain!",
message: t(
"organizationSettings.subdomainCard.rules.required"
),
},
{
pattern: subdomainPattern,
message: "Please enter a valid subdomain!",
message: t("organizationSettings.subdomainCard.rules.valid"),
},
{
min: Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH,
message: `Subdomain must be at least ${Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH} characters!`,
message: t(
"organizationSettings.subdomainCard.rules.minLength",
{
min: Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH,
}
),
},
{
max: Constants.GLOBALS.MAX_SUBDOMAIN_LENGTH,
message: "Subdomain is too long!",
message: t(
"organizationSettings.subdomainCard.rules.maxLength",
{
max: Constants.GLOBALS.MAX_SUBDOMAIN_LENGTH,
}
),
},
{
required: true,
@ -384,7 +431,11 @@ function SubdomainCard({
},
]}
>
<Input addonBefore="https://" addonAfter=". xx.de" />
<Input
addonBefore="https://"
addonAfter=". xx.de"
maxLength={Constants.GLOBALS.MAX_SUBDOMAIN_LENGTH}
/>
</Form.Item>
</Flex>
</MyMiddleCard>

View File

@ -1,7 +1,16 @@
import HeaderBar from "core/components/Header";
import { useTranslation } from "react-i18next";
import MyBanner from "shared/components/MyBanner";
export default function SuggestFeature() {
const { t } = useTranslation();
return (
<>
<h1>SuggestFeature</h1>
<MyBanner
title={t("suggestFeature.bannerTitle")}
headerBar={<HeaderBar />}
/>
</>
);
}

View File

@ -4,6 +4,7 @@ import HeaderBar from "core/components/Header";
import { useMessage } from "core/context/MessageContext";
import { useCreateTeamMemberMutation } from "core/services/organization";
import { Constants, EncodeStringToBase64 } from "core/utils/utils";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import MyMiddleCard from "shared/components/MyMiddleCard";
@ -17,11 +18,17 @@ type FieldType = {
};
export default function TeamCreateUser() {
const { t } = useTranslation();
const navigate = useNavigate();
const { success } = useMessage();
const [reqCreateTeamMember, { isLoading }] = useCreateTeamMemberMutation();
const tmpRoleNames = t("roles.roleNames", {
returnObjects: true,
}) as any;
return (
<>
<HeaderBar
@ -29,7 +36,7 @@ export default function TeamCreateUser() {
backTo={Constants.ROUTE_PATHS.ORGANIZATION_TEAM}
/>
<MyMiddleCard title="Create User">
<MyMiddleCard title={t("teamCreateUser.middleCardTitle")}>
<Form
layout="vertical"
requiredMark={false}
@ -48,7 +55,7 @@ export default function TeamCreateUser() {
password: EncodeStringToBase64(values.password),
}).unwrap();
success("User created successfully!");
success(t("teamCreateUser.messageUserSuccessfullyCreated"));
navigate(Constants.ROUTE_PATHS.ORGANIZATION_TEAM);
} catch (error) {
@ -57,74 +64,90 @@ export default function TeamCreateUser() {
}}
>
<Form.Item<FieldType>
label="First Name"
label={t("common.firstName")}
name="firstName"
rules={[
{ required: true, message: "Please input first name!" },
{ required: true, message: t("common.firstNameRules.required") },
{
min: Constants.GLOBALS.MIN_FIRST_NAME_LENGTH,
message: `First name must be at least ${Constants.GLOBALS.MIN_FIRST_NAME_LENGTH} characters long!`,
message: t("common.firstNameRules.minLength", {
minLength: Constants.GLOBALS.MIN_FIRST_NAME_LENGTH,
}),
},
{
max: Constants.GLOBALS.MAX_FIRST_NAME_LENGTH,
message: `First name must be at most ${Constants.GLOBALS.MAX_FIRST_NAME_LENGTH} characters long!`,
message: t("common.firstNameRules.maxLength", {
maxLength: Constants.GLOBALS.MAX_FIRST_NAME_LENGTH,
}),
},
]}
>
<Input
placeholder="First Name"
placeholder={t("common.firstName")}
maxLength={Constants.GLOBALS.MAX_FIRST_NAME_LENGTH}
/>
</Form.Item>
<Form.Item<FieldType>
label="Last Name"
label={t("common.lastName")}
name="lastName"
rules={[
{ required: true, message: "Please input last name!" },
{ required: true, message: t("common.lastNameRules.required") },
{
min: Constants.GLOBALS.MIN_LAST_NAME_LENGTH,
message: `Last name must be at least ${Constants.GLOBALS.MIN_LAST_NAME_LENGTH} characters long!`,
message: t("common.lastNameRules.minLength", {
minLength: Constants.GLOBALS.MIN_LAST_NAME_LENGTH,
}),
},
{
max: Constants.GLOBALS.MAX_LAST_NAME_LENGTH,
message: `Last name must be at most ${Constants.GLOBALS.MAX_LAST_NAME_LENGTH} characters long!`,
message: t("common.lastNameRules.maxLength", {
maxLength: Constants.GLOBALS.MAX_LAST_NAME_LENGTH,
}),
},
]}
>
<Input
placeholder="Last Name"
placeholder={t("common.lastName")}
maxLength={Constants.GLOBALS.MAX_LAST_NAME_LENGTH}
/>
</Form.Item>
<Form.Item<FieldType>
label="Email"
label={t("common.email")}
name="email"
rules={[
{ required: true, message: "Please input email!", type: "email" },
{
required: true,
message: t("common.emailRules.valid"),
type: "email",
},
]}
>
<Input placeholder="Email" />
</Form.Item>
<Form.Item<FieldType>
label="Password"
label={t("common.password")}
name="password"
rules={[
{ required: true, message: "Please input password!" },
{ required: true, message: t("common.passwordRules.required") },
{
min: Constants.GLOBALS.MIN_PASSWORD_LENGTH,
message: `Password must be at least ${Constants.GLOBALS.MIN_PASSWORD_LENGTH} characters long!`,
message: t("common.passwordRules.minLength", {
minLength: Constants.GLOBALS.MIN_PASSWORD_LENGTH,
}),
},
{
max: Constants.GLOBALS.MAX_PASSWORD_LENGTH,
message: `Password must be at most ${Constants.GLOBALS.MAX_PASSWORD_LENGTH} characters long!`,
message: t("common.passwordRules.maxLength", {
maxLength: Constants.GLOBALS.MAX_PASSWORD_LENGTH,
}),
},
]}
>
<Input.Password
placeholder="Password"
placeholder={t("common.password")}
maxLength={Constants.GLOBALS.MAX_PASSWORD_LENGTH}
/>
</Form.Item>
@ -132,13 +155,13 @@ export default function TeamCreateUser() {
<Form.Item name="roleId" label="Role">
<Select>
<Select.Option value="d0f0fa0d-3f3b-438b-a76f-7febeb8aab57">
Admin
{tmpRoleNames["d0f0fa0d-3f3b-438b-a76f-7febeb8aab57"]}
</Select.Option>
<Select.Option value="b7359e12-359e-423b-b39c-f0d4069adebc">
Moderator
{tmpRoleNames["b7359e12-359e-423b-b39c-f0d4069adebc"]}
</Select.Option>
<Select.Option value="a1f084ad-d501-4015-b326-4c5c46fd1c5e">
User
{tmpRoleNames["a1f084ad-d501-4015-b326-4c5c46fd1c5e"]}
</Select.Option>
</Select>
</Form.Item>
@ -150,7 +173,7 @@ export default function TeamCreateUser() {
htmlType="submit"
loading={isLoading}
>
Create User
{t("teamCreateUser.buttonCreateUser")}
</Button>
</Flex>
</Form>

View File

@ -15,15 +15,16 @@ import MyErrorResult from "shared/components/MyResult";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setTeamMembers, teamMembers } from "./teamSlice";
import { tmpRoleNames } from "features/Roles";
import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import { useMessage } from "core/context/MessageContext";
import MyUserAvatar from "shared/components/MyUserAvatar";
import { useTranslation } from "react-i18next";
const TeamList: React.FC = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const { success, error: errorMessage } = useMessage();
@ -41,41 +42,45 @@ const TeamList: React.FC = () => {
const [reqDeleteTeamMember, { isLoading: loadingDeleteTeamMember }] =
useDeleteTeamMemberMutation();
const tmpRoleNames = t("roles.roleNames", {
returnObjects: true,
}) as any;
const getTableContent = () => {
let items = [
{
title: "First name",
title: t("common.firstName"),
dataIndex: "firstName",
key: "firstName",
},
{
title: "Last name",
title: t("common.lastName"),
dataIndex: "lastName",
key: "lastName",
},
{
title: "Email",
title: t("common.email"),
dataIndex: "email",
key: "email",
},
{
title: "Role",
title: t("common.role"),
dataIndex: "role",
key: "role",
},
{
title: "Status",
title: t("common.status"),
dataIndex: "status",
key: "status",
},
{
title: "Actions",
title: t("common.actions"),
dataIndex: "actions",
key: "actions",
render: (_: any, record: any) => (
<Space size="middle">
<Popconfirm
title="Change role to"
title={t("team.popConfirmRoleChange.title")}
onConfirm={async () => {
try {
await reqUpdateTeamMemberRole({
@ -83,13 +88,18 @@ const TeamList: React.FC = () => {
roleId: selectedRoleId,
}).unwrap();
success("Role updated successfully");
success(
t(
"team.popConfirmRoleChange.messageUpdatedRoleSuccessfully"
)
);
} catch (error) {
console.error(error);
errorMessage("Error updating role");
errorMessage(t("common.messageRequestFailed"));
}
}}
okButtonProps={{ loading: loadingUpdateTeamMemberRole }}
cancelText={t("common.cancel")}
description={
<Select
style={{ width: 150 }}
@ -109,27 +119,32 @@ const TeamList: React.FC = () => {
type="link"
onClick={() => setSelectedRoleId(record.role)}
>
Change role
{t("team.changeRole")}
</Button>
</Popconfirm>
<Popconfirm
title="Confirm deletion of team member"
title={t("team.popConfirmDeleteMember.title")}
okButtonProps={{
loading: loadingDeleteTeamMember,
}}
cancelText={t("common.cancel")}
onConfirm={async () => {
try {
await reqDeleteTeamMember(record.key).unwrap();
success("Team member deleted successfully");
success(
t(
"team.popConfirmDeleteMember.messageDeletedMemberSuccessfully"
)
);
} catch (error) {
console.error(error);
errorMessage("Error deleting team member");
errorMessage(t("common.messageRequestFailed"));
}
}}
>
<Button type="link">Delete</Button>
<Button type="link">{t("common.delete")}</Button>
</Popconfirm>
</Space>
),
@ -199,9 +214,15 @@ const TeamList: React.FC = () => {
};
export default function Team() {
const { t } = useTranslation();
return (
<>
<MyBanner title="Team" subtitle="MANAGE" headerBar={<HeaderBar />} />
<MyBanner
title={t("team.bannerTitle")}
subtitle={t("common.bannerSubtitle")}
headerBar={<HeaderBar />}
/>
<MyContainer
style={{
@ -212,7 +233,9 @@ export default function Team() {
>
<Flex justify="end">
<Link to={Constants.ROUTE_PATHS.ORGANIZATION_TEAM_CREATE_USER}>
<Button icon={<UserAddOutlined />}>Invite new member</Button>
<Button icon={<UserAddOutlined />}>
{t("team.addMemberButton")}
</Button>
</Link>
</Flex>

View File

@ -1,15 +1,14 @@
import {
Avatar,
Card,
Descriptions,
Flex,
Form,
Input,
Select,
Typography,
} from "antd";
import HeaderBar from "../../core/components/Header";
import MyBanner from "../../shared/components/MyBanner";
import { Constants } from "core/utils/utils";
import MyMiddleCard from "shared/components/MyMiddleCard";
import Meta from "antd/es/card/Meta";
import { useGetUserProfileQuery } from "core/services/userProfile";
@ -31,15 +30,16 @@ import {
setProfilePictureUrl,
setRoleId,
} from "./userProfileSlice";
import { tmpRoleNames } from "features/Roles";
import MyErrorResult from "shared/components/MyResult";
import MyUpload from "shared/components/MyUpload";
import { useMessage } from "core/context/MessageContext";
import MyUserAvatar from "shared/components/MyUserAvatar";
import { setUserProfilePictureUrl } from "core/reducers/appSlice";
import { useTranslation } from "react-i18next";
export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
const dispatch = useDispatch();
const { t, i18n } = useTranslation();
const { success } = useMessage();
const dataProfilePictureUrl = useSelector(profilePictureUrl);
@ -55,6 +55,10 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
}
);
const tmpRoleNames = t("roles.roleNames", {
returnObjects: true,
}) as any;
function AdminWrapper({ children }: { children: React.ReactNode }) {
if (!isAdmin) {
return <>{children}</>;
@ -98,12 +102,15 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
return (
<>
<MyBanner
title="Account Settings"
subtitle="MANAGE"
title={t("userProfile.bannerTitle")}
subtitle={t("common.bannerSubtitle")}
headerBar={<HeaderBar />}
/>
<MyMiddleCard title="My Profile" loading={isLoading}>
<MyMiddleCard
title={t("userProfile.middleCardTitle")}
loading={isLoading}
>
{error ? (
<MyErrorResult />
) : (
@ -115,15 +122,26 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
},
}}
>
<Form
layout="vertical"
initialValues={{
language: i18n.language,
}}
>
<Flex justify="space-between" align="center" wrap gap={24}>
<Meta
avatar={
<MyUpload
action={`/user/profile/picture`}
onChange={(info) => {
if (info.file.status === "done") {
success("Profile picture updated successfully");
success(
t("userProfile.messageProfileSuccessfullyUpdated")
);
dispatch(setProfilePictureUrl(info.file.response.Data));
dispatch(
setProfilePictureUrl(info.file.response.Data)
);
dispatch(
setUserProfilePictureUrl(info.file.response.Data)
);
@ -134,12 +152,30 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
children: <></>,
}}
>
<MyUserAvatar profilePictureUrl={dataProfilePictureUrl} />
<MyUserAvatar
profilePictureUrl={dataProfilePictureUrl}
/>
</MyUpload>
}
title={`${dataFirstName} ${dataLastName}`}
description={tmpRoleNames[dataRoleId]}
/>
<Form.Item label={t("userProfile.language")} name="language">
<Select
onChange={(value) => i18n.changeLanguage(value)}
style={{ width: 120 }}
>
<Select.Option value="en">
{t("userProfile.languageEnglish")}
</Select.Option>
<Select.Option value="de">
{t("userProfile.languageGerman")}
</Select.Option>
</Select>
</Form.Item>
</Flex>
</Form>
</Card>
<Card
@ -151,26 +187,26 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
>
<AdminWrapper>
<Descriptions
title="Personal Information"
title={t("userProfile.personalInformation")}
layout="vertical"
items={[
{
key: "1",
label: "First name",
label: t("common.firstName"),
children: (
<TextItem value={dataFirstName} name="firstName" />
),
},
{
key: "2",
label: "Last name",
label: t("common.lastName"),
children: (
<TextItem value={dataLastName} name="lastName" />
),
},
{
key: "3",
label: "Email",
label: t("common.email"),
children: <TextItem value={dataEmail} name="email" />,
},
]}

View File

@ -1,7 +1,13 @@
import HeaderBar from "core/components/Header";
import { useTranslation } from "react-i18next";
import MyBanner from "shared/components/MyBanner";
export default function WhatsNew() {
const { t } = useTranslation();
return (
<>
<h1>WhatsNew</h1>
<MyBanner title={t("whatsNew.bannerTitle")} headerBar={<HeaderBar />} />
</>
);
}

18
src/i18n.ts Normal file
View File

@ -0,0 +1,18 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.use(LanguageDetector)
.use(Backend)
.init({
supportedLngs: ["en", "de"],
fallbackLng: "de",
interpolation: {
escapeValue: false, // react already safes from xss
},
});
export default i18n;

View File

@ -6,6 +6,7 @@ import { Provider } from "react-redux";
import { store } from "./core/store/store";
import { Suspense } from "react";
import MyCenteredSpin from "./shared/components/MyCenteredSpin";
import "./i18n";
// import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(

View File

@ -1,5 +1,18 @@
import { Empty } from "antd";
import { useTranslation } from "react-i18next";
export default function MyEmpty() {
return <Empty />
interface MyEmptyProps extends React.ComponentProps<typeof Empty> {
children?: React.ReactNode;
}
const MyEmpty: React.FC<MyEmptyProps> = ({ children, ...props }) => {
const { t } = useTranslation();
return (
<Empty {...props} description={t("myEmpty.noData")}>
{children}
</Empty>
);
};
export default MyEmpty;

View File

@ -5,6 +5,7 @@ import { Link } from "react-router-dom";
import MyUpload from "shared/components/MyUpload";
import styles from "./styles.module.css";
import { LessonSettings } from "core/types/lesson";
import { useTranslation } from "react-i18next";
export default function MyLessonPreviewCard({
mode = "view",
@ -21,6 +22,8 @@ export default function MyLessonPreviewCard({
onEditTitle?: (newTitle: string) => void;
onThumbnailChanged?: () => void;
}) {
const { t } = useTranslation();
const LinkWrapper = ({ children }: { children: React.ReactNode }) => {
if (mode === "editable") return <>{children}</>;
@ -74,7 +77,8 @@ export default function MyLessonPreviewCard({
{mode === "view" ? (
<div>
<div className={styles.title}>{lessonSettings.Title}</div>
<CommentOutlined /> 12 comments
<CommentOutlined /> {lessonSettings.QuestionsCount}{" "}
{t("lessonPage.questions")}
</div>
) : (
<Typography.Title

View File

@ -1,11 +1,14 @@
import { Result } from "antd";
import { useTranslation } from "react-i18next";
export default function MyErrorResult() {
const { t } = useTranslation();
return (
<Result
status="error"
title="Something went wrong"
subTitle="Please try again later."
title={t("myErrorResult.title")}
subTitle={t("myErrorResult.subTitle")}
/>
);
}

View File

@ -1,8 +1,8 @@
import { Avatar } from 'antd';
import { Constants } from 'core/utils/utils';
import { UserOutlined } from '@ant-design/icons';
import { primaryColor } from 'core/reducers/appSlice';
import { useSelector } from 'react-redux';
import { Avatar } from "antd";
import { Constants } from "core/utils/utils";
import { UserOutlined } from "@ant-design/icons";
import { primaryColor } from "core/reducers/appSlice";
import { useSelector } from "react-redux";
interface MyUserAvatarProps {
size?: number;
@ -11,20 +11,41 @@ interface MyUserAvatarProps {
disableCursorPointer?: boolean;
}
const MyUserAvatar: React.FC<MyUserAvatarProps> = ({ size = 56, firstName, profilePictureUrl, disableCursorPointer }) => {
const MyUserAvatar: React.FC<MyUserAvatarProps> = ({
size = 56,
firstName,
profilePictureUrl,
disableCursorPointer,
}) => {
const appPrimaryColor = useSelector(primaryColor);
const defaultStyle = disableCursorPointer === undefined ? { cursor: 'pointer' } : {};
const defaultStyle =
disableCursorPointer === undefined ? { cursor: "pointer" } : {};
const isProfilePictureEmpty = profilePictureUrl === '';
const avatarContent = isProfilePictureEmpty && firstName !== undefined ? firstName.charAt(0) : undefined;
const iconContent = isProfilePictureEmpty && firstName === undefined ? <UserOutlined /> : undefined;
const avatarSrc = isProfilePictureEmpty ? undefined : `${Constants.STATIC_CONTENT_ADDRESS}/${profilePictureUrl}`;
const avatarStyle = isProfilePictureEmpty ? { ...defaultStyle, backgroundColor: `#${appPrimaryColor}` } : defaultStyle;
const isProfilePictureEmpty = profilePictureUrl === "";
const avatarContent =
isProfilePictureEmpty && firstName !== undefined
? firstName.charAt(0)
: undefined;
const iconContent =
isProfilePictureEmpty && firstName === undefined ? (
<UserOutlined />
) : undefined;
const avatarSrc = isProfilePictureEmpty
? undefined
: `${Constants.STATIC_CONTENT_ADDRESS}/${profilePictureUrl}`;
const avatarStyle = isProfilePictureEmpty
? { ...defaultStyle, backgroundColor: `#${appPrimaryColor}` }
: defaultStyle;
return (
<div style={{ userSelect: 'none' }}>
<Avatar size={size} style={avatarStyle} src={avatarSrc} icon={iconContent}>
<div style={{ userSelect: "none" }}>
<Avatar
size={size}
style={avatarStyle}
src={avatarSrc}
icon={iconContent}
>
{avatarContent}
</Avatar>
</div>