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,131 +1,176 @@
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';
onView?: () => void;
onEdit?: () => void;
backTo?: string;
theme?: "light" | "dark";
onView?: () => void;
onEdit?: () => void;
backTo?: string;
sticky?: boolean;
};
export default function HeaderBar(props: HeaderBarProps = { theme: 'light' }) {
const dispatch = useDispatch();
export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
const { t } = useTranslation();
const dispatch = useDispatch();
const isCollpased = useSelector(isSideMenuCollapsed);
const isDarkMode = useSelector(darkMode);
const profilePictureUrl = useSelector(userProfilePictureUrl);
const isCollpased = useSelector(isSideMenuCollapsed);
const isDarkMode = useSelector(darkMode);
const profilePictureUrl = useSelector(userProfilePictureUrl);
const navigate = useNavigate();
const navigate = useNavigate();
return (
<Flex
justify="space-between"
align="center"
style={{
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
return (
<Flex
justify="space-between"
align="center"
style={{
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
position: props.sticky ? "sticky" : "relative",
top: 0,
zIndex: 999,
}}
>
<Flex align="center" gap={16}>
<div
className={isDarkMode ? styles.containerDark : styles.containerLight}
style={{ borderRadius: 28, padding: 4 }}
>
<Flex align="center" gap={16}>
<div className={props.theme === 'light' ? styles.containerLight : styles.containerDark} style={{ borderRadius: 28, padding: 4 }}>
{isCollpased ? (
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(false))}>
<MenuUnfoldOutlined className={styles.icon} />
</div>
) : (
<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>
)}
</Flex>
<Flex
align="center"
className={props.theme === 'light' ? styles.containerLight : styles.containerDark}
style={{
borderRadius: 28,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4,
}}
gap={8}
{isCollpased ? (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(false))}
>
{props.onView && (
<div className={styles.iconContainer} onClick={props.onView}>
<EyeOutlined className={styles.icon} />
</div>
)}
<MenuUnfoldOutlined className={styles.icon} />
</div>
) : (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(true))}
>
<MenuFoldOutlined className={styles.icon} />
</div>
)}
</div>
{props.onEdit && (
<div className={styles.iconContainer} onClick={props.onEdit}>
<EditOutlined className={styles.icon} />
</div>
)}
{props.backTo && (
<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>
{isDarkMode ? (
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(false))}>
<SunOutlined className={styles.icon} />
</div>
) : (
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(true))}>
<MoonOutlined className={styles.icon} />
</div>
)}
<Dropdown
overlayStyle={{ minWidth: 150 }}
trigger={['click']}
menu={{
items: [
{
key: '1',
label: 'Profile',
icon: <UserOutlined />,
onClick: () => navigate(Constants.ROUTE_PATHS.ACCOUNT_SETTINGS),
},
{
key: '2',
label: 'Logout',
icon: <LogoutOutlined />,
danger: true,
onClick: () => {
webSocketService.disconnect();
window.localStorage.removeItem('session');
window.location.href = '/';
},
},
],
}}
>
<div>
<MyUserAvatar size={34} profilePictureUrl={profilePictureUrl ? profilePictureUrl : ''} />
</div>
</Dropdown>
</Flex>
</Flex>
);
<Flex
align="center"
className={isDarkMode ? styles.containerDark : styles.containerLight}
style={{
borderRadius: 28,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4,
}}
gap={8}
>
{props.onView && (
<div className={styles.iconContainer} onClick={props.onView}>
<EyeOutlined className={styles.icon} />
</div>
)}
/* return (
{props.onEdit && (
<div className={styles.iconContainer} onClick={props.onEdit}>
<EditOutlined className={styles.icon} />
</div>
)}
{isDarkMode ? (
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(false))}
>
<SunOutlined className={styles.icon} />
</div>
) : (
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(true))}
>
<MoonOutlined className={styles.icon} />
</div>
)}
<Dropdown
overlayStyle={{ minWidth: 150 }}
trigger={["click"]}
menu={{
items: [
{
key: "1",
label: t("header.accountSettings"),
icon: <UserOutlined />,
onClick: () => navigate(Constants.ROUTE_PATHS.ACCOUNT_SETTINGS),
},
{
key: "2",
label: t("header.logout"),
icon: <LogoutOutlined />,
danger: true,
onClick: () => {
webSocketService.disconnect();
window.localStorage.removeItem("session");
window.location.href = "/";
},
},
],
}}
>
<div>
<MyUserAvatar
size={34}
profilePictureUrl={profilePictureUrl ? profilePictureUrl : ""}
/>
</div>
</Dropdown>
</Flex>
</Flex>
);
/* return (
<Header
style={{
position: "sticky",

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,62 +1,112 @@
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);
const [visible, setVisible] = React.useState(false);
return (
<>
{visible ? (
<div
style={{
position: "fixed",
bottom: 100,
right: 10,
zIndex: 10000,
maxWidth: "95vw",
width: 500,
height: 1000,
maxHeight: "calc(100vh - 165px)",
}}
>
<DeepChat
style={{
width: "100%",
height: "100%",
borderRadius: 10,
boxShadow: "0 0 10px rgba(0,0,0,0.1)",
bottom: 0,
position: "absolute",
}}
history={[{ text: "Stell mir Fragen :)", role: "ai" }]}
connect={{
url: "/api/chat/v1/prompt/",
method: "POST",
headers: {
"X-Authorization": getUserSessionFromLocalStorage() || "",
},
}}
onMessage={async (message) => {
console.log("onMessagee", message);
}}
></DeepChat>
</div>
) : null}
return (
<>
{visible ? (
<div
style={{
position: 'fixed',
bottom: 100,
right: 10,
zIndex: 10000,
maxWidth: '95vw',
width: 500,
height: 1000,
maxHeight: 'calc(100vh - 165px)',
}}
>
<DeepChat
style={{
width: '100%',
height: '100%',
borderRadius: 10,
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
bottom: 0,
position: 'absolute',
}}
avatars={true}
history={[{ text: 'Stell mir Fragen :)', role: 'ai' }]}
connect={{
url: '/api/chat/v1/prompt/',
method: 'POST',
headers: {
'X-Authorization': getUserSessionFromLocalStorage() || '',
},
}}
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 };
<FloatButton
icon={<CommentOutlined />}
type="primary"
onClick={() => console.log("onClick")}
style={{ zIndex: 10000 }}
onClickCapture={() => {
setVisible(!visible);
}}
/>
</>
);
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>
) : null}
<FloatButton
icon={<CommentOutlined />}
type="primary"
onClick={() => console.log('onClick')}
style={{ zIndex: 10000 }}
onClickCapture={() => {
setVisible(!visible);
}}
/>
</>
);
}
export default AiChat;

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,76 +1,90 @@
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 { setNodeRef } = useDroppable({ id: droppableID });
const currentLnId = useSelector(currentLessonId);
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 [isDragging, setIsDragging] = React.useState(false);
const itemIDs = items.map((item) => item.Id);
const itemIDs = items.map((item) => item.Id);
const handleDragEnd = (event: DragEndEvent) => {
console.log('drag end', event);
setIsDragging(false);
const handleDragEnd = (event: DragEndEvent) => {
console.log("drag end", event);
setIsDragging(false);
if (!event.over) return;
if (!event.over) return;
const activeId = event.active.id;
const overId = event.over.id;
const activeId = event.active.id;
const overId = event.over.id;
if (activeId === overId) return;
if (activeId === overId) return;
let oldIndex = itemIDs.findIndex((item) => item === activeId);
let newIndex = itemIDs.findIndex((item) => item === overId);
let oldIndex = itemIDs.findIndex((item) => item === activeId);
let newIndex = itemIDs.findIndex((item) => item === overId);
// store.dispatch(onDragHandler({ activeId, overId }));
// store.dispatch(onDragHandler({ activeId, overId }));
store.dispatch(onDragHandler({ oldIndex, newIndex }));
store.dispatch(onDragHandler({ oldIndex, newIndex }));
try {
reqUpdateLessonContentPosition({
lessonId: currentLnId,
contentId: activeId,
newPosition: newIndex + 1,
});
} catch (err) {
console.error(err);
}
};
try {
reqUpdateLessonContentPosition({
lessonId: currentLnId,
contentId: activeId,
newPosition: newIndex + 1,
});
} catch (err) {
console.error(err);
}
};
return (
<DndContext
modifiers={[snapCenterToCursor]}
collisionDetection={closestCorners}
onDragStart={() => {
setIsDragging(true);
}}
onDragEnd={handleDragEnd}
>
<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>
</DndContext>
);
return (
<DndContext
modifiers={[snapCenterToCursor]}
collisionDetection={closestCorners}
onDragStart={() => {
setIsDragging(true);
}}
onDragEnd={handleDragEnd}
>
<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>
</DndContext>
);
};
/*
function handleDragEnd(event: DragEndEvent) {

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,380 +1,410 @@
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 match = url.match(regex);
return match ? match[1] : url;
// regex to extract video id from youtube url
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 isDarkMode = useSelector(darkMode);
const lessonId = useSelector(currentLessonId);
const isDarkMode = useSelector(darkMode);
const [reqUpdateLessonContent] = useUpdateLessonContentMutation();
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>
);
}
switch (lessonContent.Type) {
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,
onChange: (event) => {
onEdit?.(event);
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: event,
});
} catch (err) {
console.error(err);
}
},
}}
level={1}
style={{
margin: 0,
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>
);
return (
<ul
style={{
fontSize: 16,
wordBreak: "break-all",
padding: 0,
margin: 0,
listStyleType: "none",
}}
>
{formattedText}
</ul>
);
}
return (
<Input.TextArea
autoSize
variant="borderless"
placeholder={t("lessonComponentsConverter.textPlaceholder")}
style={{ width: "100%", padding: 0, paddingTop: 4 }}
value={lessonContent.Data}
onChange={(event) => {
onEdit?.(event.target.value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
return (
<Typography.Title
editable={{
triggerType: 'text' as any,
onChange: (event) => {
onEdit?.(event);
debounceRef.current = setTimeout(() => {
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: event.target.value,
});
} catch (err) {
console.error(err);
}
}, 1000);
}}
/>
);
case getTypeByName("Image"):
if (mode === "view" && lessonContent.Data === "") {
return (
<div
style={{
position: "relative",
height: 120,
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
marginTop: 4,
borderRadius: 4,
marginRight: 8,
}}
>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{t("lessonComponentsConverter.noImageProvided")}
</div>
</div>
);
}
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: event,
});
} catch (err) {
console.error(err);
}
},
}}
level={1}
style={{
margin: 0,
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>);
const GalleryUpload = () => {
return (
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/image`}
onChange={(info) => {
if (info.file.status === "done") {
onEdit?.(info.file.response.Data);
}
}}
imgCropProps={{
aspect: 5 / 4,
children: <></>,
}}
>
<Button type="link">
{t("lessonComponentsConverter.gallery")}
</Button>
</MyUpload>
);
};
return (
<ul
style={{
fontSize: 16,
wordBreak: 'break-all',
padding: 0,
margin: 0,
listStyleType: 'none',
}}
>
{formattedText}
</ul>
);
}
if (lessonContent.Data === "") {
return (
<div
style={{
position: "relative",
height: 120,
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
borderRadius: 4,
margin: "12px 12px 12px 0",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<span>{t("lessonComponentsConverter.chooseImageFrom")}</span>
return (
<Input.TextArea
autoSize
variant="borderless"
placeholder="Input text here"
style={{ width: '100%', padding: 0, paddingTop: 4 }}
value={lessonContent.Data}
onChange={(event) => {
console.log('edit');
<GalleryUpload />
</div>
</div>
);
}
onEdit?.(event.target.value);
return (
<Flex vertical style={{ width: "100%" }}>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}
alt="img"
style={{ width: "100%" }}
/>
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
{mode === "edititable" && (
<Flex
style={{
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
marginTop: 4,
borderRadius: 4,
}}
justify="center"
>
<div>
<span>
{t("lessonComponentsConverter.chooseAnotherImageFrom")}
</span>
<GalleryUpload />
</div>
</Flex>
)}
</Flex>
);
case getTypeByName("YouTube"):
const videoId = extractVideoId(lessonContent.Data);
debounceRef.current = setTimeout(() => {
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: event.target.value,
});
} catch (err) {
console.error(err);
}
}, 1000);
}}
/>
);
case getTypeByName('Image'):
console.log('image', lessonContent.Data);
return (
<Flex vertical style={{ width: "100%", paddingBottom: 12 }}>
<iframe
width="100%"
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>
if (mode === 'view' && lessonContent.Data === '') {
return (
<div
style={{
position: 'relative',
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
marginTop: 4,
borderRadius: 4,
marginRight: 8,
}}
>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
No image provided
</div>
</div>
);
}
{mode === "edititable" && (
<>
<Typography.Text>
{t("lessonComponentsConverter.videoId")}
</Typography.Text>
<Input
placeholder="https://www.youtube.com/watch?v=Robzc9p1l50 or Robzc9p1l50"
value={lessonContent.Data}
onChange={(event) => {
onEdit?.(event.target.value);
const GalleryUpload = () => {
return (
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/image`}
onChange={(info) => {
if (info.file.status === 'done') {
console.log('done');
onEdit?.(info.file.response.Data);
}
}}
imgCropProps={{
aspect: 5 / 4,
children: <></>,
}}
>
<Button type="link">Gallery</Button>
</MyUpload>
);
};
if (debounceRef.current) clearTimeout(debounceRef.current);
if (lessonContent.Data === '') {
return (
<div
style={{
position: 'relative',
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
borderRadius: 4,
margin: '12px 12px 12px 0',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<span>Choose image from</span>
if (event.target.value === "") return;
<GalleryUpload />
</div>
</div>
);
}
debounceRef.current = setTimeout(() => {
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: extractVideoId(event.target.value),
});
} catch (err) {
console.error(err);
}
}, 1000);
}}
/>
</>
)}
</Flex>
);
case getTypeByName("Video"):
if (mode === "view" && lessonContent.Data === "") {
return (
<div
style={{
position: "relative",
height: 120,
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
marginTop: 4,
borderRadius: 4,
marginRight: 8,
}}
>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{t("lessonComponentsConverter.noVideoProvided")}
</div>
</div>
);
}
return (
<Flex vertical style={{ width: '100%' }}>
<img src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`} alt="img" style={{ width: '100%' }} />
const VideoUpload = () => {
return (
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/video`}
onChange={(info) => {
if (info.file.status === "done") {
onEdit?.(info.file.response.Data);
}
}}
accept={Constants.ACCEPTED_VIDEO_FILE_TYPES}
>
<Button type="link">{t("lessonComponentsConverter.video")}</Button>
</MyUpload>
);
};
{mode === 'edititable' && (
<Flex
style={{
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
marginTop: 4,
borderRadius: 4,
}}
justify="center"
>
<div>
<span>Choose another image from</span>
<GalleryUpload />
</div>
</Flex>
)}
</Flex>
);
case getTypeByName('YouTube'):
const videoId = extractVideoId(lessonContent.Data);
if (lessonContent.Data === "") {
return (
<div
style={{
position: "relative",
height: 120,
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
borderRadius: 4,
margin: "12px 12px 12px 0",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<span>{t("lessonComponentsConverter.chooseVideoFrom")}</span>
console.log('videoId', videoId);
<VideoUpload />
</div>
</div>
);
}
return (
<Flex vertical style={{ width: '100%', paddingBottom: 12 }}>
<iframe
width="100%"
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>
return (
<Flex vertical style={{ width: "100%" }}>
<MediaPlayer
load="idle"
src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
{mode === 'edititable' && (
<>
<Typography.Text>Video ID</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);
{mode === "edititable" && (
<Flex
style={{
width: "100%",
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
margin: "4px 0",
borderRadius: 4,
height: 48,
}}
justify="center"
align="center"
>
<div>
<span>
{t("lessonComponentsConverter.chooseAnotherVideoFrom")}
</span>
<VideoUpload />
</div>
</Flex>
)}
</Flex>
);
onEdit?.(event.target.value);
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>
);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (event.target.value === '') {
return;
}
debounceRef.current = setTimeout(() => {
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: extractVideoId(event.target.value),
});
} catch (err) {
console.error(err);
}
}, 1000);
}}
/>
</>
)}
</Flex>
);
case getTypeByName('Video'):
if (mode === 'view' && lessonContent.Data === '') {
return (
<div
style={{
position: 'relative',
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
marginTop: 4,
borderRadius: 4,
marginRight: 8,
}}
>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
No video provided
</div>
</div>
);
}
const VideoUpload = () => {
return (
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/video`}
onChange={(info) => {
if (info.file.status === 'done') {
console.log('done');
onEdit?.(info.file.response.Data);
}
}}
accept={Constants.ACCEPTED_VIDEO_FILE_TYPES}
>
<Button type="link">Video</Button>
</MyUpload>
);
};
if (lessonContent.Data === '') {
return (
<div
style={{
position: 'relative',
height: 120,
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
borderRadius: 4,
margin: '12px 12px 12px 0',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<span>Choose video from</span>
<VideoUpload />
</div>
</div>
);
}
return (
<Flex vertical style={{ width: '100%' }}>
<MediaPlayer load="idle" src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
{mode === 'edititable' && (
<Flex
style={{
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
margin: '4px 0',
borderRadius: 4,
height: 48,
}}
justify="center"
align="center"
>
<div>
<span>Choose another video from</span>
<VideoUpload />
</div>
</Flex>
)}
</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>;
default:
return <div>Unknown type</div>;
}
default:
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,31 +122,60 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
},
}}
>
<Meta
avatar={
<MyUpload
action={`/user/profile/picture`}
onChange={(info) => {
if (info.file.status === "done") {
success("Profile picture updated successfully");
<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(
t("userProfile.messageProfileSuccessfullyUpdated")
);
dispatch(setProfilePictureUrl(info.file.response.Data));
dispatch(
setUserProfilePictureUrl(info.file.response.Data)
);
}
}}
imgCropProps={{
aspect: 1 / 1,
children: <></>,
}}
>
<MyUserAvatar profilePictureUrl={dataProfilePictureUrl} />
</MyUpload>
}
title={`${dataFirstName} ${dataLastName}`}
description={tmpRoleNames[dataRoleId]}
/>
dispatch(
setProfilePictureUrl(info.file.response.Data)
);
dispatch(
setUserProfilePictureUrl(info.file.response.Data)
);
}
}}
imgCropProps={{
aspect: 1 / 1,
children: <></>,
}}
>
<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() {
return (
<>
<h1>WhatsNew</h1>
</>
);
const { t } = useTranslation();
return (
<>
<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,34 +1,55 @@
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;
firstName?: string;
profilePictureUrl: string;
disableCursorPointer?: boolean;
size?: number;
firstName?: string;
profilePictureUrl: string;
disableCursorPointer?: boolean;
}
const MyUserAvatar: React.FC<MyUserAvatarProps> = ({ size = 56, firstName, profilePictureUrl, disableCursorPointer }) => {
const appPrimaryColor = useSelector(primaryColor);
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}>
{avatarContent}
</Avatar>
</div>
);
return (
<div style={{ userSelect: "none" }}>
<Avatar
size={size}
style={avatarStyle}
src={avatarSrc}
icon={iconContent}
>
{avatarContent}
</Avatar>
</div>
);
};
export default MyUserAvatar;