i18n
parent
9be79b1481
commit
c40cba88d4
|
@ -25,6 +25,9 @@
|
||||||
"deep-chat-react": "^2.0.1",
|
"deep-chat-react": "^2.0.1",
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
|
@ -6043,6 +6046,15 @@
|
||||||
"node": ">= 4.0.0"
|
"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": {
|
"node_modules/autolinker": {
|
||||||
"version": "3.16.2",
|
"version": "3.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz",
|
||||||
|
@ -7209,6 +7221,15 @@
|
||||||
"node": ">=10"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
|
@ -10569,6 +10590,15 @@
|
||||||
"@babel/runtime": "^7.23.2"
|
"@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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
@ -13621,6 +13651,15 @@
|
||||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json-parse-even-better-errors": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
"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"
|
"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": {
|
"node_modules/mdn-data": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
|
||||||
|
@ -14176,6 +14239,12 @@
|
||||||
"mkdirp": "bin/cmd.js"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
@ -14261,6 +14330,48 @@
|
||||||
"tslib": "^2.0.3"
|
"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": {
|
"node_modules/node-forge": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||||
|
|
|
@ -20,6 +20,9 @@
|
||||||
"deep-chat-react": "^2.0.1",
|
"deep-chat-react": "^2.0.1",
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,131 +1,176 @@
|
||||||
import { Avatar, Dropdown, Flex } from 'antd';
|
import { Button, Dropdown, Flex } from "antd";
|
||||||
import { isSideMenuCollapsed, setIsSideMenuCollapsed } from '../SideMenu/sideMenuSlice';
|
import {
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
isSideMenuCollapsed,
|
||||||
import { EditOutlined, EyeOutlined, LeftOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined, UserOutlined } from '@ant-design/icons';
|
setIsSideMenuCollapsed,
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
} from "../SideMenu/sideMenuSlice";
|
||||||
import { darkMode, setDarkMode, setUserAuthenticated } from '../../reducers/appSlice';
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import styles from './styles.module.css';
|
import {
|
||||||
import { Constants } from 'core/utils/utils';
|
EditOutlined,
|
||||||
import webSocketService from 'core/services/websocketService';
|
EyeOutlined,
|
||||||
import { userProfilePictureUrl } from 'core/reducers/appSlice';
|
LeftOutlined,
|
||||||
import MyUserAvatar from 'shared/components/MyUserAvatar';
|
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 = {
|
type HeaderBarProps = {
|
||||||
theme?: 'light' | 'dark';
|
theme?: "light" | "dark";
|
||||||
onView?: () => void;
|
onView?: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
backTo?: string;
|
backTo?: string;
|
||||||
|
sticky?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HeaderBar(props: HeaderBarProps = { theme: 'light' }) {
|
export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
|
||||||
const dispatch = useDispatch();
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const isCollpased = useSelector(isSideMenuCollapsed);
|
const isCollpased = useSelector(isSideMenuCollapsed);
|
||||||
const isDarkMode = useSelector(darkMode);
|
const isDarkMode = useSelector(darkMode);
|
||||||
const profilePictureUrl = useSelector(userProfilePictureUrl);
|
const profilePictureUrl = useSelector(userProfilePictureUrl);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
style={{
|
style={{
|
||||||
paddingTop: 12,
|
paddingTop: 12,
|
||||||
paddingLeft: 12,
|
paddingLeft: 12,
|
||||||
paddingRight: 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}>
|
{isCollpased ? (
|
||||||
<div className={props.theme === 'light' ? styles.containerLight : styles.containerDark} style={{ borderRadius: 28, padding: 4 }}>
|
<div
|
||||||
{isCollpased ? (
|
className={styles.iconContainer}
|
||||||
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(false))}>
|
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}
|
|
||||||
>
|
>
|
||||||
{props.onView && (
|
<MenuUnfoldOutlined className={styles.icon} />
|
||||||
<div className={styles.iconContainer} onClick={props.onView}>
|
</div>
|
||||||
<EyeOutlined className={styles.icon} />
|
) : (
|
||||||
</div>
|
<div
|
||||||
)}
|
className={styles.iconContainer}
|
||||||
|
onClick={() => dispatch(setIsSideMenuCollapsed(true))}
|
||||||
|
>
|
||||||
|
<MenuFoldOutlined className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{props.onEdit && (
|
{props.backTo && (
|
||||||
<div className={styles.iconContainer} onClick={props.onEdit}>
|
<Button
|
||||||
<EditOutlined className={styles.icon} />
|
type="link"
|
||||||
</div>
|
style={{ color: isDarkMode ? "#fff" : "#1e1e1e" }}
|
||||||
)}
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
props.backTo
|
||||||
|
? props.backTo
|
||||||
|
: Constants.ROUTE_PATHS.LESSIONS.ROOT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
>
|
||||||
|
{t("headerBar.backButton")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{isDarkMode ? (
|
<Flex
|
||||||
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(false))}>
|
align="center"
|
||||||
<SunOutlined className={styles.icon} />
|
className={isDarkMode ? styles.containerDark : styles.containerLight}
|
||||||
</div>
|
style={{
|
||||||
) : (
|
borderRadius: 28,
|
||||||
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(true))}>
|
paddingLeft: 6,
|
||||||
<MoonOutlined className={styles.icon} />
|
paddingRight: 6,
|
||||||
</div>
|
paddingTop: 4,
|
||||||
)}
|
paddingBottom: 4,
|
||||||
<Dropdown
|
}}
|
||||||
overlayStyle={{ minWidth: 150 }}
|
gap={8}
|
||||||
trigger={['click']}
|
>
|
||||||
menu={{
|
{props.onView && (
|
||||||
items: [
|
<div className={styles.iconContainer} onClick={props.onView}>
|
||||||
{
|
<EyeOutlined className={styles.icon} />
|
||||||
key: '1',
|
</div>
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* 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
|
<Header
|
||||||
style={{
|
style={{
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.containerDark {
|
.containerDark {
|
||||||
background-color: rgba(0, 0, 0, 0.22);
|
background-color: rgba(39, 39, 39, 0.4);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,9 +46,11 @@ import webSocketService, {
|
||||||
import { WebSocketSendMessagesCmds } from "core/utils/webSocket";
|
import { WebSocketSendMessagesCmds } from "core/utils/webSocket";
|
||||||
import { useMessage } from "core/context/MessageContext";
|
import { useMessage } from "core/context/MessageContext";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function SideMenuContent() {
|
export function SideMenuContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selectedKeys, setSelectedKeys] = useState("/");
|
const [selectedKeys, setSelectedKeys] = useState("/");
|
||||||
const [openKeys, setOpenKeys] = useState([""]);
|
const [openKeys, setOpenKeys] = useState([""]);
|
||||||
|
|
||||||
|
@ -65,7 +67,7 @@ export function SideMenuContent() {
|
||||||
|
|
||||||
let overviewGroup: ItemType<MenuItemType> = {
|
let overviewGroup: ItemType<MenuItemType> = {
|
||||||
key: "overviewGroup",
|
key: "overviewGroup",
|
||||||
label: "OVERVIEW",
|
label: t("sideMenu.overview"),
|
||||||
type: "group",
|
type: "group",
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
|
@ -73,13 +75,13 @@ export function SideMenuContent() {
|
||||||
if (overviewGroup.children) {
|
if (overviewGroup.children) {
|
||||||
overviewGroup.children.push({
|
overviewGroup.children.push({
|
||||||
key: Constants.ROUTE_PATHS.BOARD,
|
key: Constants.ROUTE_PATHS.BOARD,
|
||||||
label: "Board",
|
label: t("sideMenu.board"),
|
||||||
icon: <FundProjectionScreenOutlined />,
|
icon: <FundProjectionScreenOutlined />,
|
||||||
});
|
});
|
||||||
|
|
||||||
overviewGroup.children.push({
|
overviewGroup.children.push({
|
||||||
key: Constants.ROUTE_PATHS.LESSIONS.ROOT,
|
key: Constants.ROUTE_PATHS.LESSIONS.ROOT,
|
||||||
label: "Lessons",
|
label: t("sideMenu.lessons"),
|
||||||
icon: <SnippetsOutlined />,
|
icon: <SnippetsOutlined />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -90,7 +92,7 @@ export function SideMenuContent() {
|
||||||
|
|
||||||
let organizationGroup: ItemType<MenuItemType> = {
|
let organizationGroup: ItemType<MenuItemType> = {
|
||||||
key: "organizationGroup",
|
key: "organizationGroup",
|
||||||
label: "ORGANIZATION",
|
label: t("sideMenu.organization"),
|
||||||
type: "group",
|
type: "group",
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
|
@ -98,19 +100,19 @@ export function SideMenuContent() {
|
||||||
if (organizationGroup.children) {
|
if (organizationGroup.children) {
|
||||||
organizationGroup.children.push({
|
organizationGroup.children.push({
|
||||||
key: Constants.ROUTE_PATHS.ORGANIZATION_TEAM,
|
key: Constants.ROUTE_PATHS.ORGANIZATION_TEAM,
|
||||||
label: "Team",
|
label: t("sideMenu.team"),
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
});
|
});
|
||||||
|
|
||||||
organizationGroup.children.push({
|
organizationGroup.children.push({
|
||||||
key: Constants.ROUTE_PATHS.ORGANIZATION_ROLES,
|
key: Constants.ROUTE_PATHS.ORGANIZATION_ROLES,
|
||||||
label: "Roles",
|
label: t("sideMenu.roles"),
|
||||||
icon: <ControlOutlined />,
|
icon: <ControlOutlined />,
|
||||||
});
|
});
|
||||||
|
|
||||||
organizationGroup.children.push({
|
organizationGroup.children.push({
|
||||||
key: Constants.ROUTE_PATHS.ORGANIZATION_SETTINGS,
|
key: Constants.ROUTE_PATHS.ORGANIZATION_SETTINGS,
|
||||||
label: "Settings",
|
label: t("sideMenu.settings"),
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -127,7 +129,7 @@ export function SideMenuContent() {
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
key: Constants.ROUTE_PATHS.WHATS_NEW,
|
key: Constants.ROUTE_PATHS.WHATS_NEW,
|
||||||
label: "What's New",
|
label: t("sideMenu.whatsNew"),
|
||||||
icon: <QuestionCircleOutlined />,
|
icon: <QuestionCircleOutlined />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -135,7 +137,7 @@ export function SideMenuContent() {
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
key: Constants.ROUTE_PATHS.SUGGEST_FEATURE,
|
key: Constants.ROUTE_PATHS.SUGGEST_FEATURE,
|
||||||
label: "Suggest a Feature",
|
label: t("sideMenu.suggestFeature"),
|
||||||
icon: <MessageOutlined />,
|
icon: <MessageOutlined />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -143,7 +145,7 @@ export function SideMenuContent() {
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
key: Constants.ROUTE_PATHS.CONTACT_SUPPORT,
|
key: Constants.ROUTE_PATHS.CONTACT_SUPPORT,
|
||||||
label: "Contact Support",
|
label: t("sideMenu.contactSupport"),
|
||||||
icon: <WalletOutlined />,
|
icon: <WalletOutlined />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -264,6 +266,7 @@ export function SideMenuContent() {
|
||||||
|
|
||||||
export function SideMenuEditorContent() {
|
export function SideMenuEditorContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isDragging, setIsDragging] = useState<String | null>(null);
|
const [isDragging, setIsDragging] = useState<String | null>(null);
|
||||||
const { lessonId } = useParams();
|
const { lessonId } = useParams();
|
||||||
const { success, error } = useMessage();
|
const { success, error } = useMessage();
|
||||||
|
@ -275,6 +278,10 @@ export function SideMenuEditorContent() {
|
||||||
|
|
||||||
const [reqUpdateLessonState] = useUpdateLessonStateMutation();
|
const [reqUpdateLessonState] = useUpdateLessonStateMutation();
|
||||||
|
|
||||||
|
const lessonsComponentsCategories = t("lessonsComponents.categories", {
|
||||||
|
returnObjects: true,
|
||||||
|
}) as { [key: string]: string };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// subscribe to the current page
|
// subscribe to the current page
|
||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
|
@ -319,7 +326,7 @@ export function SideMenuEditorContent() {
|
||||||
>
|
>
|
||||||
{componentsGroups.map((group, i) => (
|
{componentsGroups.map((group, i) => (
|
||||||
<div key={i} style={{ paddingTop: 16 }}>
|
<div key={i} style={{ paddingTop: 16 }}>
|
||||||
<span>{group.category}</span>
|
<span>{lessonsComponentsCategories[group.category]}</span>
|
||||||
|
|
||||||
<Flex
|
<Flex
|
||||||
gap={16}
|
gap={16}
|
||||||
|
@ -359,29 +366,35 @@ export function SideMenuEditorContent() {
|
||||||
|
|
||||||
<div style={{ padding: 12 }}>
|
<div style={{ padding: 12 }}>
|
||||||
<Form form={form} layout="vertical">
|
<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
|
<Select
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
onChange={async (value) => {
|
onChange={async (value) => {
|
||||||
console.log("state changed", value, lessonId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reqUpdateLessonState({
|
await reqUpdateLessonState({
|
||||||
lessonId: currentLnId,
|
lessonId: currentLnId,
|
||||||
newState: value,
|
newState: value,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
success("Lesson state updated successfully");
|
success(
|
||||||
|
t("sideMenuEditor.messageLessonStatusSuccessfullyUpdated")
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("error", err);
|
console.log("error", err);
|
||||||
error("Failed to update lesson state");
|
error(t("common.messageRequestFailed"));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select.Option value={LessonState.Published}>
|
<Select.Option value={LessonState.Published}>
|
||||||
Published
|
{t("sideMenuEditor.lessonStatusPublished")}
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value={LessonState.Draft}>
|
||||||
|
{t("sideMenuEditor.lessonStatusDraft")}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
<Select.Option value={LessonState.Draft}>Draft</Select.Option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -423,6 +436,7 @@ export function DraggableCreateComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateComponent({ component }: { component: Component }) {
|
function CreateComponent({ component }: { component: Component }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { error } = useMessage();
|
const { error } = useMessage();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
@ -431,6 +445,14 @@ function CreateComponent({ component }: { component: Component }) {
|
||||||
|
|
||||||
const [reqAddLessonContent] = useAddLessonContentMutation();
|
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 (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
vertical
|
vertical
|
||||||
|
@ -447,24 +469,32 @@ function CreateComponent({ component }: { component: Component }) {
|
||||||
}}
|
}}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
|
const defaultData =
|
||||||
|
component.type < 2
|
||||||
|
? lessonsCommonComponents[component.name] ||
|
||||||
|
lessonsMediaComponents[component.name] ||
|
||||||
|
component.defaultData ||
|
||||||
|
""
|
||||||
|
: component.defaultData || "";
|
||||||
|
|
||||||
const res = await reqAddLessonContent({
|
const res = await reqAddLessonContent({
|
||||||
lessonId: currentLnId,
|
lessonId: currentLnId,
|
||||||
type: component.type,
|
type: component.type,
|
||||||
data: component.defaultData || "",
|
data: defaultData,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
addLessonContent({
|
addLessonContent({
|
||||||
Id: res.Id,
|
Id: res.Id,
|
||||||
Type: component.type,
|
Type: component.type,
|
||||||
Data: component.defaultData || "",
|
Data: defaultData,
|
||||||
Page: 1,
|
Page: 1,
|
||||||
Position: 1,
|
Position: 1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("error", err);
|
console.log("error", err);
|
||||||
error("Failed to add content");
|
error(t("common.messageRequestFailed"));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -485,11 +515,13 @@ function CreateComponent({ component }: { component: Component }) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography.Text style={{ fontSize: 12 }}>
|
<Typography.Text style={{ fontSize: 12 }}>
|
||||||
{component.name}
|
{
|
||||||
|
{
|
||||||
|
...lessonsCommonComponents,
|
||||||
|
...lessonsMediaComponents,
|
||||||
|
}[component.name]
|
||||||
|
}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("insert component", component.type);
|
|
||||||
//dispatch(addLessonContent(component.type));
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ export interface Lesson {
|
||||||
Title: string;
|
Title: string;
|
||||||
ThumbnailUrl: string;
|
ThumbnailUrl: string;
|
||||||
CreatorUserId: string;
|
CreatorUserId: string;
|
||||||
|
QuestionsCount?: number;
|
||||||
CreatedAt: string;
|
CreatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +18,7 @@ export interface LessonSettings {
|
||||||
Title: string;
|
Title: string;
|
||||||
ThumbnailUrl: string;
|
ThumbnailUrl: string;
|
||||||
State?: LessonState;
|
State?: LessonState;
|
||||||
|
QuestionsCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// used on lesson page and on the lesson editor
|
// used on lesson page and on the lesson editor
|
||||||
|
|
|
@ -1,62 +1,112 @@
|
||||||
import React from "react";
|
import React from 'react';
|
||||||
import { FloatButton } from "antd";
|
import { FloatButton } from 'antd';
|
||||||
import { CommentOutlined } from "@ant-design/icons";
|
import { CommentOutlined } from '@ant-design/icons';
|
||||||
import { DeepChat } from "deep-chat-react";
|
import { DeepChat } from 'deep-chat-react';
|
||||||
import { getUserSessionFromLocalStorage } from "core/utils/utils";
|
import { getUserSessionFromLocalStorage } from 'core/utils/utils';
|
||||||
|
|
||||||
|
import { Marked, marked } from 'marked';
|
||||||
|
import { createDirectives, presetDirectiveConfigs } from 'marked-directive';
|
||||||
|
|
||||||
function AiChat() {
|
function AiChat() {
|
||||||
const [visible, setVisible] = React.useState(false);
|
const [visible, setVisible] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{visible ? (
|
{visible ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: 'fixed',
|
||||||
bottom: 100,
|
bottom: 100,
|
||||||
right: 10,
|
right: 10,
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
maxWidth: "95vw",
|
maxWidth: '95vw',
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
maxHeight: "calc(100vh - 165px)",
|
maxHeight: 'calc(100vh - 165px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeepChat
|
<DeepChat
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
boxShadow: "0 0 10px rgba(0,0,0,0.1)",
|
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
}}
|
}}
|
||||||
history={[{ text: "Stell mir Fragen :)", role: "ai" }]}
|
avatars={true}
|
||||||
connect={{
|
history={[{ text: 'Stell mir Fragen :)', role: 'ai' }]}
|
||||||
url: "/api/chat/v1/prompt/",
|
connect={{
|
||||||
method: "POST",
|
url: '/api/chat/v1/prompt/',
|
||||||
headers: {
|
method: 'POST',
|
||||||
"X-Authorization": getUserSessionFromLocalStorage() || "",
|
headers: {
|
||||||
},
|
'X-Authorization': getUserSessionFromLocalStorage() || '',
|
||||||
}}
|
},
|
||||||
onMessage={async (message) => {
|
}}
|
||||||
console.log("onMessagee", message);
|
htmlClassUtilities={{
|
||||||
}}
|
['aiButtonSource']: {
|
||||||
></DeepChat>
|
styles: {
|
||||||
</div>
|
default: {
|
||||||
) : null}
|
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
|
let text = response.text;
|
||||||
icon={<CommentOutlined />}
|
|
||||||
type="primary"
|
delete _response.text;
|
||||||
onClick={() => console.log("onClick")}
|
|
||||||
style={{ zIndex: 10000 }}
|
// Regular expression to match pattern %URL%x%URL%
|
||||||
onClickCapture={() => {
|
//const re = /%URL%(.*?)%URL%/g;
|
||||||
setVisible(!visible);
|
// 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;
|
export default AiChat;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -1,16 +1,22 @@
|
||||||
import { Descriptions, Typography } from "antd";
|
import { Descriptions, Typography } from "antd";
|
||||||
import MyMiddleCard from "shared/components/MyMiddleCard";
|
import MyMiddleCard from "shared/components/MyMiddleCard";
|
||||||
import MyBanner from "shared/components/MyBanner";
|
import MyBanner from "shared/components/MyBanner";
|
||||||
|
import HeaderBar from "core/components/Header";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function ContactSupport() {
|
export default function ContactSupport() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MyBanner title="Contact Support" />
|
<MyBanner
|
||||||
|
title={t("contactSupport.bannerTitle")}
|
||||||
|
headerBar={<HeaderBar />}
|
||||||
|
/>
|
||||||
|
|
||||||
<MyMiddleCard title="Support">
|
<MyMiddleCard title={t("contactSupport.middleCardTitle")}>
|
||||||
<Typography.Paragraph>
|
<Typography.Paragraph>
|
||||||
If you have any questions or need help, please contact us at the
|
{t("contactSupport.paragraph")}
|
||||||
following e-mail address:
|
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Descriptions>
|
<Descriptions>
|
||||||
<Descriptions.Item label="E-Mail">
|
<Descriptions.Item label="E-Mail">
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
setLessonPageCurrentLessonId,
|
setLessonPageCurrentLessonId,
|
||||||
} from "./lessonPageSlice";
|
} from "./lessonPageSlice";
|
||||||
import MyCenteredSpin from "shared/components/MyCenteredSpin";
|
import MyCenteredSpin from "shared/components/MyCenteredSpin";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const LessonContents: React.FC = () => {
|
const LessonContents: React.FC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
@ -65,6 +66,8 @@ const LessonContents: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LessonPage() {
|
export default function LessonPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
@ -74,6 +77,7 @@ export default function LessonPage() {
|
||||||
theme="light"
|
theme="light"
|
||||||
backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT}
|
backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT}
|
||||||
onEdit={() => navigate(`${location.pathname}/editor`)}
|
onEdit={() => navigate(`${location.pathname}/editor`)}
|
||||||
|
sticky
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MyMiddleCard
|
<MyMiddleCard
|
||||||
|
@ -86,8 +90,12 @@ export default function LessonPage() {
|
||||||
<LessonContents />
|
<LessonContents />
|
||||||
|
|
||||||
<Flex justify="right">
|
<Flex justify="right">
|
||||||
<Button type="primary" icon={<CheckOutlined />}>
|
<Button
|
||||||
Finish lesson
|
type="primary"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => navigate(Constants.ROUTE_PATHS.LESSIONS.ROOT)}
|
||||||
|
>
|
||||||
|
{t("lessonPage.finishLessonButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</MyMiddleCard>
|
</MyMiddleCard>
|
||||||
|
|
|
@ -1,76 +1,90 @@
|
||||||
import { closestCenter, closestCorners, DndContext, DragEndEvent, DragOverlay, MeasuringStrategy, rectIntersection, useDroppable } from '@dnd-kit/core';
|
import {
|
||||||
import { verticalListSortingStrategy, SortableContext, rectSwappingStrategy, rectSortingStrategy } from '@dnd-kit/sortable';
|
closestCorners,
|
||||||
import SortableEditorItem from './SortableEditorItem';
|
DndContext,
|
||||||
import { store } from 'core/store/store';
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
import { restrictToVerticalAxis, restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers';
|
useDroppable,
|
||||||
import { currentLessonId, onDragHandler } from './lessonPageEditorSlice';
|
} from "@dnd-kit/core";
|
||||||
import { LessonContent } from 'core/types/lesson';
|
import {
|
||||||
import { useUpdateLessonContentPositionMutation } from 'core/services/lessons';
|
verticalListSortingStrategy,
|
||||||
import { useSelector } from 'react-redux';
|
SortableContext,
|
||||||
import React from 'react';
|
} from "@dnd-kit/sortable";
|
||||||
import { Typography } from 'antd';
|
import SortableEditorItem from "./SortableEditorItem";
|
||||||
import { HolderOutlined } from '@ant-design/icons';
|
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 Droppable = ({ items }: { items: LessonContent[] }) => {
|
||||||
const droppableID = 'editorComponentArea';
|
const droppableID = "editorComponentArea";
|
||||||
const { setNodeRef } = useDroppable({ id: droppableID });
|
const { setNodeRef } = useDroppable({ id: droppableID });
|
||||||
const currentLnId = useSelector(currentLessonId);
|
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) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
console.log('drag end', event);
|
console.log("drag end", event);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
if (!event.over) return;
|
if (!event.over) return;
|
||||||
|
|
||||||
const activeId = event.active.id;
|
const activeId = event.active.id;
|
||||||
const overId = event.over.id;
|
const overId = event.over.id;
|
||||||
|
|
||||||
if (activeId === overId) return;
|
if (activeId === overId) return;
|
||||||
|
|
||||||
let oldIndex = itemIDs.findIndex((item) => item === activeId);
|
let oldIndex = itemIDs.findIndex((item) => item === activeId);
|
||||||
let newIndex = itemIDs.findIndex((item) => item === overId);
|
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 {
|
try {
|
||||||
reqUpdateLessonContentPosition({
|
reqUpdateLessonContentPosition({
|
||||||
lessonId: currentLnId,
|
lessonId: currentLnId,
|
||||||
contentId: activeId,
|
contentId: activeId,
|
||||||
newPosition: newIndex + 1,
|
newPosition: newIndex + 1,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
modifiers={[snapCenterToCursor]}
|
modifiers={[snapCenterToCursor]}
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={closestCorners}
|
||||||
onDragStart={() => {
|
onDragStart={() => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}}
|
}}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext id={droppableID} items={itemIDs} strategy={verticalListSortingStrategy}>
|
<SortableContext
|
||||||
<div ref={setNodeRef}>
|
id={droppableID}
|
||||||
{items.map((item) => (
|
items={itemIDs}
|
||||||
<SortableEditorItem key={`${droppableID}_${item.Id}`} item={item} />
|
strategy={verticalListSortingStrategy}
|
||||||
))}
|
>
|
||||||
</div>
|
<div ref={setNodeRef}>
|
||||||
</SortableContext>
|
{items.map((item) => (
|
||||||
<DragOverlay>{isDragging ? <HolderOutlined style={{ cursor: 'grabbing' }} /> : null}</DragOverlay>
|
<SortableEditorItem key={`${droppableID}_${item.Id}`} item={item} />
|
||||||
</DndContext>
|
))}
|
||||||
);
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay>
|
||||||
|
{isDragging ? <HolderOutlined style={{ cursor: "grabbing" }} /> : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
/*
|
/*
|
||||||
function handleDragEnd(event: DragEndEvent) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
|
|
@ -19,9 +19,10 @@ import "./styles.module.css";
|
||||||
import { Converter } from "../converter";
|
import { Converter } from "../converter";
|
||||||
import { useDeleteLessonContentMutation } from "core/services/lessons";
|
import { useDeleteLessonContentMutation } from "core/services/lessons";
|
||||||
|
|
||||||
|
/*
|
||||||
const animateLayoutChanges = (args: any) =>
|
const animateLayoutChanges = (args: any) =>
|
||||||
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true;
|
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true;
|
||||||
|
*/
|
||||||
const SortableEditorItem = (props: { item: LessonContent }) => {
|
const SortableEditorItem = (props: { item: LessonContent }) => {
|
||||||
const lnContent = props.item;
|
const lnContent = props.item;
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -52,6 +52,12 @@ const PreviewCard: React.FC = () => {
|
||||||
dispatch(setLessonThumbnailUrl(data.ThumbnailUrl));
|
dispatch(setLessonThumbnailUrl(data.ThumbnailUrl));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addWebSocketReconnectListener(refetch);
|
||||||
|
|
||||||
|
return () => removeWebSocketReconnectListener(refetch);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (error) return <MyErrorResult />;
|
if (error) return <MyErrorResult />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -150,6 +156,7 @@ export default function LessonPageEditor() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
sticky
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Flex justify="center" style={{ paddingTop: 24 }}>
|
<Flex justify="center" style={{ paddingTop: 24 }}>
|
||||||
|
|
|
@ -27,8 +27,11 @@ import {
|
||||||
} from "core/services/websocketService";
|
} from "core/services/websocketService";
|
||||||
import { useCachedUser } from "core/services/cachedUser";
|
import { useCachedUser } from "core/services/cachedUser";
|
||||||
import MyUserAvatar from "shared/components/MyUserAvatar";
|
import MyUserAvatar from "shared/components/MyUserAvatar";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const CreateQuestionForm: React.FC = () => {
|
const CreateQuestionForm: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { lessonId } = useParams();
|
const { lessonId } = useParams();
|
||||||
const [form] = useForm();
|
const [form] = useForm();
|
||||||
|
|
||||||
|
@ -42,11 +45,11 @@ const CreateQuestionForm: React.FC = () => {
|
||||||
message: form.getFieldValue("message"),
|
message: form.getFieldValue("message"),
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
success("Question created successfully");
|
success(t("lessonQuestions.messageQuestionSuccessfullyCreated"));
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
error("Failed to create question");
|
error(t("common.messageRequestFailed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -58,12 +61,14 @@ const CreateQuestionForm: React.FC = () => {
|
||||||
requiredMark={false}
|
requiredMark={false}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Ask a question"
|
label={t("lessonQuestions.askAQuestion")}
|
||||||
name="message"
|
name="message"
|
||||||
rules={[{ required: true, message: "Please write a question" }]}
|
rules={[
|
||||||
|
{ required: true, message: t("lessonQuestions.ruleMessageRequired") },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
placeholder={"Type something"}
|
placeholder={t("lessonQuestions.typeSomething")}
|
||||||
autoSize={{ minRows: 2 }}
|
autoSize={{ minRows: 2 }}
|
||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
/>
|
/>
|
||||||
|
@ -71,7 +76,7 @@ const CreateQuestionForm: React.FC = () => {
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" loading={isLoading}>
|
<Button type="primary" htmlType="submit" loading={isLoading}>
|
||||||
Submit
|
{t("lessonQuestions.submitButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -79,6 +84,8 @@ const CreateQuestionForm: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Questions() {
|
export default function Questions() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { lessonId } = useParams();
|
const { lessonId } = useParams();
|
||||||
|
|
||||||
|
@ -108,7 +115,9 @@ export default function Questions() {
|
||||||
return (
|
return (
|
||||||
<Flex justify="center">
|
<Flex justify="center">
|
||||||
<Flex style={{ width: 800, maxWidth: 800 * 0.9 }} vertical>
|
<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 />
|
<CreateQuestionForm />
|
||||||
|
|
||||||
|
@ -152,6 +161,7 @@ export function QuestionItem({
|
||||||
replies: LessonQuestion[];
|
replies: LessonQuestion[];
|
||||||
likedQuestions: string[];
|
likedQuestions: string[];
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showReplies, setShowReplies] = useState(1);
|
const [showReplies, setShowReplies] = useState(1);
|
||||||
const { success, error } = useMessage();
|
const { success, error } = useMessage();
|
||||||
|
|
||||||
|
@ -167,12 +177,12 @@ export function QuestionItem({
|
||||||
message: message,
|
message: message,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
success("Question created successfully");
|
success(t("lessonQuestions.messageReplySuccessfullyCreated"));
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
error("Failed to create question");
|
error(t("common.messageRequestFailed"));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -185,7 +195,7 @@ export function QuestionItem({
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
error("Failed to like");
|
error(t("lessonQuestions.messageRequestFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -197,7 +207,7 @@ export function QuestionItem({
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 [hasLiked, setHasLiked] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [replyFormVisible, setReplyFormVisible] = useState(false);
|
const [replyFormVisible, setReplyFormVisible] = useState(false);
|
||||||
const [replyMessage, setReplyMessage] = useState<null | string>(null);
|
const [replyMessage, setReplyMessage] = useState<null | string>(null);
|
||||||
const [isSendingReply, setIsSendingReply] = useState(false);
|
const [isSendingReply, setIsSendingReply] = useState(false);
|
||||||
|
@ -324,7 +336,9 @@ export function QuestionUIRaw({
|
||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{replyFormVisible ? "Hide" : "Reply"}
|
{replyFormVisible
|
||||||
|
? t("lessonQuestions.hide")
|
||||||
|
: t("lessonQuestions.reply")}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
{replyFormVisible ? (
|
{replyFormVisible ? (
|
||||||
|
@ -344,12 +358,17 @@ export function QuestionUIRaw({
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="reply"
|
name="reply"
|
||||||
rules={[{ required: true, message: "Please write a reply" }]}
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: t("lessonQuestions.ruleReplyRequired"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={replyMessage ? replyMessage : userAt}
|
value={replyMessage ? replyMessage : userAt}
|
||||||
placeholder="Write a reply"
|
placeholder={t("lessonQuestions.ruleReplyRequired")}
|
||||||
onChange={(e) => setReplyMessage(e.target.value)}
|
onChange={(e) => setReplyMessage(e.target.value)}
|
||||||
autoSize={{ minRows: 2 }}
|
autoSize={{ minRows: 2 }}
|
||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
|
@ -361,7 +380,7 @@ export function QuestionUIRaw({
|
||||||
loading={isSendingReply}
|
loading={isSendingReply}
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
>
|
>
|
||||||
Reply
|
{t("lessonQuestions.reply")}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -1,380 +1,410 @@
|
||||||
import { LessonContent } from 'core/types/lesson';
|
import { LessonContent } from "core/types/lesson";
|
||||||
import { getTypeByName } from './components';
|
import { getTypeByName } from "./components";
|
||||||
import { Button, Input, Typography, Flex } from 'antd';
|
import { Button, Input, Typography, Flex } from "antd";
|
||||||
import { useUpdateLessonContentMutation } from 'core/services/lessons';
|
import { useUpdateLessonContentMutation } from "core/services/lessons";
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from "react-redux";
|
||||||
import { currentLessonId } from './LessonPageEditor/lessonPageEditorSlice';
|
import { currentLessonId } from "./LessonPageEditor/lessonPageEditorSlice";
|
||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import MyUpload from 'shared/components/MyUpload';
|
import MyUpload from "shared/components/MyUpload";
|
||||||
import { Constants } from 'core/utils/utils';
|
import { Constants } from "core/utils/utils";
|
||||||
import { MediaPlayer, MediaProvider } from '@vidstack/react';
|
import { MediaPlayer, MediaProvider } from "@vidstack/react";
|
||||||
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
|
import {
|
||||||
import '@vidstack/react/player/styles/default/theme.css';
|
defaultLayoutIcons,
|
||||||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
DefaultVideoLayout,
|
||||||
import { darkMode } from 'core/reducers/appSlice';
|
} 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) => {
|
const extractVideoId = (url: string) => {
|
||||||
// regex to extract video id from youtube url
|
// regex to extract video id from youtube url
|
||||||
const regex = /(?:https?:\/\/)?(?:www\.)?youtube\.com\/(?:watch\?v=|embed\/|v\/|.+\?v=|.+\/|.+v=)?([^"&?\/\s]{11})/;
|
const regex =
|
||||||
const match = url.match(regex);
|
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/(?:watch\?v=|embed\/|v\/|.+\?v=|.+\/|.+v=)?([^"&?\/\s]{11})/;
|
||||||
return match ? match[1] : url;
|
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 }) {
|
export function Converter({
|
||||||
const lessonId = useSelector(currentLessonId);
|
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) {
|
return (
|
||||||
case getTypeByName('Header'):
|
<Typography.Title
|
||||||
if (mode === 'view') {
|
editable={{
|
||||||
return <div style={{ fontWeight: 'bold', fontSize: 24, wordBreak: 'break-all' }}>{lessonContent.Data}</div>;
|
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 (
|
debounceRef.current = setTimeout(() => {
|
||||||
<Typography.Title
|
try {
|
||||||
editable={{
|
reqUpdateLessonContent({
|
||||||
triggerType: 'text' as any,
|
lessonId: lessonId,
|
||||||
onChange: (event) => {
|
contentId: lessonContent.Id,
|
||||||
onEdit?.(event);
|
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 {
|
const GalleryUpload = () => {
|
||||||
reqUpdateLessonContent({
|
return (
|
||||||
lessonId: lessonId,
|
<MyUpload
|
||||||
contentId: lessonContent.Id,
|
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/image`}
|
||||||
data: event,
|
onChange={(info) => {
|
||||||
});
|
if (info.file.status === "done") {
|
||||||
} catch (err) {
|
onEdit?.(info.file.response.Data);
|
||||||
console.error(err);
|
}
|
||||||
}
|
}}
|
||||||
},
|
imgCropProps={{
|
||||||
}}
|
aspect: 5 / 4,
|
||||||
level={1}
|
children: <></>,
|
||||||
style={{
|
}}
|
||||||
margin: 0,
|
>
|
||||||
width: '100%',
|
<Button type="link">
|
||||||
}}
|
{t("lessonComponentsConverter.gallery")}
|
||||||
>
|
</Button>
|
||||||
{lessonContent.Data}
|
</MyUpload>
|
||||||
</Typography.Title>
|
);
|
||||||
);
|
};
|
||||||
case getTypeByName('Text'):
|
|
||||||
if (mode === 'view') {
|
|
||||||
const formattedText = lessonContent.Data.split('\n').map((line, index) => <li key={index}>{line || '\u00A0'}</li>);
|
|
||||||
|
|
||||||
return (
|
if (lessonContent.Data === "") {
|
||||||
<ul
|
return (
|
||||||
style={{
|
<div
|
||||||
fontSize: 16,
|
style={{
|
||||||
wordBreak: 'break-all',
|
position: "relative",
|
||||||
padding: 0,
|
height: 120,
|
||||||
margin: 0,
|
width: "100%",
|
||||||
listStyleType: 'none',
|
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
|
||||||
}}
|
borderRadius: 4,
|
||||||
>
|
margin: "12px 12px 12px 0",
|
||||||
{formattedText}
|
}}
|
||||||
</ul>
|
>
|
||||||
);
|
<div
|
||||||
}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{t("lessonComponentsConverter.chooseImageFrom")}</span>
|
||||||
|
|
||||||
return (
|
<GalleryUpload />
|
||||||
<Input.TextArea
|
</div>
|
||||||
autoSize
|
</div>
|
||||||
variant="borderless"
|
);
|
||||||
placeholder="Input text here"
|
}
|
||||||
style={{ width: '100%', padding: 0, paddingTop: 4 }}
|
|
||||||
value={lessonContent.Data}
|
|
||||||
onChange={(event) => {
|
|
||||||
console.log('edit');
|
|
||||||
|
|
||||||
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) {
|
{mode === "edititable" && (
|
||||||
clearTimeout(debounceRef.current);
|
<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(() => {
|
return (
|
||||||
try {
|
<Flex vertical style={{ width: "100%", paddingBottom: 12 }}>
|
||||||
reqUpdateLessonContent({
|
<iframe
|
||||||
lessonId: lessonId,
|
width="100%"
|
||||||
contentId: lessonContent.Id,
|
height={mode === "view" ? 422 : 390}
|
||||||
data: event.target.value,
|
style={{ border: 0 }}
|
||||||
});
|
src={`https://www.youtube.com/embed/${videoId}`}
|
||||||
} catch (err) {
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
console.error(err);
|
></iframe>
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case getTypeByName('Image'):
|
|
||||||
console.log('image', lessonContent.Data);
|
|
||||||
|
|
||||||
if (mode === 'view' && lessonContent.Data === '') {
|
{mode === "edititable" && (
|
||||||
return (
|
<>
|
||||||
<div
|
<Typography.Text>
|
||||||
style={{
|
{t("lessonComponentsConverter.videoId")}
|
||||||
position: 'relative',
|
</Typography.Text>
|
||||||
height: 120,
|
<Input
|
||||||
width: '100%',
|
placeholder="https://www.youtube.com/watch?v=Robzc9p1l50 or Robzc9p1l50"
|
||||||
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
|
value={lessonContent.Data}
|
||||||
marginTop: 4,
|
onChange={(event) => {
|
||||||
borderRadius: 4,
|
onEdit?.(event.target.value);
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No image provided
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const GalleryUpload = () => {
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
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 (lessonContent.Data === '') {
|
if (event.target.value === "") return;
|
||||||
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>
|
|
||||||
|
|
||||||
<GalleryUpload />
|
debounceRef.current = setTimeout(() => {
|
||||||
</div>
|
try {
|
||||||
</div>
|
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 (
|
const VideoUpload = () => {
|
||||||
<Flex vertical style={{ width: '100%' }}>
|
return (
|
||||||
<img src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`} alt="img" style={{ width: '100%' }} />
|
<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' && (
|
if (lessonContent.Data === "") {
|
||||||
<Flex
|
return (
|
||||||
style={{
|
<div
|
||||||
width: '100%',
|
style={{
|
||||||
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
|
position: "relative",
|
||||||
marginTop: 4,
|
height: 120,
|
||||||
borderRadius: 4,
|
width: "100%",
|
||||||
}}
|
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
|
||||||
justify="center"
|
borderRadius: 4,
|
||||||
>
|
margin: "12px 12px 12px 0",
|
||||||
<div>
|
}}
|
||||||
<span>Choose another image from</span>
|
>
|
||||||
<GalleryUpload />
|
<div
|
||||||
</div>
|
style={{
|
||||||
</Flex>
|
display: "flex",
|
||||||
)}
|
flexDirection: "column",
|
||||||
</Flex>
|
alignItems: "center",
|
||||||
);
|
position: "absolute",
|
||||||
case getTypeByName('YouTube'):
|
top: "50%",
|
||||||
const videoId = extractVideoId(lessonContent.Data);
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{t("lessonComponentsConverter.chooseVideoFrom")}</span>
|
||||||
|
|
||||||
console.log('videoId', videoId);
|
<VideoUpload />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical style={{ width: '100%', paddingBottom: 12 }}>
|
<Flex vertical style={{ width: "100%" }}>
|
||||||
<iframe
|
<MediaPlayer
|
||||||
width="100%"
|
load="idle"
|
||||||
height={mode === 'view' ? 422 : 390}
|
src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}
|
||||||
style={{ border: 0 }}
|
>
|
||||||
src={`https://www.youtube.com/embed/${videoId}`}
|
<MediaProvider />
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||||
></iframe>
|
</MediaPlayer>
|
||||||
|
|
||||||
{mode === 'edititable' && (
|
{mode === "edititable" && (
|
||||||
<>
|
<Flex
|
||||||
<Typography.Text>Video ID</Typography.Text>
|
style={{
|
||||||
<Input
|
width: "100%",
|
||||||
placeholder="https://www.youtube.com/watch?v=Robzc9p1l50 or Robzc9p1l50"
|
backgroundColor: isDarkMode ? "#222" : "#EBEBEB",
|
||||||
value={lessonContent.Data}
|
margin: "4px 0",
|
||||||
onChange={(event) => {
|
borderRadius: 4,
|
||||||
console.warn('edit', event.target.value, videoId);
|
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) {
|
default:
|
||||||
clearTimeout(debounceRef.current);
|
return <div>{t("lessonComponentsConverter.unkownType")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,15 +16,23 @@ import LessonPreviewCard from "shared/components/MyLessonPreviewCard";
|
||||||
import { useMessage } from "core/context/MessageContext";
|
import { useMessage } from "core/context/MessageContext";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { lessons, setLessons } from "./lessonsSlice";
|
import {
|
||||||
|
lessons,
|
||||||
|
searchFilter,
|
||||||
|
setLessons,
|
||||||
|
setSearchFilter,
|
||||||
|
} from "./lessonsSlice";
|
||||||
import {
|
import {
|
||||||
addWebSocketReconnectListener,
|
addWebSocketReconnectListener,
|
||||||
removeWebSocketReconnectListener,
|
removeWebSocketReconnectListener,
|
||||||
} from "core/services/websocketService";
|
} from "core/services/websocketService";
|
||||||
import MyCenteredSpin from "shared/components/MyCenteredSpin";
|
import MyCenteredSpin from "shared/components/MyCenteredSpin";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const CreateLessonButton: React.FC = () => {
|
const CreateLessonButton: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [createLesson, { isLoading }] = useCreateLessonMutation();
|
const [createLesson, { isLoading }] = useCreateLessonMutation();
|
||||||
const { success, error } = useMessage();
|
const { success, error } = useMessage();
|
||||||
|
|
||||||
|
@ -33,7 +41,7 @@ const CreateLessonButton: React.FC = () => {
|
||||||
const res = await createLesson({}).unwrap();
|
const res = await createLesson({}).unwrap();
|
||||||
|
|
||||||
if (res && res.Id) {
|
if (res && res.Id) {
|
||||||
success("Lesson created successfully ");
|
success(t("lessons.messageLessonSuccessfullyCreated"));
|
||||||
|
|
||||||
navigate(
|
navigate(
|
||||||
Constants.ROUTE_PATHS.LESSIONS.PAGE_EDITOR.replace(
|
Constants.ROUTE_PATHS.LESSIONS.PAGE_EDITOR.replace(
|
||||||
|
@ -44,7 +52,7 @@ const CreateLessonButton: React.FC = () => {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
error("Failed to create lesson");
|
error(t("common.messageRequestFailed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -54,14 +62,16 @@ const CreateLessonButton: React.FC = () => {
|
||||||
onClick={handleCreateLesson}
|
onClick={handleCreateLesson}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
>
|
>
|
||||||
Create
|
{t("common.create")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LessonList: React.FC = () => {
|
const LessonList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const dataLessons = useSelector(lessons);
|
const dataLessons = useSelector(lessons);
|
||||||
|
const dataSearchFilter = useSelector(searchFilter);
|
||||||
|
|
||||||
const { data, error, isLoading, refetch } = useGetLessonsQuery(undefined, {
|
const { data, error, isLoading, refetch } = useGetLessonsQuery(undefined, {
|
||||||
refetchOnMountOrArgChange: true,
|
refetchOnMountOrArgChange: true,
|
||||||
|
@ -84,8 +94,12 @@ const LessonList: React.FC = () => {
|
||||||
|
|
||||||
if (!dataLessons || dataLessons.length === 0) return <MyEmpty />;
|
if (!dataLessons || dataLessons.length === 0) return <MyEmpty />;
|
||||||
|
|
||||||
const publishedItems = dataLessons.filter((item) => item.State === 1);
|
const filteredItems = dataLessons.filter((item) =>
|
||||||
const unpublishedItems = dataLessons.filter((item) => item.State === 2);
|
item.Title.toLowerCase().includes(dataSearchFilter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const publishedItems = filteredItems.filter((item) => item.State === 1);
|
||||||
|
const unpublishedItems = filteredItems.filter((item) => item.State === 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -100,6 +114,7 @@ const LessonList: React.FC = () => {
|
||||||
lessonSettings={{
|
lessonSettings={{
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
ThumbnailUrl: item.ThumbnailUrl,
|
ThumbnailUrl: item.ThumbnailUrl,
|
||||||
|
QuestionsCount: item.QuestionsCount,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -109,7 +124,7 @@ const LessonList: React.FC = () => {
|
||||||
{unpublishedItems.length > 0 && (
|
{unpublishedItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider orientation="left" style={{ marginBottom: 0 }}>
|
<Divider orientation="left" style={{ marginBottom: 0 }}>
|
||||||
Unpublished
|
{t("lessons.lessonStatusDrafts")}
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
{unpublishedItems.map((item, index) => (
|
{unpublishedItems.map((item, index) => (
|
||||||
|
@ -121,6 +136,7 @@ const LessonList: React.FC = () => {
|
||||||
lessonSettings={{
|
lessonSettings={{
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
ThumbnailUrl: item.ThumbnailUrl,
|
ThumbnailUrl: item.ThumbnailUrl,
|
||||||
|
QuestionsCount: item.QuestionsCount,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -131,20 +147,29 @@ const LessonList: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Lessons() {
|
export default function Lessons() {
|
||||||
const onSearch: SearchProps["onSearch"] = (value, _e, info) =>
|
const { t } = useTranslation();
|
||||||
console.log(info?.source, value);
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dispatch(setSearchFilter(""));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MyBanner title="Lessons" headerBar={<HeaderBar />} />
|
<MyBanner title={t("lessons.bannerTitle")} headerBar={<HeaderBar />} />
|
||||||
|
|
||||||
<MyContainer>
|
<MyContainer>
|
||||||
<Flex justify="right" gap={16} style={{ paddingBottom: 16 }}>
|
<Flex justify="right" gap={16} style={{ paddingBottom: 16 }}>
|
||||||
<CreateLessonButton />
|
<CreateLessonButton />
|
||||||
|
|
||||||
<Search
|
<Search
|
||||||
placeholder="Search..."
|
placeholder={t("lessons.searchPlaceholder")}
|
||||||
onSearch={onSearch}
|
onChange={(e) => {
|
||||||
|
dispatch(setSearchFilter(e.target.value));
|
||||||
|
}}
|
||||||
style={{ width: 300 }}
|
style={{ width: 300 }}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -10,6 +10,7 @@ export const lessonsSlice = createSlice({
|
||||||
name: "lessons",
|
name: "lessons",
|
||||||
initialState: {
|
initialState: {
|
||||||
lessons: [] as Lesson[],
|
lessons: [] as Lesson[],
|
||||||
|
searchFilter: "",
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
setLessons: (state, action) => {
|
setLessons: (state, action) => {
|
||||||
|
@ -45,9 +46,13 @@ export const lessonsSlice = createSlice({
|
||||||
lesson.State = action.payload.State;
|
lesson.State = action.payload.State;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setSearchFilter: (state, action) => {
|
||||||
|
state.searchFilter = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
selectors: {
|
selectors: {
|
||||||
lessons: (state) => state.lessons,
|
lessons: (state) => state.lessons,
|
||||||
|
searchFilter: (state) => state.searchFilter,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -57,6 +62,7 @@ export const {
|
||||||
updateLessonPreviewTitle,
|
updateLessonPreviewTitle,
|
||||||
updateLessonPreviewThumbnail,
|
updateLessonPreviewThumbnail,
|
||||||
updateLessonState,
|
updateLessonState,
|
||||||
|
setSearchFilter,
|
||||||
} = lessonsSlice.actions;
|
} = lessonsSlice.actions;
|
||||||
|
|
||||||
export const { lessons } = lessonsSlice.selectors;
|
export const { lessons, searchFilter } = lessonsSlice.selectors;
|
||||||
|
|
|
@ -12,8 +12,12 @@ import {
|
||||||
addWebSocketReconnectListener,
|
addWebSocketReconnectListener,
|
||||||
removeWebSocketReconnectListener,
|
removeWebSocketReconnectListener,
|
||||||
} from "core/services/websocketService";
|
} from "core/services/websocketService";
|
||||||
|
import MyUserAvatar from "shared/components/MyUserAvatar";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Roles() {
|
export default function Roles() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, error, isLoading, refetch } = useGetRolesQuery(undefined, {
|
const { data, error, isLoading, refetch } = useGetRolesQuery(undefined, {
|
||||||
refetchOnMountOrArgChange: true,
|
refetchOnMountOrArgChange: true,
|
||||||
});
|
});
|
||||||
|
@ -26,7 +30,11 @@ export default function Roles() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MyBanner title="Roles" subtitle="MANAGE" headerBar={<HeaderBar />} />
|
<MyBanner
|
||||||
|
title={t("roles.bannerTitle")}
|
||||||
|
subtitle={t("common.bannerSubtitle")}
|
||||||
|
headerBar={<HeaderBar />}
|
||||||
|
/>
|
||||||
|
|
||||||
<MyContainer
|
<MyContainer
|
||||||
style={{
|
style={{
|
||||||
|
@ -61,6 +69,7 @@ interface Permission {
|
||||||
}
|
}
|
||||||
|
|
||||||
// test data
|
// test data
|
||||||
|
|
||||||
const tmpI18nObj = [
|
const tmpI18nObj = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -91,17 +100,28 @@ const tmpI18nObj = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/*
|
||||||
export const tmpRoleNames = {
|
export const tmpRoleNames = {
|
||||||
"d0f0fa0d-3f3b-438b-a76f-7febeb8aab57": "Admin",
|
"d0f0fa0d-3f3b-438b-a76f-7febeb8aab57": "Admin",
|
||||||
"b7359e12-359e-423b-b39c-f0d4069adebc": "Moderator",
|
"b7359e12-359e-423b-b39c-f0d4069adebc": "Moderator",
|
||||||
"a1f084ad-d501-4015-b326-4c5c46fd1c5e": "User",
|
"a1f084ad-d501-4015-b326-4c5c46fd1c5e": "User",
|
||||||
} as any;
|
} as any; */
|
||||||
|
|
||||||
function RoleComponent({ role }: { role: Role }) {
|
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"
|
(permission) => permission.category === "Team"
|
||||||
);
|
);
|
||||||
const rolePermissions = tmpI18nObj.filter(
|
const rolePermissions = tmpI18nRoles.filter(
|
||||||
(permission) => permission.category === "Roles"
|
(permission) => permission.category === "Roles"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -130,7 +150,7 @@ function RoleComponent({ role }: { role: Role }) {
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: "1",
|
key: "1",
|
||||||
label: "Team",
|
label: t("roles.collapseTitleTeam"),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
{teamPermissions.map((permission, index) => (
|
{teamPermissions.map((permission, index) => (
|
||||||
|
@ -141,7 +161,7 @@ function RoleComponent({ role }: { role: Role }) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "2",
|
key: "2",
|
||||||
label: "Roles",
|
label: t("roles.collapseTitleRoles"),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
{rolePermissions.map((permission, index) => (
|
{rolePermissions.map((permission, index) => (
|
||||||
|
@ -168,9 +188,13 @@ function RoleComponent({ role }: { role: Role }) {
|
||||||
title={`${user.FirstName} ${user.LastName}`}
|
title={`${user.FirstName} ${user.LastName}`}
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Avatar style={{ backgroundColor: "#f56a00" }}>
|
<div>
|
||||||
{user.FirstName[0]}
|
<MyUserAvatar
|
||||||
</Avatar>
|
profilePictureUrl={user.ProfilePictureUrl}
|
||||||
|
firstName={user.FirstName}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
addWebSocketReconnectListener,
|
addWebSocketReconnectListener,
|
||||||
removeWebSocketReconnectListener,
|
removeWebSocketReconnectListener,
|
||||||
} from "core/services/websocketService";
|
} from "core/services/websocketService";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type GeneralFieldType = {
|
type GeneralFieldType = {
|
||||||
primaryColor: string | AggregationColor;
|
primaryColor: string | AggregationColor;
|
||||||
|
@ -37,6 +38,8 @@ type GeneralFieldType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, error, isLoading, refetch } = useGetOrganizationSettingsQuery(
|
const { data, error, isLoading, refetch } = useGetOrganizationSettingsQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
|
@ -54,7 +57,11 @@ export default function Settings() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MyBanner title="Settings" subtitle="MANAGE" headerBar={<HeaderBar />} />
|
<MyBanner
|
||||||
|
title={t("organizationSettings.bannerTitle")}
|
||||||
|
subtitle={t("common.bannerSubtitle")}
|
||||||
|
headerBar={<HeaderBar />}
|
||||||
|
/>
|
||||||
|
|
||||||
<GeneralCard data={data} isLoading={isLoading} />
|
<GeneralCard data={data} isLoading={isLoading} />
|
||||||
|
|
||||||
|
@ -69,6 +76,7 @@ function GeneralCard({
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
|
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [form] = useForm<GeneralFieldType>();
|
const [form] = useForm<GeneralFieldType>();
|
||||||
const { success, error: errorMessage } = useMessage();
|
const { success, error: errorMessage } = useMessage();
|
||||||
|
|
||||||
|
@ -93,10 +101,12 @@ function GeneralCard({
|
||||||
|
|
||||||
currentPrimaryColor.current = hexColor;
|
currentPrimaryColor.current = hexColor;
|
||||||
|
|
||||||
success("Settings updated successfully!");
|
success(
|
||||||
|
t("organizationSettings.generalCard.messageSettingsSuccessfullyUpdated")
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
errorMessage("Failed to update settings!");
|
errorMessage(t("common.messageRequestFailed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -129,7 +139,7 @@ function GeneralCard({
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
title="General"
|
title={t("organizationSettings.generalCard.title")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
|
@ -145,7 +155,7 @@ function GeneralCard({
|
||||||
<Flex wrap gap={12}>
|
<Flex wrap gap={12}>
|
||||||
<Form.Item<GeneralFieldType>
|
<Form.Item<GeneralFieldType>
|
||||||
name="primaryColor"
|
name="primaryColor"
|
||||||
label="Primary color"
|
label={t("organizationSettings.generalCard.primaryColor")}
|
||||||
>
|
>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
size="small"
|
size="small"
|
||||||
|
@ -162,7 +172,10 @@ function GeneralCard({
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item<GeneralFieldType> name="companyName" label="Company name">
|
<Form.Item<GeneralFieldType>
|
||||||
|
name="companyName"
|
||||||
|
label={t("organizationSettings.generalCard.companyName")}
|
||||||
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@ -174,6 +187,7 @@ function GeneralCard({
|
||||||
function MediaCard({
|
function MediaCard({
|
||||||
isLoading,
|
isLoading,
|
||||||
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
|
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { success } = useMessage();
|
const { success } = useMessage();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
@ -182,15 +196,22 @@ function MediaCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<MyMiddleCard title="Media" loading={isLoading}>
|
<MyMiddleCard
|
||||||
<Form.Item label="Logo">
|
title={t("organizationSettings.mediaCard.title")}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<Form.Item label={t("organizationSettings.mediaCard.logo")}>
|
||||||
<MyUpload
|
<MyUpload
|
||||||
action="/organization/file/logo"
|
action="/organization/file/logo"
|
||||||
onChange={(info) => {
|
onChange={(info) => {
|
||||||
if (info.file.status === "done" && info.file.response.Data) {
|
if (info.file.status === "done" && info.file.response.Data) {
|
||||||
dispatch(setLogoUrl(info.file.response.Data));
|
dispatch(setLogoUrl(info.file.response.Data));
|
||||||
|
|
||||||
success("Logo updated successfully!");
|
success(
|
||||||
|
t(
|
||||||
|
"organizationSettings.mediaCard.messageLogoSuccessfullyUpdated"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
imgCropProps={{
|
imgCropProps={{
|
||||||
|
@ -202,7 +223,7 @@ function MediaCard({
|
||||||
src={`${Constants.STATIC_CONTENT_ADDRESS}/${
|
src={`${Constants.STATIC_CONTENT_ADDRESS}/${
|
||||||
appLogoUrl || Constants.DEMO_LOGO_URL
|
appLogoUrl || Constants.DEMO_LOGO_URL
|
||||||
}`}
|
}`}
|
||||||
alt="Company Logo"
|
alt={t("organizationSettings.mediaCard.logo")}
|
||||||
style={{
|
style={{
|
||||||
width: 128,
|
width: 128,
|
||||||
maxHeight: 128,
|
maxHeight: 128,
|
||||||
|
@ -214,14 +235,18 @@ function MediaCard({
|
||||||
</MyUpload>
|
</MyUpload>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="Banner">
|
<Form.Item label={t("organizationSettings.mediaCard.banner")}>
|
||||||
<MyUpload
|
<MyUpload
|
||||||
action="/organization/file/banner"
|
action="/organization/file/banner"
|
||||||
onChange={(info) => {
|
onChange={(info) => {
|
||||||
if (info.file.status === "done" && info.file.response.Data) {
|
if (info.file.status === "done" && info.file.response.Data) {
|
||||||
dispatch(setBannerUrl(info.file.response.Data));
|
dispatch(setBannerUrl(info.file.response.Data));
|
||||||
|
|
||||||
success("Banner updated successfully!");
|
success(
|
||||||
|
t(
|
||||||
|
"organizationSettings.mediaCard.messageBannerSuccessfullyUpdated"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
imgCropProps={{
|
imgCropProps={{
|
||||||
|
@ -233,7 +258,7 @@ function MediaCard({
|
||||||
src={`${Constants.STATIC_CONTENT_ADDRESS}/${
|
src={`${Constants.STATIC_CONTENT_ADDRESS}/${
|
||||||
appBannerUrl || Constants.DEMO_BANNER_URL
|
appBannerUrl || Constants.DEMO_BANNER_URL
|
||||||
}`}
|
}`}
|
||||||
alt="Banner"
|
alt={t("organizationSettings.mediaCard.banner")}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 228,
|
height: 228,
|
||||||
|
@ -256,6 +281,7 @@ function SubdomainCard({
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
|
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [form] = useForm();
|
const [form] = useForm();
|
||||||
const { success, info } = useMessage();
|
const { success, info } = useMessage();
|
||||||
|
|
||||||
|
@ -283,12 +309,16 @@ function SubdomainCard({
|
||||||
const { data } = await reqIsSubdomainAvailable(value);
|
const { data } = await reqIsSubdomainAvailable(value);
|
||||||
|
|
||||||
if (!data.Available) {
|
if (!data.Available) {
|
||||||
return Promise.reject("This subdomain is already taken!");
|
return Promise.reject(
|
||||||
|
t("organizationSettings.subdomainCard.subdomainAlreadyTaken")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject("This subdomain is already taken!");
|
return Promise.reject(
|
||||||
|
t("organizationSettings.subdomainCard.subdomainAlreadyTaken")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -306,17 +336,27 @@ function SubdomainCard({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title="Change Subdomain"
|
title={t(
|
||||||
|
"organizationSettings.subdomainCard.modalChangeSubdomain.title"
|
||||||
|
)}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
centered
|
centered
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
okText="Change"
|
okText={t("common.change")}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
try {
|
try {
|
||||||
await reqUpdateSubdomain(form.getFieldValue("subdomain"));
|
await reqUpdateSubdomain(form.getFieldValue("subdomain"));
|
||||||
|
|
||||||
success("Subdomain updated successfully!");
|
success(
|
||||||
info("You will be redirected to the new subdomain!");
|
t(
|
||||||
|
"organizationSettings.subdomainCard.modalChangeSubdomain.messageSubdomainSuccessfullyUpdated"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
info(
|
||||||
|
t(
|
||||||
|
"organizationSettings.subdomainCard.modalChangeSubdomain.messageRedirect"
|
||||||
|
)
|
||||||
|
);
|
||||||
/*
|
/*
|
||||||
window.location.href = `https://${form.getFieldValue(
|
window.location.href = `https://${form.getFieldValue(
|
||||||
"subdomain"
|
"subdomain"
|
||||||
|
@ -327,16 +367,13 @@ function SubdomainCard({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Changing your subdomain will make your organization available at the
|
{t("organizationSettings.subdomainCard.modalChangeSubdomain.message")}
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Form form={form} layout="vertical" requiredMark={false}>
|
<Form form={form} layout="vertical" requiredMark={false}>
|
||||||
<MyMiddleCard
|
<MyMiddleCard
|
||||||
title="Subdomain"
|
title={t("organizationSettings.subdomainCard.middleCardTitle")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
|
@ -348,9 +385,7 @@ function SubdomainCard({
|
||||||
form
|
form
|
||||||
.validateFields()
|
.validateFields()
|
||||||
.then(() => setIsModalOpen(true))
|
.then(() => setIsModalOpen(true))
|
||||||
.catch(() => {
|
.catch(() => {});
|
||||||
console.error("Validation failed!");
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -358,25 +393,37 @@ function SubdomainCard({
|
||||||
<Flex>
|
<Flex>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="subdomain"
|
name="subdomain"
|
||||||
label="Subdomain"
|
label={t("organizationSettings.subdomainCard.subdomain")}
|
||||||
hasFeedback
|
hasFeedback
|
||||||
validateDebounce={300}
|
validateDebounce={300}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: "Please input your subdomain!",
|
message: t(
|
||||||
|
"organizationSettings.subdomainCard.rules.required"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: subdomainPattern,
|
pattern: subdomainPattern,
|
||||||
message: "Please enter a valid subdomain!",
|
message: t("organizationSettings.subdomainCard.rules.valid"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
min: Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH,
|
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,
|
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,
|
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>
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
</MyMiddleCard>
|
</MyMiddleCard>
|
||||||
|
|
|
@ -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() {
|
export default function SuggestFeature() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>SuggestFeature</h1>
|
<MyBanner
|
||||||
|
title={t("suggestFeature.bannerTitle")}
|
||||||
|
headerBar={<HeaderBar />}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import HeaderBar from "core/components/Header";
|
||||||
import { useMessage } from "core/context/MessageContext";
|
import { useMessage } from "core/context/MessageContext";
|
||||||
import { useCreateTeamMemberMutation } from "core/services/organization";
|
import { useCreateTeamMemberMutation } from "core/services/organization";
|
||||||
import { Constants, EncodeStringToBase64 } from "core/utils/utils";
|
import { Constants, EncodeStringToBase64 } from "core/utils/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import MyMiddleCard from "shared/components/MyMiddleCard";
|
import MyMiddleCard from "shared/components/MyMiddleCard";
|
||||||
|
|
||||||
|
@ -17,11 +18,17 @@ type FieldType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TeamCreateUser() {
|
export default function TeamCreateUser() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { success } = useMessage();
|
const { success } = useMessage();
|
||||||
|
|
||||||
const [reqCreateTeamMember, { isLoading }] = useCreateTeamMemberMutation();
|
const [reqCreateTeamMember, { isLoading }] = useCreateTeamMemberMutation();
|
||||||
|
|
||||||
|
const tmpRoleNames = t("roles.roleNames", {
|
||||||
|
returnObjects: true,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
|
@ -29,7 +36,7 @@ export default function TeamCreateUser() {
|
||||||
backTo={Constants.ROUTE_PATHS.ORGANIZATION_TEAM}
|
backTo={Constants.ROUTE_PATHS.ORGANIZATION_TEAM}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MyMiddleCard title="Create User">
|
<MyMiddleCard title={t("teamCreateUser.middleCardTitle")}>
|
||||||
<Form
|
<Form
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
requiredMark={false}
|
requiredMark={false}
|
||||||
|
@ -48,7 +55,7 @@ export default function TeamCreateUser() {
|
||||||
password: EncodeStringToBase64(values.password),
|
password: EncodeStringToBase64(values.password),
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
success("User created successfully!");
|
success(t("teamCreateUser.messageUserSuccessfullyCreated"));
|
||||||
|
|
||||||
navigate(Constants.ROUTE_PATHS.ORGANIZATION_TEAM);
|
navigate(Constants.ROUTE_PATHS.ORGANIZATION_TEAM);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -57,74 +64,90 @@ export default function TeamCreateUser() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item<FieldType>
|
<Form.Item<FieldType>
|
||||||
label="First Name"
|
label={t("common.firstName")}
|
||||||
name="firstName"
|
name="firstName"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: "Please input first name!" },
|
{ required: true, message: t("common.firstNameRules.required") },
|
||||||
{
|
{
|
||||||
min: Constants.GLOBALS.MIN_FIRST_NAME_LENGTH,
|
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,
|
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
|
<Input
|
||||||
placeholder="First Name"
|
placeholder={t("common.firstName")}
|
||||||
maxLength={Constants.GLOBALS.MAX_FIRST_NAME_LENGTH}
|
maxLength={Constants.GLOBALS.MAX_FIRST_NAME_LENGTH}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item<FieldType>
|
<Form.Item<FieldType>
|
||||||
label="Last Name"
|
label={t("common.lastName")}
|
||||||
name="lastName"
|
name="lastName"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: "Please input last name!" },
|
{ required: true, message: t("common.lastNameRules.required") },
|
||||||
{
|
{
|
||||||
min: Constants.GLOBALS.MIN_LAST_NAME_LENGTH,
|
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,
|
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
|
<Input
|
||||||
placeholder="Last Name"
|
placeholder={t("common.lastName")}
|
||||||
maxLength={Constants.GLOBALS.MAX_LAST_NAME_LENGTH}
|
maxLength={Constants.GLOBALS.MAX_LAST_NAME_LENGTH}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item<FieldType>
|
<Form.Item<FieldType>
|
||||||
label="Email"
|
label={t("common.email")}
|
||||||
name="email"
|
name="email"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: "Please input email!", type: "email" },
|
{
|
||||||
|
required: true,
|
||||||
|
message: t("common.emailRules.valid"),
|
||||||
|
type: "email",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="Email" />
|
<Input placeholder="Email" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item<FieldType>
|
<Form.Item<FieldType>
|
||||||
label="Password"
|
label={t("common.password")}
|
||||||
name="password"
|
name="password"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: "Please input password!" },
|
{ required: true, message: t("common.passwordRules.required") },
|
||||||
{
|
{
|
||||||
min: Constants.GLOBALS.MIN_PASSWORD_LENGTH,
|
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,
|
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
|
<Input.Password
|
||||||
placeholder="Password"
|
placeholder={t("common.password")}
|
||||||
maxLength={Constants.GLOBALS.MAX_PASSWORD_LENGTH}
|
maxLength={Constants.GLOBALS.MAX_PASSWORD_LENGTH}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
@ -132,13 +155,13 @@ export default function TeamCreateUser() {
|
||||||
<Form.Item name="roleId" label="Role">
|
<Form.Item name="roleId" label="Role">
|
||||||
<Select>
|
<Select>
|
||||||
<Select.Option value="d0f0fa0d-3f3b-438b-a76f-7febeb8aab57">
|
<Select.Option value="d0f0fa0d-3f3b-438b-a76f-7febeb8aab57">
|
||||||
Admin
|
{tmpRoleNames["d0f0fa0d-3f3b-438b-a76f-7febeb8aab57"]}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
<Select.Option value="b7359e12-359e-423b-b39c-f0d4069adebc">
|
<Select.Option value="b7359e12-359e-423b-b39c-f0d4069adebc">
|
||||||
Moderator
|
{tmpRoleNames["b7359e12-359e-423b-b39c-f0d4069adebc"]}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
<Select.Option value="a1f084ad-d501-4015-b326-4c5c46fd1c5e">
|
<Select.Option value="a1f084ad-d501-4015-b326-4c5c46fd1c5e">
|
||||||
User
|
{tmpRoleNames["a1f084ad-d501-4015-b326-4c5c46fd1c5e"]}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
@ -150,7 +173,7 @@ export default function TeamCreateUser() {
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
>
|
>
|
||||||
Create User
|
{t("teamCreateUser.buttonCreateUser")}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -15,15 +15,16 @@ import MyErrorResult from "shared/components/MyResult";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { setTeamMembers, teamMembers } from "./teamSlice";
|
import { setTeamMembers, teamMembers } from "./teamSlice";
|
||||||
import { tmpRoleNames } from "features/Roles";
|
|
||||||
import {
|
import {
|
||||||
addWebSocketReconnectListener,
|
addWebSocketReconnectListener,
|
||||||
removeWebSocketReconnectListener,
|
removeWebSocketReconnectListener,
|
||||||
} from "core/services/websocketService";
|
} from "core/services/websocketService";
|
||||||
import { useMessage } from "core/context/MessageContext";
|
import { useMessage } from "core/context/MessageContext";
|
||||||
import MyUserAvatar from "shared/components/MyUserAvatar";
|
import MyUserAvatar from "shared/components/MyUserAvatar";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const TeamList: React.FC = () => {
|
const TeamList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { success, error: errorMessage } = useMessage();
|
const { success, error: errorMessage } = useMessage();
|
||||||
|
|
||||||
|
@ -41,41 +42,45 @@ const TeamList: React.FC = () => {
|
||||||
const [reqDeleteTeamMember, { isLoading: loadingDeleteTeamMember }] =
|
const [reqDeleteTeamMember, { isLoading: loadingDeleteTeamMember }] =
|
||||||
useDeleteTeamMemberMutation();
|
useDeleteTeamMemberMutation();
|
||||||
|
|
||||||
|
const tmpRoleNames = t("roles.roleNames", {
|
||||||
|
returnObjects: true,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
const getTableContent = () => {
|
const getTableContent = () => {
|
||||||
let items = [
|
let items = [
|
||||||
{
|
{
|
||||||
title: "First name",
|
title: t("common.firstName"),
|
||||||
dataIndex: "firstName",
|
dataIndex: "firstName",
|
||||||
key: "firstName",
|
key: "firstName",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Last name",
|
title: t("common.lastName"),
|
||||||
dataIndex: "lastName",
|
dataIndex: "lastName",
|
||||||
key: "lastName",
|
key: "lastName",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Email",
|
title: t("common.email"),
|
||||||
dataIndex: "email",
|
dataIndex: "email",
|
||||||
key: "email",
|
key: "email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Role",
|
title: t("common.role"),
|
||||||
dataIndex: "role",
|
dataIndex: "role",
|
||||||
key: "role",
|
key: "role",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Status",
|
title: t("common.status"),
|
||||||
dataIndex: "status",
|
dataIndex: "status",
|
||||||
key: "status",
|
key: "status",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Actions",
|
title: t("common.actions"),
|
||||||
dataIndex: "actions",
|
dataIndex: "actions",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Change role to"
|
title={t("team.popConfirmRoleChange.title")}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await reqUpdateTeamMemberRole({
|
await reqUpdateTeamMemberRole({
|
||||||
|
@ -83,13 +88,18 @@ const TeamList: React.FC = () => {
|
||||||
roleId: selectedRoleId,
|
roleId: selectedRoleId,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
success("Role updated successfully");
|
success(
|
||||||
|
t(
|
||||||
|
"team.popConfirmRoleChange.messageUpdatedRoleSuccessfully"
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
errorMessage("Error updating role");
|
errorMessage(t("common.messageRequestFailed"));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
okButtonProps={{ loading: loadingUpdateTeamMemberRole }}
|
okButtonProps={{ loading: loadingUpdateTeamMemberRole }}
|
||||||
|
cancelText={t("common.cancel")}
|
||||||
description={
|
description={
|
||||||
<Select
|
<Select
|
||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
|
@ -109,27 +119,32 @@ const TeamList: React.FC = () => {
|
||||||
type="link"
|
type="link"
|
||||||
onClick={() => setSelectedRoleId(record.role)}
|
onClick={() => setSelectedRoleId(record.role)}
|
||||||
>
|
>
|
||||||
Change role
|
{t("team.changeRole")}
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Confirm deletion of team member"
|
title={t("team.popConfirmDeleteMember.title")}
|
||||||
okButtonProps={{
|
okButtonProps={{
|
||||||
loading: loadingDeleteTeamMember,
|
loading: loadingDeleteTeamMember,
|
||||||
}}
|
}}
|
||||||
|
cancelText={t("common.cancel")}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await reqDeleteTeamMember(record.key).unwrap();
|
await reqDeleteTeamMember(record.key).unwrap();
|
||||||
|
|
||||||
success("Team member deleted successfully");
|
success(
|
||||||
|
t(
|
||||||
|
"team.popConfirmDeleteMember.messageDeletedMemberSuccessfully"
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
|
@ -199,9 +214,15 @@ const TeamList: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Team() {
|
export default function Team() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MyBanner title="Team" subtitle="MANAGE" headerBar={<HeaderBar />} />
|
<MyBanner
|
||||||
|
title={t("team.bannerTitle")}
|
||||||
|
subtitle={t("common.bannerSubtitle")}
|
||||||
|
headerBar={<HeaderBar />}
|
||||||
|
/>
|
||||||
|
|
||||||
<MyContainer
|
<MyContainer
|
||||||
style={{
|
style={{
|
||||||
|
@ -212,7 +233,9 @@ export default function Team() {
|
||||||
>
|
>
|
||||||
<Flex justify="end">
|
<Flex justify="end">
|
||||||
<Link to={Constants.ROUTE_PATHS.ORGANIZATION_TEAM_CREATE_USER}>
|
<Link to={Constants.ROUTE_PATHS.ORGANIZATION_TEAM_CREATE_USER}>
|
||||||
<Button icon={<UserAddOutlined />}>Invite new member</Button>
|
<Button icon={<UserAddOutlined />}>
|
||||||
|
{t("team.addMemberButton")}
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
Card,
|
Card,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Flex,
|
Flex,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
Select,
|
||||||
Typography,
|
Typography,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import HeaderBar from "../../core/components/Header";
|
import HeaderBar from "../../core/components/Header";
|
||||||
import MyBanner from "../../shared/components/MyBanner";
|
import MyBanner from "../../shared/components/MyBanner";
|
||||||
import { Constants } from "core/utils/utils";
|
|
||||||
import MyMiddleCard from "shared/components/MyMiddleCard";
|
import MyMiddleCard from "shared/components/MyMiddleCard";
|
||||||
import Meta from "antd/es/card/Meta";
|
import Meta from "antd/es/card/Meta";
|
||||||
import { useGetUserProfileQuery } from "core/services/userProfile";
|
import { useGetUserProfileQuery } from "core/services/userProfile";
|
||||||
|
@ -31,15 +30,16 @@ import {
|
||||||
setProfilePictureUrl,
|
setProfilePictureUrl,
|
||||||
setRoleId,
|
setRoleId,
|
||||||
} from "./userProfileSlice";
|
} from "./userProfileSlice";
|
||||||
import { tmpRoleNames } from "features/Roles";
|
|
||||||
import MyErrorResult from "shared/components/MyResult";
|
import MyErrorResult from "shared/components/MyResult";
|
||||||
import MyUpload from "shared/components/MyUpload";
|
import MyUpload from "shared/components/MyUpload";
|
||||||
import { useMessage } from "core/context/MessageContext";
|
import { useMessage } from "core/context/MessageContext";
|
||||||
import MyUserAvatar from "shared/components/MyUserAvatar";
|
import MyUserAvatar from "shared/components/MyUserAvatar";
|
||||||
import { setUserProfilePictureUrl } from "core/reducers/appSlice";
|
import { setUserProfilePictureUrl } from "core/reducers/appSlice";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
|
export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const { success } = useMessage();
|
const { success } = useMessage();
|
||||||
|
|
||||||
const dataProfilePictureUrl = useSelector(profilePictureUrl);
|
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 }) {
|
function AdminWrapper({ children }: { children: React.ReactNode }) {
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
@ -98,12 +102,15 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MyBanner
|
<MyBanner
|
||||||
title="Account Settings"
|
title={t("userProfile.bannerTitle")}
|
||||||
subtitle="MANAGE"
|
subtitle={t("common.bannerSubtitle")}
|
||||||
headerBar={<HeaderBar />}
|
headerBar={<HeaderBar />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MyMiddleCard title="My Profile" loading={isLoading}>
|
<MyMiddleCard
|
||||||
|
title={t("userProfile.middleCardTitle")}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<MyErrorResult />
|
<MyErrorResult />
|
||||||
) : (
|
) : (
|
||||||
|
@ -115,31 +122,60 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Meta
|
<Form
|
||||||
avatar={
|
layout="vertical"
|
||||||
<MyUpload
|
initialValues={{
|
||||||
action={`/user/profile/picture`}
|
language: i18n.language,
|
||||||
onChange={(info) => {
|
}}
|
||||||
if (info.file.status === "done") {
|
>
|
||||||
success("Profile picture updated successfully");
|
<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(
|
||||||
dispatch(
|
setProfilePictureUrl(info.file.response.Data)
|
||||||
setUserProfilePictureUrl(info.file.response.Data)
|
);
|
||||||
);
|
dispatch(
|
||||||
}
|
setUserProfilePictureUrl(info.file.response.Data)
|
||||||
}}
|
);
|
||||||
imgCropProps={{
|
}
|
||||||
aspect: 1 / 1,
|
}}
|
||||||
children: <></>,
|
imgCropProps={{
|
||||||
}}
|
aspect: 1 / 1,
|
||||||
>
|
children: <></>,
|
||||||
<MyUserAvatar profilePictureUrl={dataProfilePictureUrl} />
|
}}
|
||||||
</MyUpload>
|
>
|
||||||
}
|
<MyUserAvatar
|
||||||
title={`${dataFirstName} ${dataLastName}`}
|
profilePictureUrl={dataProfilePictureUrl}
|
||||||
description={tmpRoleNames[dataRoleId]}
|
/>
|
||||||
/>
|
</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>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
@ -151,26 +187,26 @@ export default function UserProfile({ isAdmin }: { isAdmin?: boolean }) {
|
||||||
>
|
>
|
||||||
<AdminWrapper>
|
<AdminWrapper>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
title="Personal Information"
|
title={t("userProfile.personalInformation")}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: "1",
|
key: "1",
|
||||||
label: "First name",
|
label: t("common.firstName"),
|
||||||
children: (
|
children: (
|
||||||
<TextItem value={dataFirstName} name="firstName" />
|
<TextItem value={dataFirstName} name="firstName" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "2",
|
key: "2",
|
||||||
label: "Last name",
|
label: t("common.lastName"),
|
||||||
children: (
|
children: (
|
||||||
<TextItem value={dataLastName} name="lastName" />
|
<TextItem value={dataLastName} name="lastName" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "3",
|
key: "3",
|
||||||
label: "Email",
|
label: t("common.email"),
|
||||||
children: <TextItem value={dataEmail} name="email" />,
|
children: <TextItem value={dataEmail} name="email" />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
@ -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() {
|
export default function WhatsNew() {
|
||||||
return (
|
const { t } = useTranslation();
|
||||||
<>
|
|
||||||
<h1>WhatsNew</h1>
|
return (
|
||||||
</>
|
<>
|
||||||
);
|
<MyBanner title={t("whatsNew.bannerTitle")} headerBar={<HeaderBar />} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -6,6 +6,7 @@ import { Provider } from "react-redux";
|
||||||
import { store } from "./core/store/store";
|
import { store } from "./core/store/store";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import MyCenteredSpin from "./shared/components/MyCenteredSpin";
|
import MyCenteredSpin from "./shared/components/MyCenteredSpin";
|
||||||
|
import "./i18n";
|
||||||
// import reportWebVitals from './reportWebVitals';
|
// import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
|
|
|
@ -1,5 +1,18 @@
|
||||||
import { Empty } from "antd";
|
import { Empty } from "antd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function MyEmpty() {
|
interface MyEmptyProps extends React.ComponentProps<typeof Empty> {
|
||||||
return <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;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Link } from "react-router-dom";
|
||||||
import MyUpload from "shared/components/MyUpload";
|
import MyUpload from "shared/components/MyUpload";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
import { LessonSettings } from "core/types/lesson";
|
import { LessonSettings } from "core/types/lesson";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function MyLessonPreviewCard({
|
export default function MyLessonPreviewCard({
|
||||||
mode = "view",
|
mode = "view",
|
||||||
|
@ -21,6 +22,8 @@ export default function MyLessonPreviewCard({
|
||||||
onEditTitle?: (newTitle: string) => void;
|
onEditTitle?: (newTitle: string) => void;
|
||||||
onThumbnailChanged?: () => void;
|
onThumbnailChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const LinkWrapper = ({ children }: { children: React.ReactNode }) => {
|
const LinkWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
if (mode === "editable") return <>{children}</>;
|
if (mode === "editable") return <>{children}</>;
|
||||||
|
|
||||||
|
@ -74,7 +77,8 @@ export default function MyLessonPreviewCard({
|
||||||
{mode === "view" ? (
|
{mode === "view" ? (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.title}>{lessonSettings.Title}</div>
|
<div className={styles.title}>{lessonSettings.Title}</div>
|
||||||
<CommentOutlined /> 12 comments
|
<CommentOutlined /> {lessonSettings.QuestionsCount}{" "}
|
||||||
|
{t("lessonPage.questions")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Title
|
<Typography.Title
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import { Result } from "antd";
|
import { Result } from "antd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function MyErrorResult() {
|
export default function MyErrorResult() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Result
|
<Result
|
||||||
status="error"
|
status="error"
|
||||||
title="Something went wrong"
|
title={t("myErrorResult.title")}
|
||||||
subTitle="Please try again later."
|
subTitle={t("myErrorResult.subTitle")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,55 @@
|
||||||
import { Avatar } from 'antd';
|
import { Avatar } from "antd";
|
||||||
import { Constants } from 'core/utils/utils';
|
import { Constants } from "core/utils/utils";
|
||||||
import { UserOutlined } from '@ant-design/icons';
|
import { UserOutlined } from "@ant-design/icons";
|
||||||
import { primaryColor } from 'core/reducers/appSlice';
|
import { primaryColor } from "core/reducers/appSlice";
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
interface MyUserAvatarProps {
|
interface MyUserAvatarProps {
|
||||||
size?: number;
|
size?: number;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
profilePictureUrl: string;
|
profilePictureUrl: string;
|
||||||
disableCursorPointer?: boolean;
|
disableCursorPointer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyUserAvatar: React.FC<MyUserAvatarProps> = ({ size = 56, firstName, profilePictureUrl, disableCursorPointer }) => {
|
const MyUserAvatar: React.FC<MyUserAvatarProps> = ({
|
||||||
const appPrimaryColor = useSelector(primaryColor);
|
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 isProfilePictureEmpty = profilePictureUrl === "";
|
||||||
const avatarContent = isProfilePictureEmpty && firstName !== undefined ? firstName.charAt(0) : undefined;
|
const avatarContent =
|
||||||
const iconContent = isProfilePictureEmpty && firstName === undefined ? <UserOutlined /> : undefined;
|
isProfilePictureEmpty && firstName !== undefined
|
||||||
const avatarSrc = isProfilePictureEmpty ? undefined : `${Constants.STATIC_CONTENT_ADDRESS}/${profilePictureUrl}`;
|
? firstName.charAt(0)
|
||||||
const avatarStyle = isProfilePictureEmpty ? { ...defaultStyle, backgroundColor: `#${appPrimaryColor}` } : defaultStyle;
|
: 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 (
|
return (
|
||||||
<div style={{ userSelect: 'none' }}>
|
<div style={{ userSelect: "none" }}>
|
||||||
<Avatar size={size} style={avatarStyle} src={avatarSrc} icon={iconContent}>
|
<Avatar
|
||||||
{avatarContent}
|
size={size}
|
||||||
</Avatar>
|
style={avatarStyle}
|
||||||
</div>
|
src={avatarSrc}
|
||||||
);
|
icon={iconContent}
|
||||||
|
>
|
||||||
|
{avatarContent}
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MyUserAvatar;
|
export default MyUserAvatar;
|
||||||
|
|
Loading…
Reference in New Issue