Merge remote-tracking branch 'origin/dev' into feat/user-routes
# Conflicts: # backend/src/controllers/learning-objects.ts # frontend/src/controllers/controllers.ts # frontend/src/queries/themes.ts
This commit is contained in:
		
						commit
						084f4fcdbd
					
				
					 45 changed files with 1319 additions and 110 deletions
				
			
		|  | @ -35,12 +35,8 @@ Om de applicatie lokaal te draaien als kant-en-klare Docker-containers: | ||||||
| ```bash | ```bash | ||||||
| docker compose version | docker compose version | ||||||
| git clone https://github.com/SELab-2/Dwengo-1.git | git clone https://github.com/SELab-2/Dwengo-1.git | ||||||
| cd Dwengo-1/backend |  | ||||||
| cp .env.example .env |  | ||||||
| # Pas .env aan |  | ||||||
| nano .env |  | ||||||
| cd .. |  | ||||||
| docker compose -f compose.staging.yml up --build | docker compose -f compose.staging.yml up --build | ||||||
|  | # Gebruikt backend/.env.staging | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| ### Handmatige installatie en ontwikkeling | ### Handmatige installatie en ontwikkeling | ||||||
|  |  | ||||||
							
								
								
									
										21
									
								
								backend/.env.staging
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								backend/.env.staging
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | PORT=3000 | ||||||
|  | DWENGO_DB_HOST=db | ||||||
|  | DWENGO_DB_PORT=5432 | ||||||
|  | DWENGO_DB_USERNAME=postgres | ||||||
|  | DWENGO_DB_PASSWORD=postgres | ||||||
|  | DWENGO_DB_UPDATE=false | ||||||
|  | 
 | ||||||
|  | DWENGO_AUTH_STUDENT_URL=http://localhost/idp/realms/student | ||||||
|  | DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo | ||||||
|  | DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs | ||||||
|  | DWENGO_AUTH_TEACHER_URL=http://localhost/idp/realms/teacher | ||||||
|  | DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo | ||||||
|  | DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs | ||||||
|  | 
 | ||||||
|  | # Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! | ||||||
|  | #DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/,127.0.0.1:80,http://127.0.0.1,http://localhost:80,http://127.0.0.1:80,localhost | ||||||
|  | DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080 | ||||||
|  | 
 | ||||||
|  | # Logging and monitoring | ||||||
|  | 
 | ||||||
|  | LOKI_HOST=http://logging:3102 | ||||||
|  | @ -1,3 +1,13 @@ | ||||||
| PORT=3000 | # | ||||||
| DWENGO_DB_UPDATE=true | # Test environment configuration | ||||||
|  | # | ||||||
|  | # Should not need to be modified. | ||||||
|  | # See .env.example for more information. | ||||||
|  | # | ||||||
|  | 
 | ||||||
|  | ### Dwengo ### | ||||||
|  | 
 | ||||||
|  | DWENGO_PORT=3000 | ||||||
|  | 
 | ||||||
| DWENGO_DB_NAME=":memory:" | DWENGO_DB_NAME=":memory:" | ||||||
|  | DWENGO_DB_UPDATE=true | ||||||
|  |  | ||||||
|  | @ -1,13 +0,0 @@ | ||||||
| # |  | ||||||
| # Test environment configuration |  | ||||||
| # |  | ||||||
| # Should not need to be modified. |  | ||||||
| # See .env.example for more information. |  | ||||||
| # |  | ||||||
| 
 |  | ||||||
| ### Dwengo ### |  | ||||||
| 
 |  | ||||||
| DWENGO_PORT=3000 |  | ||||||
| 
 |  | ||||||
| DWENGO_DB_NAME=":memory:" |  | ||||||
| DWENGO_DB_UPDATE=true |  | ||||||
|  | @ -34,7 +34,9 @@ npm run test:unit | ||||||
| 
 | 
 | ||||||
| ```shell | ```shell | ||||||
| # Omgevingsvariabelen | # Omgevingsvariabelen | ||||||
| cp .env.development.example .env | cp .env.example .env | ||||||
|  | # Configureer de .env file met de juiste waarden! | ||||||
|  | nano .env | ||||||
| 
 | 
 | ||||||
| npm run build | npm run build | ||||||
| npm run start | npm run start | ||||||
|  |  | ||||||
|  | @ -1,12 +1,12 @@ | ||||||
| import { Request, Response } from 'express'; | import { Request, Response } from 'express'; | ||||||
| import { FALLBACK_LANG } from '../config.js'; | import { FALLBACK_LANG } from '../config.js'; | ||||||
|  | import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.js'; | ||||||
| import learningObjectService from '../services/learning-objects/learning-object-service.js'; | import learningObjectService from '../services/learning-objects/learning-object-service.js'; | ||||||
| import { envVars, getEnvVar } from '../util/envVars.js'; |  | ||||||
| import { Language } from '@dwengo-1/common/util/language'; | import { Language } from '@dwengo-1/common/util/language'; | ||||||
| import attachmentService from '../services/learning-objects/attachment-service.js'; | import attachmentService from '../services/learning-objects/attachment-service.js'; | ||||||
| import { NotFoundError } from '@mikro-orm/core'; |  | ||||||
| import { BadRequestException } from '../exceptions/bad-request-exception.js'; | import { BadRequestException } from '../exceptions/bad-request-exception.js'; | ||||||
| import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||||
|  | import { envVars, getEnvVar } from '../util/envVars.js'; | ||||||
| 
 | 
 | ||||||
| function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { | function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { | ||||||
|     if (!req.params.hruid) { |     if (!req.params.hruid) { | ||||||
|  | @ -47,6 +47,11 @@ export async function getLearningObject(req: Request, res: Response): Promise<vo | ||||||
|     const learningObjectId = getLearningObjectIdentifierFromRequest(req); |     const learningObjectId = getLearningObjectIdentifierFromRequest(req); | ||||||
| 
 | 
 | ||||||
|     const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); |     const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); | ||||||
|  | 
 | ||||||
|  |     if (!learningObject) { | ||||||
|  |         throw new NotFoundException('Learning object not found'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     res.json(learningObject); |     res.json(learningObject); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -63,7 +68,7 @@ export async function getAttachment(req: Request, res: Response): Promise<void> | ||||||
|     const attachment = await attachmentService.getAttachment(learningObjectId, name); |     const attachment = await attachmentService.getAttachment(learningObjectId, name); | ||||||
| 
 | 
 | ||||||
|     if (!attachment) { |     if (!attachment) { | ||||||
|         throw new NotFoundError(`Attachment ${name} not found`); |         throw new NotFoundException(`Attachment ${name} not found`); | ||||||
|     } |     } | ||||||
|     res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); |     res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,6 +4,6 @@ export default defineConfig({ | ||||||
|     test: { |     test: { | ||||||
|         environment: 'node', |         environment: 'node', | ||||||
|         globals: true, |         globals: true, | ||||||
|         testTimeout: 10000, |         testTimeout: 100000, | ||||||
|     }, |     }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -24,7 +24,7 @@ services: | ||||||
|             - '3000:3000/tcp' |             - '3000:3000/tcp' | ||||||
|         restart: unless-stopped |         restart: unless-stopped | ||||||
|         volumes: |         volumes: | ||||||
|             - ./backend/.env:/app/.env |             - ./backend/.env.staging:/app/.env | ||||||
|         depends_on: |         depends_on: | ||||||
|             - db |             - db | ||||||
|             - logging |             - logging | ||||||
|  |  | ||||||
|  | @ -1,9 +1,10 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import ThemeCard from "@/components/ThemeCard.vue"; |     import ThemeCard from "@/components/ThemeCard.vue"; | ||||||
|     import { ref, watchEffect, computed } from "vue"; |     import { ref, watchEffect, computed, type Ref } from "vue"; | ||||||
|     import { useI18n } from "vue-i18n"; |     import { useI18n } from "vue-i18n"; | ||||||
|     import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; |     import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; | ||||||
|     import { useThemeQuery } from "@/queries/themes.ts"; |     import { useThemeQuery } from "@/queries/themes.ts"; | ||||||
|  |     import type { Theme } from "@/data-objects/theme.ts"; | ||||||
| 
 | 
 | ||||||
|     const props = defineProps({ |     const props = defineProps({ | ||||||
|         selectedTheme: { type: String, required: true }, |         selectedTheme: { type: String, required: true }, | ||||||
|  | @ -15,11 +16,11 @@ | ||||||
| 
 | 
 | ||||||
|     const { data: allThemes, isLoading, error } = useThemeQuery(language); |     const { data: allThemes, isLoading, error } = useThemeQuery(language); | ||||||
| 
 | 
 | ||||||
|     const allCards = ref([]); |     const allCards: Ref<Theme[]> = ref([]); | ||||||
|     const cards = ref([]); |     const cards: Ref<Theme[]> = ref([]); | ||||||
| 
 | 
 | ||||||
|     watchEffect(() => { |     watchEffect(() => { | ||||||
|         const themes = allThemes.value ?? []; |         const themes: Theme[] = allThemes.value ?? []; | ||||||
|         allCards.value = themes; |         allCards.value = themes; | ||||||
| 
 | 
 | ||||||
|         if (props.selectedTheme) { |         if (props.selectedTheme) { | ||||||
|  |  | ||||||
|  | @ -1,7 +0,0 @@ | ||||||
| <script setup lang="ts"></script> |  | ||||||
| 
 |  | ||||||
| <template> |  | ||||||
|     <main></main> |  | ||||||
| </template> |  | ||||||
| 
 |  | ||||||
| <style scoped></style> |  | ||||||
							
								
								
									
										34
									
								
								frontend/src/components/LearningPathSearchField.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								frontend/src/components/LearningPathSearchField.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  |     import { useI18n } from "vue-i18n"; | ||||||
|  |     import { useRoute, useRouter } from "vue-router"; | ||||||
|  |     import { computed, ref } from "vue"; | ||||||
|  |     const route = useRoute(); | ||||||
|  |     const router = useRouter(); | ||||||
|  |     const { t } = useI18n(); | ||||||
|  | 
 | ||||||
|  |     const SEARCH_PATH = "/learningPath/search"; | ||||||
|  | 
 | ||||||
|  |     const query = computed({ | ||||||
|  |         get: () => route.query.query as string | null, | ||||||
|  |         set: async (newValue) => router.push({ path: SEARCH_PATH, query: { query: newValue } }), | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const queryInput = ref(query.value); | ||||||
|  | 
 | ||||||
|  |     function search(): void { | ||||||
|  |         query.value = queryInput.value; | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <v-text-field | ||||||
|  |         class="search-field" | ||||||
|  |         :label="t('search')" | ||||||
|  |         append-inner-icon="mdi-magnify" | ||||||
|  |         v-model="queryInput" | ||||||
|  |         @keyup.enter="search()" | ||||||
|  |         @click:append-inner="search()" | ||||||
|  |     ></v-text-field> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped></style> | ||||||
							
								
								
									
										62
									
								
								frontend/src/components/LearningPathsGrid.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								frontend/src/components/LearningPathsGrid.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  |     import { convertBase64ToImageSrc } from "@/utils/base64ToImage.ts"; | ||||||
|  |     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  |     import { useI18n } from "vue-i18n"; | ||||||
|  | 
 | ||||||
|  |     const { t } = useI18n(); | ||||||
|  |     const props = defineProps<{ learningPaths: LearningPath[] }>(); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <div | ||||||
|  |         class="results-grid" | ||||||
|  |         v-if="props.learningPaths.length > 0" | ||||||
|  |     > | ||||||
|  |         <v-card | ||||||
|  |             class="learning-path-card" | ||||||
|  |             link | ||||||
|  |             :to="`/learningPath/${learningPath.hruid}/${learningPath.language}/${learningPath.startNode.learningobjectHruid}`" | ||||||
|  |             :key="`${learningPath.hruid}/${learningPath.language}`" | ||||||
|  |             v-for="learningPath in props.learningPaths" | ||||||
|  |         > | ||||||
|  |             <v-img | ||||||
|  |                 height="300px" | ||||||
|  |                 :src="convertBase64ToImageSrc(learningPath.image)" | ||||||
|  |                 cover | ||||||
|  |                 v-if="learningPath.image" | ||||||
|  |             ></v-img> | ||||||
|  |             <v-card-title class="learning-path-title">{{ learningPath.title }}</v-card-title> | ||||||
|  |             <v-card-subtitle> | ||||||
|  |                 <v-icon icon="mdi-human-male-boy"></v-icon> | ||||||
|  |                 <span>{{ learningPath.targetAges.min }} - {{ learningPath.targetAges.max }} {{ t("yearsAge") }}</span> | ||||||
|  |             </v-card-subtitle> | ||||||
|  |             <v-card-text>{{ learningPath.description }}</v-card-text> | ||||||
|  |         </v-card> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |         content="empty-state-container" | ||||||
|  |         v-else | ||||||
|  |     > | ||||||
|  |         <v-empty-state | ||||||
|  |             icon="mdi-emoticon-sad-outline" | ||||||
|  |             :title="t('noLearningPathsFound')" | ||||||
|  |             :text="t('noLearningPathsFoundDescription')" | ||||||
|  |         ></v-empty-state> | ||||||
|  |     </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  |     .learning-path-card { | ||||||
|  |         width: 300px; | ||||||
|  |     } | ||||||
|  |     .learning-path-title { | ||||||
|  |         white-space: normal; | ||||||
|  |     } | ||||||
|  |     .results-grid { | ||||||
|  |         margin: 20px; | ||||||
|  |         display: flex; | ||||||
|  |         align-items: stretch; | ||||||
|  |         gap: 20px; | ||||||
|  |         flex-wrap: wrap; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
							
								
								
									
										45
									
								
								frontend/src/components/UsingQueryResult.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								frontend/src/components/UsingQueryResult.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | ||||||
|  | <script setup lang="ts" generic="T"> | ||||||
|  |     import { computed } from "vue"; | ||||||
|  |     import { useI18n } from "vue-i18n"; | ||||||
|  |     import type { UseQueryReturnType } from "@tanstack/vue-query"; | ||||||
|  | 
 | ||||||
|  |     const props = defineProps<{ | ||||||
|  |         queryResult: UseQueryReturnType<T, Error>; | ||||||
|  |     }>(); | ||||||
|  | 
 | ||||||
|  |     const { isLoading, isError, isSuccess, data, error } = props.queryResult; | ||||||
|  | 
 | ||||||
|  |     const { t } = useI18n(); | ||||||
|  | 
 | ||||||
|  |     const errorMessage = computed(() => { | ||||||
|  |         const errorWithMessage = (error.value as { message: string }) || null; | ||||||
|  |         return errorWithMessage?.message || JSON.stringify(errorWithMessage); | ||||||
|  |     }); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <div | ||||||
|  |         class="loading-div" | ||||||
|  |         v-if="isLoading" | ||||||
|  |     > | ||||||
|  |         <v-progress-circular indeterminate></v-progress-circular> | ||||||
|  |     </div> | ||||||
|  |     <div v-if="isError"> | ||||||
|  |         <v-empty-state | ||||||
|  |             icon="mdi-alert-circle-outline" | ||||||
|  |             :text="errorMessage" | ||||||
|  |             :title="t('error_title')" | ||||||
|  |         ></v-empty-state> | ||||||
|  |     </div> | ||||||
|  |     <slot | ||||||
|  |         v-if="isSuccess && data" | ||||||
|  |         :data="data" | ||||||
|  |     ></slot> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  |     .loading-div { | ||||||
|  |         padding: 20px; | ||||||
|  |         text-align: center; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  | @ -1,73 +1,47 @@ | ||||||
| import { apiConfig } from "@/config.ts"; | import apiClient from "@/services/api-client/api-client.ts"; | ||||||
|  | import type { AxiosResponse, ResponseType } from "axios"; | ||||||
|  | import { HttpErrorResponseException } from "@/exception/http-error-response-exception.ts"; | ||||||
| 
 | 
 | ||||||
| export class BaseController { | export abstract class BaseController { | ||||||
|     protected baseUrl: string; |     protected basePath: string; | ||||||
| 
 | 
 | ||||||
|     constructor(basePath: string) { |     protected constructor(basePath: string) { | ||||||
|         this.baseUrl = `${apiConfig.baseUrl}/${basePath}`; |         this.basePath = basePath; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     protected async get<T>(path: string, queryParams?: Record<string, string | number | boolean>): Promise<T> { |     private static assertSuccessResponse(response: AxiosResponse<unknown, unknown>): void { | ||||||
|         let url = `${this.baseUrl}${path}`; |         if (response.status / 100 !== 2) { | ||||||
|         if (queryParams) { |             throw new HttpErrorResponseException(response); | ||||||
|             const query = new URLSearchParams(); |  | ||||||
|             Object.entries(queryParams).forEach(([key, value]) => { |  | ||||||
|                 if (value !== undefined && value !== null) { |  | ||||||
|                     query.append(key, value.toString()); |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|             url += `?${query.toString()}`; |  | ||||||
|         } |         } | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|         const res = await fetch(url); |     protected async get<T>(path: string, queryParams?: QueryParams, responseType?: ResponseType): Promise<T> { | ||||||
|         if (!res.ok) { |         const response = await apiClient.get<T>(this.absolutePathFor(path), { params: queryParams, responseType }); | ||||||
|             const errorData = await res.json().catch(() => ({})); |         BaseController.assertSuccessResponse(response); | ||||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); |         return response.data; | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return res.json(); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     protected async post<T>(path: string, body: unknown): Promise<T> { |     protected async post<T>(path: string, body: unknown): Promise<T> { | ||||||
|         const res = await fetch(`${this.baseUrl}${path}`, { |         const response = await apiClient.post<T>(this.absolutePathFor(path), body); | ||||||
|             method: "POST", |         BaseController.assertSuccessResponse(response); | ||||||
|             headers: { "Content-Type": "application/json" }, |         return response.data; | ||||||
|             body: JSON.stringify(body), |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         if (!res.ok) { |  | ||||||
|             const errorData = await res.json().catch(() => ({})); |  | ||||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return res.json(); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     protected async delete<T>(path: string): Promise<T> { |     protected async delete<T>(path: string): Promise<T> { | ||||||
|         const res = await fetch(`${this.baseUrl}${path}`, { |         const response = await apiClient.delete<T>(this.absolutePathFor(path)); | ||||||
|             method: "DELETE", |         BaseController.assertSuccessResponse(response); | ||||||
|         }); |         return response.data; | ||||||
| 
 |  | ||||||
|         if (!res.ok) { |  | ||||||
|             const errorData = await res.json().catch(() => ({})); |  | ||||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return res.json(); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     protected async put<T>(path: string, body: unknown): Promise<T> { |     protected async put<T>(path: string, body: unknown): Promise<T> { | ||||||
|         const res = await fetch(`${this.baseUrl}${path}`, { |         const response = await apiClient.put<T>(this.absolutePathFor(path), body); | ||||||
|             method: "PUT", |         BaseController.assertSuccessResponse(response); | ||||||
|             headers: { "Content-Type": "application/json" }, |         return response.data; | ||||||
|             body: JSON.stringify(body), |     } | ||||||
|         }); |  | ||||||
| 
 | 
 | ||||||
|         if (!res.ok) { |     private absolutePathFor(path: string): string { | ||||||
|             const errorData = await res.json().catch(() => ({})); |         return "/" + this.basePath + path; | ||||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return res.json(); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | type QueryParams = Record<string, string | number | boolean | undefined>; | ||||||
|  |  | ||||||
							
								
								
									
										18
									
								
								frontend/src/controllers/controllers.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								frontend/src/controllers/controllers.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | ||||||
|  | import { ThemeController } from "@/controllers/themes.ts"; | ||||||
|  | import { LearningObjectController } from "@/controllers/learning-objects.ts"; | ||||||
|  | import { LearningPathController } from "@/controllers/learning-paths.ts"; | ||||||
|  | 
 | ||||||
|  | export function controllerGetter<T>(factory: new () => T): () => T { | ||||||
|  |     let instance: T | undefined; | ||||||
|  | 
 | ||||||
|  |     return (): T => { | ||||||
|  |         if (!instance) { | ||||||
|  |             instance = new factory(); | ||||||
|  |         } | ||||||
|  |         return instance; | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const getThemeController = controllerGetter(ThemeController); | ||||||
|  | export const getLearningObjectController = controllerGetter(LearningObjectController); | ||||||
|  | export const getLearningPathController = controllerGetter(LearningPathController); | ||||||
							
								
								
									
										17
									
								
								frontend/src/controllers/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/controllers/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | ||||||
|  | import { BaseController } from "@/controllers/base-controller.ts"; | ||||||
|  | import type { Language } from "@/data-objects/language.ts"; | ||||||
|  | import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; | ||||||
|  | 
 | ||||||
|  | export class LearningObjectController extends BaseController { | ||||||
|  |     constructor() { | ||||||
|  |         super("learningObject"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async getMetadata(hruid: string, language: Language, version: number): Promise<LearningObject> { | ||||||
|  |         return this.get<LearningObject>(`/${hruid}`, { language, version }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async getHTML(hruid: string, language: Language, version: number): Promise<Document> { | ||||||
|  |         return this.get<Document>(`/${hruid}/html`, { language, version }, "document"); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								frontend/src/controllers/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								frontend/src/controllers/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | ||||||
|  | import { BaseController } from "@/controllers/base-controller.ts"; | ||||||
|  | import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  | import type { Language } from "@/data-objects/language.ts"; | ||||||
|  | import { single } from "@/utils/response-assertions.ts"; | ||||||
|  | import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; | ||||||
|  | 
 | ||||||
|  | export class LearningPathController extends BaseController { | ||||||
|  |     constructor() { | ||||||
|  |         super("learningPath"); | ||||||
|  |     } | ||||||
|  |     async search(query: string): Promise<LearningPath[]> { | ||||||
|  |         const dtos = await this.get<LearningPathDTO[]>("/", { search: query }); | ||||||
|  |         return dtos.map((dto) => LearningPath.fromDTO(dto)); | ||||||
|  |     } | ||||||
|  |     async getBy( | ||||||
|  |         hruid: string, | ||||||
|  |         language: Language, | ||||||
|  |         options?: { forGroup?: string; forStudent?: string }, | ||||||
|  |     ): Promise<LearningPath> { | ||||||
|  |         const dtos = await this.get<LearningPathDTO[]>("/", { | ||||||
|  |             hruid, | ||||||
|  |             language, | ||||||
|  |             forGroup: options?.forGroup, | ||||||
|  |             forStudent: options?.forStudent, | ||||||
|  |         }); | ||||||
|  |         return LearningPath.fromDTO(single(dtos)); | ||||||
|  |     } | ||||||
|  |     async getAllByTheme(theme: string): Promise<LearningPath[]> { | ||||||
|  |         const dtos = await this.get<LearningPathDTO[]>("/", { theme }); | ||||||
|  |         return dtos.map((dto) => LearningPath.fromDTO(dto)); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										186
									
								
								frontend/src/data-objects/language.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								frontend/src/data-objects/language.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,186 @@ | ||||||
|  | export enum Language { | ||||||
|  |     Afar = "aa", | ||||||
|  |     Abkhazian = "ab", | ||||||
|  |     Afrikaans = "af", | ||||||
|  |     Akan = "ak", | ||||||
|  |     Albanian = "sq", | ||||||
|  |     Amharic = "am", | ||||||
|  |     Arabic = "ar", | ||||||
|  |     Aragonese = "an", | ||||||
|  |     Armenian = "hy", | ||||||
|  |     Assamese = "as", | ||||||
|  |     Avaric = "av", | ||||||
|  |     Avestan = "ae", | ||||||
|  |     Aymara = "ay", | ||||||
|  |     Azerbaijani = "az", | ||||||
|  |     Bashkir = "ba", | ||||||
|  |     Bambara = "bm", | ||||||
|  |     Basque = "eu", | ||||||
|  |     Belarusian = "be", | ||||||
|  |     Bengali = "bn", | ||||||
|  |     Bihari = "bh", | ||||||
|  |     Bislama = "bi", | ||||||
|  |     Bosnian = "bs", | ||||||
|  |     Breton = "br", | ||||||
|  |     Bulgarian = "bg", | ||||||
|  |     Burmese = "my", | ||||||
|  |     Catalan = "ca", | ||||||
|  |     Chamorro = "ch", | ||||||
|  |     Chechen = "ce", | ||||||
|  |     Chinese = "zh", | ||||||
|  |     ChurchSlavic = "cu", | ||||||
|  |     Chuvash = "cv", | ||||||
|  |     Cornish = "kw", | ||||||
|  |     Corsican = "co", | ||||||
|  |     Cree = "cr", | ||||||
|  |     Czech = "cs", | ||||||
|  |     Danish = "da", | ||||||
|  |     Divehi = "dv", | ||||||
|  |     Dutch = "nl", | ||||||
|  |     Dzongkha = "dz", | ||||||
|  |     English = "en", | ||||||
|  |     Esperanto = "eo", | ||||||
|  |     Estonian = "et", | ||||||
|  |     Ewe = "ee", | ||||||
|  |     Faroese = "fo", | ||||||
|  |     Fijian = "fj", | ||||||
|  |     Finnish = "fi", | ||||||
|  |     French = "fr", | ||||||
|  |     Frisian = "fy", | ||||||
|  |     Fulah = "ff", | ||||||
|  |     Georgian = "ka", | ||||||
|  |     German = "de", | ||||||
|  |     Gaelic = "gd", | ||||||
|  |     Irish = "ga", | ||||||
|  |     Galician = "gl", | ||||||
|  |     Manx = "gv", | ||||||
|  |     Greek = "el", | ||||||
|  |     Guarani = "gn", | ||||||
|  |     Gujarati = "gu", | ||||||
|  |     Haitian = "ht", | ||||||
|  |     Hausa = "ha", | ||||||
|  |     Hebrew = "he", | ||||||
|  |     Herero = "hz", | ||||||
|  |     Hindi = "hi", | ||||||
|  |     HiriMotu = "ho", | ||||||
|  |     Croatian = "hr", | ||||||
|  |     Hungarian = "hu", | ||||||
|  |     Igbo = "ig", | ||||||
|  |     Icelandic = "is", | ||||||
|  |     Ido = "io", | ||||||
|  |     SichuanYi = "ii", | ||||||
|  |     Inuktitut = "iu", | ||||||
|  |     Interlingue = "ie", | ||||||
|  |     Interlingua = "ia", | ||||||
|  |     Indonesian = "id", | ||||||
|  |     Inupiaq = "ik", | ||||||
|  |     Italian = "it", | ||||||
|  |     Javanese = "jv", | ||||||
|  |     Japanese = "ja", | ||||||
|  |     Kalaallisut = "kl", | ||||||
|  |     Kannada = "kn", | ||||||
|  |     Kashmiri = "ks", | ||||||
|  |     Kanuri = "kr", | ||||||
|  |     Kazakh = "kk", | ||||||
|  |     Khmer = "km", | ||||||
|  |     Kikuyu = "ki", | ||||||
|  |     Kinyarwanda = "rw", | ||||||
|  |     Kirghiz = "ky", | ||||||
|  |     Komi = "kv", | ||||||
|  |     Kongo = "kg", | ||||||
|  |     Korean = "ko", | ||||||
|  |     Kuanyama = "kj", | ||||||
|  |     Kurdish = "ku", | ||||||
|  |     Lao = "lo", | ||||||
|  |     Latin = "la", | ||||||
|  |     Latvian = "lv", | ||||||
|  |     Limburgan = "li", | ||||||
|  |     Lingala = "ln", | ||||||
|  |     Lithuanian = "lt", | ||||||
|  |     Luxembourgish = "lb", | ||||||
|  |     LubaKatanga = "lu", | ||||||
|  |     Ganda = "lg", | ||||||
|  |     Macedonian = "mk", | ||||||
|  |     Marshallese = "mh", | ||||||
|  |     Malayalam = "ml", | ||||||
|  |     Maori = "mi", | ||||||
|  |     Marathi = "mr", | ||||||
|  |     Malay = "ms", | ||||||
|  |     Malagasy = "mg", | ||||||
|  |     Maltese = "mt", | ||||||
|  |     Mongolian = "mn", | ||||||
|  |     Nauru = "na", | ||||||
|  |     Navajo = "nv", | ||||||
|  |     SouthNdebele = "nr", | ||||||
|  |     NorthNdebele = "nd", | ||||||
|  |     Ndonga = "ng", | ||||||
|  |     Nepali = "ne", | ||||||
|  |     NorwegianNynorsk = "nn", | ||||||
|  |     NorwegianBokmal = "nb", | ||||||
|  |     Norwegian = "no", | ||||||
|  |     Chichewa = "ny", | ||||||
|  |     Occitan = "oc", | ||||||
|  |     Ojibwa = "oj", | ||||||
|  |     Oriya = "or", | ||||||
|  |     Oromo = "om", | ||||||
|  |     Ossetian = "os", | ||||||
|  |     Punjabi = "pa", | ||||||
|  |     Persian = "fa", | ||||||
|  |     Pali = "pi", | ||||||
|  |     Polish = "pl", | ||||||
|  |     Portuguese = "pt", | ||||||
|  |     Pashto = "ps", | ||||||
|  |     Quechua = "qu", | ||||||
|  |     Romansh = "rm", | ||||||
|  |     Romanian = "ro", | ||||||
|  |     Rundi = "rn", | ||||||
|  |     Russian = "ru", | ||||||
|  |     Sango = "sg", | ||||||
|  |     Sanskrit = "sa", | ||||||
|  |     Sinhala = "si", | ||||||
|  |     Slovak = "sk", | ||||||
|  |     Slovenian = "sl", | ||||||
|  |     NorthernSami = "se", | ||||||
|  |     Samoan = "sm", | ||||||
|  |     Shona = "sn", | ||||||
|  |     Sindhi = "sd", | ||||||
|  |     Somali = "so", | ||||||
|  |     Sotho = "st", | ||||||
|  |     Spanish = "es", | ||||||
|  |     Sardinian = "sc", | ||||||
|  |     Serbian = "sr", | ||||||
|  |     Swati = "ss", | ||||||
|  |     Sundanese = "su", | ||||||
|  |     Swahili = "sw", | ||||||
|  |     Swedish = "sv", | ||||||
|  |     Tahitian = "ty", | ||||||
|  |     Tamil = "ta", | ||||||
|  |     Tatar = "tt", | ||||||
|  |     Telugu = "te", | ||||||
|  |     Tajik = "tg", | ||||||
|  |     Tagalog = "tl", | ||||||
|  |     Thai = "th", | ||||||
|  |     Tibetan = "bo", | ||||||
|  |     Tigrinya = "ti", | ||||||
|  |     Tonga = "to", | ||||||
|  |     Tswana = "tn", | ||||||
|  |     Tsonga = "ts", | ||||||
|  |     Turkmen = "tk", | ||||||
|  |     Turkish = "tr", | ||||||
|  |     Twi = "tw", | ||||||
|  |     Uighur = "ug", | ||||||
|  |     Ukrainian = "uk", | ||||||
|  |     Urdu = "ur", | ||||||
|  |     Uzbek = "uz", | ||||||
|  |     Venda = "ve", | ||||||
|  |     Vietnamese = "vi", | ||||||
|  |     Volapuk = "vo", | ||||||
|  |     Welsh = "cy", | ||||||
|  |     Walloon = "wa", | ||||||
|  |     Wolof = "wo", | ||||||
|  |     Xhosa = "xh", | ||||||
|  |     Yiddish = "yi", | ||||||
|  |     Yoruba = "yo", | ||||||
|  |     Zhuang = "za", | ||||||
|  |     Zulu = "zu", | ||||||
|  | } | ||||||
|  | @ -0,0 +1,4 @@ | ||||||
|  | export interface EducationalGoal { | ||||||
|  |     source: string; | ||||||
|  |     id: string; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,25 @@ | ||||||
|  | import type { Language } from "@/data-objects/language.ts"; | ||||||
|  | import type { ReturnValue } from "@/data-objects/learning-objects/return-value.ts"; | ||||||
|  | import type { EducationalGoal } from "@/data-objects/learning-objects/educational-goal.ts"; | ||||||
|  | 
 | ||||||
|  | export interface LearningObject { | ||||||
|  |     key: string; | ||||||
|  |     _id: string; | ||||||
|  |     uuid: string; | ||||||
|  |     version: number; | ||||||
|  |     title: string; | ||||||
|  |     htmlUrl: string; | ||||||
|  |     language: Language; | ||||||
|  |     difficulty: number; | ||||||
|  |     estimatedTime?: number; | ||||||
|  |     available: boolean; | ||||||
|  |     teacherExclusive: boolean; | ||||||
|  |     educationalGoals: EducationalGoal[]; | ||||||
|  |     keywords: string[]; | ||||||
|  |     description: string; | ||||||
|  |     targetAges: number[]; | ||||||
|  |     contentType: string; | ||||||
|  |     contentLocation?: string; | ||||||
|  |     skosConcepts?: string[]; | ||||||
|  |     returnValue?: ReturnValue; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,4 @@ | ||||||
|  | export interface ReturnValue { | ||||||
|  |     callback_url: string; | ||||||
|  |     callback_schema: Record<string, unknown>; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  | 
 | ||||||
|  | export interface LearningPathDTO { | ||||||
|  |     language: string; | ||||||
|  |     hruid: string; | ||||||
|  |     title: string; | ||||||
|  |     description: string; | ||||||
|  |     image?: string; // Image might be missing, so it's optional
 | ||||||
|  |     num_nodes: number; | ||||||
|  |     num_nodes_left: number; | ||||||
|  |     nodes: LearningPathNodeDTO[]; | ||||||
|  |     keywords: string; | ||||||
|  |     target_ages: number[]; | ||||||
|  |     min_age: number; | ||||||
|  |     max_age: number; | ||||||
|  |     __order: number; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,58 @@ | ||||||
|  | import type { Language } from "@/data-objects/language.ts"; | ||||||
|  | import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  | 
 | ||||||
|  | export class LearningPathNode { | ||||||
|  |     public readonly learningobjectHruid: string; | ||||||
|  |     public readonly version: number; | ||||||
|  |     public readonly language: Language; | ||||||
|  |     public readonly transitions: { next: LearningPathNode; default: boolean }[]; | ||||||
|  |     public readonly createdAt: Date; | ||||||
|  |     public readonly updatedAt: Date; | ||||||
|  |     public readonly done: boolean; | ||||||
|  | 
 | ||||||
|  |     constructor(options: { | ||||||
|  |         learningobjectHruid: string; | ||||||
|  |         version: number; | ||||||
|  |         language: Language; | ||||||
|  |         transitions: { next: LearningPathNode; default: boolean }[]; | ||||||
|  |         createdAt: Date; | ||||||
|  |         updatedAt: Date; | ||||||
|  |         done?: boolean; | ||||||
|  |     }) { | ||||||
|  |         this.learningobjectHruid = options.learningobjectHruid; | ||||||
|  |         this.version = options.version; | ||||||
|  |         this.language = options.language; | ||||||
|  |         this.transitions = options.transitions; | ||||||
|  |         this.createdAt = options.createdAt; | ||||||
|  |         this.updatedAt = options.updatedAt; | ||||||
|  |         this.done = options.done || false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     static fromDTOAndOtherNodes(dto: LearningPathNodeDTO, otherNodes: LearningPathNodeDTO[]): LearningPathNode { | ||||||
|  |         return new LearningPathNode({ | ||||||
|  |             learningobjectHruid: dto.learningobject_hruid, | ||||||
|  |             version: dto.version, | ||||||
|  |             language: dto.language, | ||||||
|  |             transitions: dto.transitions | ||||||
|  |                 .map((transDto) => { | ||||||
|  |                     const nextNodeDto = otherNodes.find( | ||||||
|  |                         (it) => | ||||||
|  |                             it.learningobject_hruid === transDto.next.hruid && | ||||||
|  |                             it.language === transDto.next.language && | ||||||
|  |                             it.version === transDto.next.version, | ||||||
|  |                     ); | ||||||
|  |                     if (nextNodeDto) { | ||||||
|  |                         return { | ||||||
|  |                             next: LearningPathNode.fromDTOAndOtherNodes(nextNodeDto, otherNodes), | ||||||
|  |                             default: transDto.default, | ||||||
|  |                         }; | ||||||
|  |                     } | ||||||
|  |                     return undefined; | ||||||
|  |                 }) | ||||||
|  |                 .filter((it) => it !== undefined), | ||||||
|  |             createdAt: new Date(dto.created_at), | ||||||
|  |             updatedAt: new Date(dto.updatedAt), | ||||||
|  |             done: dto.done, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										97
									
								
								frontend/src/data-objects/learning-paths/learning-path.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								frontend/src/data-objects/learning-paths/learning-path.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,97 @@ | ||||||
|  | import type { Language } from "@/data-objects/language.ts"; | ||||||
|  | import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; | ||||||
|  | import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; | ||||||
|  | 
 | ||||||
|  | export interface LearningPathNodeDTO { | ||||||
|  |     _id: string; | ||||||
|  |     learningobject_hruid: string; | ||||||
|  |     version: number; | ||||||
|  |     language: Language; | ||||||
|  |     start_node?: boolean; | ||||||
|  |     transitions: LearningPathTransitionDTO[]; | ||||||
|  |     created_at: string; | ||||||
|  |     updatedAt: string; | ||||||
|  |     done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface LearningPathTransitionDTO { | ||||||
|  |     default: boolean; | ||||||
|  |     _id: string; | ||||||
|  |     next: { | ||||||
|  |         _id: string; | ||||||
|  |         hruid: string; | ||||||
|  |         version: number; | ||||||
|  |         language: string; | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export class LearningPath { | ||||||
|  |     public readonly language: string; | ||||||
|  |     public readonly hruid: string; | ||||||
|  |     public readonly title: string; | ||||||
|  |     public readonly description: string; | ||||||
|  |     public readonly amountOfNodes: number; | ||||||
|  |     public readonly amountOfNodesLeft: number; | ||||||
|  |     public readonly keywords: string[]; | ||||||
|  |     public readonly targetAges: { min: number; max: number }; | ||||||
|  |     public readonly startNode: LearningPathNode; | ||||||
|  |     public readonly image?: string; // Image might be missing, so it's optional
 | ||||||
|  | 
 | ||||||
|  |     constructor(options: { | ||||||
|  |         language: string; | ||||||
|  |         hruid: string; | ||||||
|  |         title: string; | ||||||
|  |         description: string; | ||||||
|  |         amountOfNodes: number; | ||||||
|  |         amountOfNodesLeft: number; | ||||||
|  |         keywords: string[]; | ||||||
|  |         targetAges: { min: number; max: number }; | ||||||
|  |         startNode: LearningPathNode; | ||||||
|  |         image?: string; // Image might be missing, so it's optional
 | ||||||
|  |     }) { | ||||||
|  |         this.language = options.language; | ||||||
|  |         this.hruid = options.hruid; | ||||||
|  |         this.title = options.title; | ||||||
|  |         this.description = options.description; | ||||||
|  |         this.amountOfNodes = options.amountOfNodes; | ||||||
|  |         this.amountOfNodesLeft = options.amountOfNodesLeft; | ||||||
|  |         this.keywords = options.keywords; | ||||||
|  |         this.targetAges = options.targetAges; | ||||||
|  |         this.startNode = options.startNode; | ||||||
|  |         this.image = options.image; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public get nodesAsList(): LearningPathNode[] { | ||||||
|  |         const list: LearningPathNode[] = []; | ||||||
|  |         let currentNode = this.startNode; | ||||||
|  |         while (currentNode) { | ||||||
|  |             list.push(currentNode); | ||||||
|  |             currentNode = currentNode.transitions.find((it) => it.default)?.next || currentNode.transitions[0]?.next; | ||||||
|  |         } | ||||||
|  |         return list; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     static fromDTO(dto: LearningPathDTO): LearningPath { | ||||||
|  |         return new LearningPath({ | ||||||
|  |             language: dto.language, | ||||||
|  |             hruid: dto.hruid, | ||||||
|  |             title: dto.title, | ||||||
|  |             description: dto.description, | ||||||
|  |             amountOfNodes: dto.num_nodes, | ||||||
|  |             amountOfNodesLeft: dto.num_nodes_left, | ||||||
|  |             keywords: dto.keywords.split(" "), | ||||||
|  |             targetAges: { min: dto.min_age, max: dto.max_age }, | ||||||
|  |             startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes), | ||||||
|  |             image: dto.image, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO { | ||||||
|  |         const startNodeDtos = dto.nodes.filter((it) => it.start_node === true); | ||||||
|  |         if (startNodeDtos.length < 1) { | ||||||
|  |             // The learning path has no starting node -> use the first node.
 | ||||||
|  |             return dto.nodes[0]; | ||||||
|  |         } // The learning path has 1 or more starting nodes -> use the first start node.
 | ||||||
|  |         return startNodeDtos[0]; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										8
									
								
								frontend/src/data-objects/theme.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/src/data-objects/theme.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | ||||||
|  | export interface Theme { | ||||||
|  |     key: string; | ||||||
|  |     title: string; | ||||||
|  |     description: string; | ||||||
|  | 
 | ||||||
|  |     // URL of the image
 | ||||||
|  |     image: string; | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								frontend/src/exception/http-error-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/exception/http-error-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | import type { AxiosResponse } from "axios"; | ||||||
|  | 
 | ||||||
|  | export class HttpErrorResponseException extends Error { | ||||||
|  |     public statusCode: number; | ||||||
|  |     constructor(public response: AxiosResponse<unknown, unknown>) { | ||||||
|  |         super((response.data as { message: string })?.message || JSON.stringify(response.data)); | ||||||
|  |         this.statusCode = response.status; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										5
									
								
								frontend/src/exception/invalid-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/exception/invalid-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | export class InvalidResponseException extends Error { | ||||||
|  |     constructor(message: string) { | ||||||
|  |         super(message); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										5
									
								
								frontend/src/exception/not-found-exception.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/exception/not-found-exception.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | export class NotFoundException extends Error { | ||||||
|  |     constructor(message: string) { | ||||||
|  |         super(message); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -39,5 +39,17 @@ | ||||||
|         "high-school": "16-18 jahre alt", |         "high-school": "16-18 jahre alt", | ||||||
|         "older": "18 und älter" |         "older": "18 und älter" | ||||||
|     }, |     }, | ||||||
|     "read-more": "Mehr lesen" |     "read-more": "Mehr lesen", | ||||||
|  |     "error_title": "Fehler", | ||||||
|  |     "previous": "Zurück", | ||||||
|  |     "next": "Weiter", | ||||||
|  |     "search": "Suchen...", | ||||||
|  |     "yearsAge": "Jahre", | ||||||
|  |     "enterSearchTerm": "Lernpfade suchen", | ||||||
|  |     "enterSearchTermDescription": "Bitte geben Sie einen Suchbegriff ein.", | ||||||
|  |     "noLearningPathsFound": "Nichts gefunden!", | ||||||
|  |     "noLearningPathsFoundDescription": "Es gibt keine Lernpfade, die zu Ihrem Suchbegriff passen.", | ||||||
|  |     "legendNotCompletedYet": "Noch nicht fertig", | ||||||
|  |     "legendCompleted": "Fertig", | ||||||
|  |     "legendTeacherExclusive": "Information für Lehrkräfte" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,10 +2,22 @@ | ||||||
|     "welcome": "Welcome", |     "welcome": "Welcome", | ||||||
|     "student": "student", |     "student": "student", | ||||||
|     "teacher": "teacher", |     "teacher": "teacher", | ||||||
|     "assignments": "Assignments", |     "assignments": "assignments", | ||||||
|     "classes": "Classes", |     "classes": "classes", | ||||||
|     "discussions": "Discussions", |     "discussions": "discussions", | ||||||
|     "logout": "log out", |     "logout": "log out", | ||||||
|  |     "error_title": "Error", | ||||||
|  |     "previous": "Previous", | ||||||
|  |     "next": "Next", | ||||||
|  |     "search": "Search...", | ||||||
|  |     "yearsAge": "years", | ||||||
|  |     "enterSearchTerm": "Search learning paths", | ||||||
|  |     "enterSearchTermDescription": "Please enter a search term.", | ||||||
|  |     "noLearningPathsFound": "Nothing found!", | ||||||
|  |     "noLearningPathsFoundDescription": "There are no learning paths matching your search term.", | ||||||
|  |     "legendNotCompletedYet": "Not completed yet", | ||||||
|  |     "legendCompleted": "Completed", | ||||||
|  |     "legendTeacherExclusive": "Information for teachers", | ||||||
|     "cancel": "cancel", |     "cancel": "cancel", | ||||||
|     "logoutVerification": "Are you sure you want to log out?", |     "logoutVerification": "Are you sure you want to log out?", | ||||||
|     "homeTitle": "Our strengths", |     "homeTitle": "Our strengths", | ||||||
|  |  | ||||||
|  | @ -1,5 +1,17 @@ | ||||||
| { | { | ||||||
|     "welcome": "Bienvenue", |     "welcome": "Bienvenue", | ||||||
|  |     "error_title": "Erreur", | ||||||
|  |     "previous": "Précédente", | ||||||
|  |     "next": "Suivante", | ||||||
|  |     "search": "Réchercher...", | ||||||
|  |     "yearsAge": "ans", | ||||||
|  |     "enterSearchTerm": "Rechercher des parcours d'apprentissage", | ||||||
|  |     "enterSearchTermDescription": "Saisissez un terme de recherche pour commencer.", | ||||||
|  |     "noLearningPathsFound": "Rien trouvé !", | ||||||
|  |     "noLearningPathsFoundDescription": "Aucun parcours d'apprentissage ne correspond à votre recherche.", | ||||||
|  |     "legendNotCompletedYet": "Pas encore achevé", | ||||||
|  |     "legendCompleted": "Achevé", | ||||||
|  |     "legendTeacherExclusive": "Informations pour les enseignants", | ||||||
|     "student": "élève", |     "student": "élève", | ||||||
|     "teacher": "enseignant", |     "teacher": "enseignant", | ||||||
|     "assignments": "Travails", |     "assignments": "Travails", | ||||||
|  |  | ||||||
|  | @ -2,10 +2,22 @@ | ||||||
|     "welcome": "Welkom", |     "welcome": "Welkom", | ||||||
|     "student": "leerling", |     "student": "leerling", | ||||||
|     "teacher": "leerkracht", |     "teacher": "leerkracht", | ||||||
|     "assignments": "Opdrachten", |     "assignments": "opdrachten", | ||||||
|     "classes": "Klassen", |     "classes": "klassen", | ||||||
|     "discussions": "Discussies", |     "discussions": "discussies", | ||||||
|     "logout": "log uit", |     "logout": "log uit", | ||||||
|  |     "error_title": "Fout", | ||||||
|  |     "previous": "Vorige", | ||||||
|  |     "next": "Volgende", | ||||||
|  |     "search": "Zoeken...", | ||||||
|  |     "yearsAge": "jaar", | ||||||
|  |     "enterSearchTerm": "Zoek naar leerpaden", | ||||||
|  |     "enterSearchTermDescription": "Gelieve een zoekterm in te voeren.", | ||||||
|  |     "noLearningPathsFound": "Niets gevonden!", | ||||||
|  |     "noLearningPathsFoundDescription": "Er zijn geen leerpaden die overeenkomen met je zoekterm.", | ||||||
|  |     "legendNotCompletedYet": "Nog niet afgewerkt", | ||||||
|  |     "legendCompleted": "Afgewerkt", | ||||||
|  |     "legendTeacherExclusive": "Informatie voor leerkrachten", | ||||||
|     "cancel": "annuleren", |     "cancel": "annuleren", | ||||||
|     "logoutVerification": "Bent u zeker dat u wilt uitloggen?", |     "logoutVerification": "Bent u zeker dat u wilt uitloggen?", | ||||||
|     "homeTitle": "Onze sterke punten", |     "homeTitle": "Onze sterke punten", | ||||||
|  |  | ||||||
|  | @ -10,6 +10,7 @@ import i18n from "./i18n/i18n.ts"; | ||||||
| // Components
 | // Components
 | ||||||
| import App from "./App.vue"; | import App from "./App.vue"; | ||||||
| import router from "./router"; | import router from "./router"; | ||||||
|  | import { aliases, mdi } from "vuetify/iconsets/mdi"; | ||||||
| import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; | import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; | ||||||
| 
 | 
 | ||||||
| const app = createApp(App); | const app = createApp(App); | ||||||
|  | @ -24,6 +25,13 @@ document.head.appendChild(link); | ||||||
| const vuetify = createVuetify({ | const vuetify = createVuetify({ | ||||||
|     components, |     components, | ||||||
|     directives, |     directives, | ||||||
|  |     icons: { | ||||||
|  |         defaultSet: "mdi", | ||||||
|  |         aliases, | ||||||
|  |         sets: { | ||||||
|  |             mdi, | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const queryClient = new QueryClient({ | const queryClient = new QueryClient({ | ||||||
|  |  | ||||||
							
								
								
									
										57
									
								
								frontend/src/queries/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								frontend/src/queries/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | ||||||
|  | import { type MaybeRefOrGetter, toValue } from "vue"; | ||||||
|  | import type { Language } from "@/data-objects/language.ts"; | ||||||
|  | import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||||
|  | import { getLearningObjectController } from "@/controllers/controllers.ts"; | ||||||
|  | import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; | ||||||
|  | import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  | 
 | ||||||
|  | const LEARNING_OBJECT_KEY = "learningObject"; | ||||||
|  | const learningObjectController = getLearningObjectController(); | ||||||
|  | 
 | ||||||
|  | export function useLearningObjectMetadataQuery( | ||||||
|  |     hruid: MaybeRefOrGetter<string>, | ||||||
|  |     language: MaybeRefOrGetter<Language>, | ||||||
|  |     version: MaybeRefOrGetter<number>, | ||||||
|  | ): UseQueryReturnType<LearningObject, Error> { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: [LEARNING_OBJECT_KEY, "metadata", hruid, language, version], | ||||||
|  |         queryFn: async () => { | ||||||
|  |             const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; | ||||||
|  |             return learningObjectController.getMetadata(hruidVal, languageVal, versionVal); | ||||||
|  |         }, | ||||||
|  |         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function useLearningObjectHTMLQuery( | ||||||
|  |     hruid: MaybeRefOrGetter<string>, | ||||||
|  |     language: MaybeRefOrGetter<Language>, | ||||||
|  |     version: MaybeRefOrGetter<number>, | ||||||
|  | ): UseQueryReturnType<Document, Error> { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version], | ||||||
|  |         queryFn: async () => { | ||||||
|  |             const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; | ||||||
|  |             return learningObjectController.getHTML(hruidVal, languageVal, versionVal); | ||||||
|  |         }, | ||||||
|  |         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function useLearningObjectListForPathQuery( | ||||||
|  |     learningPath: MaybeRefOrGetter<LearningPath | undefined>, | ||||||
|  | ): UseQueryReturnType<LearningObject[], Error> { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: [LEARNING_OBJECT_KEY, "onPath", learningPath], | ||||||
|  |         queryFn: async () => { | ||||||
|  |             const learningObjects: Promise<LearningObject>[] = []; | ||||||
|  |             for (const node of toValue(learningPath)!.nodesAsList) { | ||||||
|  |                 learningObjects.push( | ||||||
|  |                     learningObjectController.getMetadata(node.learningobjectHruid, node.language, node.version), | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |             return Promise.all(learningObjects); | ||||||
|  |         }, | ||||||
|  |         enabled: () => Boolean(toValue(learningPath)), | ||||||
|  |     }); | ||||||
|  | } | ||||||
							
								
								
									
										46
									
								
								frontend/src/queries/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/queries/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | ||||||
|  | import { type MaybeRefOrGetter, toValue } from "vue"; | ||||||
|  | import type { Language } from "@/data-objects/language.ts"; | ||||||
|  | import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||||
|  | import { getLearningPathController } from "@/controllers/controllers"; | ||||||
|  | import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  | 
 | ||||||
|  | const LEARNING_PATH_KEY = "learningPath"; | ||||||
|  | const learningPathController = getLearningPathController(); | ||||||
|  | 
 | ||||||
|  | export function useGetLearningPathQuery( | ||||||
|  |     hruid: MaybeRefOrGetter<string>, | ||||||
|  |     language: MaybeRefOrGetter<Language>, | ||||||
|  |     options?: MaybeRefOrGetter<{ forGroup?: string; forStudent?: string }>, | ||||||
|  | ): UseQueryReturnType<LearningPath, Error> { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: [LEARNING_PATH_KEY, "get", hruid, language, options], | ||||||
|  |         queryFn: async () => { | ||||||
|  |             const [hruidVal, languageVal, optionsVal] = [toValue(hruid), toValue(language), toValue(options)]; | ||||||
|  |             return learningPathController.getBy(hruidVal, languageVal, optionsVal); | ||||||
|  |         }, | ||||||
|  |         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)), | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function useGetAllLearningPathsByThemeQuery( | ||||||
|  |     theme: MaybeRefOrGetter<string>, | ||||||
|  | ): UseQueryReturnType<LearningPath[], Error> { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme], | ||||||
|  |         queryFn: async () => learningPathController.getAllByTheme(toValue(theme)), | ||||||
|  |         enabled: () => Boolean(toValue(theme)), | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function useSearchLearningPathQuery( | ||||||
|  |     query: MaybeRefOrGetter<string | undefined>, | ||||||
|  | ): UseQueryReturnType<LearningPath[], Error> { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: [LEARNING_PATH_KEY, "search", query], | ||||||
|  |         queryFn: async () => { | ||||||
|  |             const queryVal = toValue(query)!; | ||||||
|  |             return learningPathController.search(queryVal); | ||||||
|  |         }, | ||||||
|  |         enabled: () => Boolean(toValue(query)), | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | @ -11,8 +11,11 @@ import UserDiscussions from "@/views/discussions/UserDiscussions.vue"; | ||||||
| import UserClasses from "@/views/classes/UserClasses.vue"; | import UserClasses from "@/views/classes/UserClasses.vue"; | ||||||
| import UserAssignments from "@/views/classes/UserAssignments.vue"; | import UserAssignments from "@/views/classes/UserAssignments.vue"; | ||||||
| import authState from "@/services/auth/auth-service.ts"; | import authState from "@/services/auth/auth-service.ts"; | ||||||
|  | import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; | ||||||
|  | import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue"; | ||||||
| import UserHomePage from "@/views/homepage/UserHomePage.vue"; | import UserHomePage from "@/views/homepage/UserHomePage.vue"; | ||||||
| import SingleTheme from "@/views/SingleTheme.vue"; | import SingleTheme from "@/views/SingleTheme.vue"; | ||||||
|  | import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; | ||||||
| 
 | 
 | ||||||
| const router = createRouter({ | const router = createRouter({ | ||||||
|     history: createWebHistory(import.meta.env.BASE_URL), |     history: createWebHistory(import.meta.env.BASE_URL), | ||||||
|  | @ -63,9 +66,10 @@ const router = createRouter({ | ||||||
|         }, |         }, | ||||||
| 
 | 
 | ||||||
|         { |         { | ||||||
|             path: "/theme/:id", |             path: "/theme/:theme", | ||||||
|             name: "Theme", |             name: "Theme", | ||||||
|             component: SingleTheme, |             component: SingleTheme, | ||||||
|  |             props: true, | ||||||
|             meta: { requiresAuth: true }, |             meta: { requiresAuth: true }, | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|  | @ -104,6 +108,31 @@ const router = createRouter({ | ||||||
|             component: SingleDiscussion, |             component: SingleDiscussion, | ||||||
|             meta: { requiresAuth: true }, |             meta: { requiresAuth: true }, | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |             path: "/learningPath", | ||||||
|  |             children: [ | ||||||
|  |                 { | ||||||
|  |                     path: "search", | ||||||
|  |                     name: "LearningPathSearchPage", | ||||||
|  |                     component: LearningPathSearchPage, | ||||||
|  |                     meta: { requiresAuth: true }, | ||||||
|  |                 }, | ||||||
|  |                 { | ||||||
|  |                     path: ":hruid/:language/:learningObjectHruid", | ||||||
|  |                     name: "LearningPath", | ||||||
|  |                     component: LearningPathPage, | ||||||
|  |                     props: true, | ||||||
|  |                     meta: { requiresAuth: true }, | ||||||
|  |                 }, | ||||||
|  |             ], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             path: "/learningObject/:hruid/:language/:version/raw", | ||||||
|  |             name: "LearningObjectView", | ||||||
|  |             component: LearningObjectView, | ||||||
|  |             props: true, | ||||||
|  |             meta: { requiresAuth: true }, | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|             path: "/:catchAll(.*)", |             path: "/:catchAll(.*)", | ||||||
|             name: "NotFound", |             name: "NotFound", | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import apiClient from "@/services/api-client.ts"; | import apiClient from "@/services/api-client/api-client.ts"; | ||||||
| import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts"; | import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts"; | ||||||
| import type { UserManagerSettings } from "oidc-client-ts"; | import type { UserManagerSettings } from "oidc-client-ts"; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -8,7 +8,7 @@ import { User, UserManager } from "oidc-client-ts"; | ||||||
| import { AUTH_CONFIG_ENDPOINT, loadAuthConfig } from "@/services/auth/auth-config-loader.ts"; | import { AUTH_CONFIG_ENDPOINT, loadAuthConfig } from "@/services/auth/auth-config-loader.ts"; | ||||||
| import authStorage from "./auth-storage.ts"; | import authStorage from "./auth-storage.ts"; | ||||||
| import { loginRoute } from "@/config.ts"; | import { loginRoute } from "@/config.ts"; | ||||||
| import apiClient from "@/services/api-client.ts"; | import apiClient from "@/services/api-client/api-client.ts"; | ||||||
| import router from "@/router"; | import router from "@/router"; | ||||||
| import type { AxiosError } from "axios"; | import type { AxiosError } from "axios"; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								frontend/src/utils/response-assertions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								frontend/src/utils/response-assertions.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | ||||||
|  | import { NotFoundException } from "@/exception/not-found-exception.ts"; | ||||||
|  | import { InvalidResponseException } from "@/exception/invalid-response-exception.ts"; | ||||||
|  | 
 | ||||||
|  | export function single<T>(list: T[]): T { | ||||||
|  |     if (list.length === 1) { | ||||||
|  |         return list[0]; | ||||||
|  |     } else if (list.length === 0) { | ||||||
|  |         throw new NotFoundException("Expected list with exactly one element, but got an empty list."); | ||||||
|  |     } else { | ||||||
|  |         throw new InvalidResponseException( | ||||||
|  |             `Expected list with exactly one element, but got one with ${list.length} elements.`, | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,22 +1,25 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import { useRouter } from "vue-router"; |     import { useRouter } from "vue-router"; | ||||||
|     import { onMounted } from "vue"; |     import { onMounted, ref, type Ref } from "vue"; | ||||||
|     import auth from "../services/auth/auth-service.ts"; |     import auth from "../services/auth/auth-service.ts"; | ||||||
| 
 | 
 | ||||||
|     const router = useRouter(); |     const router = useRouter(); | ||||||
| 
 | 
 | ||||||
|  |     const errorMessage: Ref<string | null> = ref(null); | ||||||
|  | 
 | ||||||
|     onMounted(async () => { |     onMounted(async () => { | ||||||
|         try { |         try { | ||||||
|             await auth.handleLoginCallback(); |             await auth.handleLoginCallback(); | ||||||
|             await router.replace("/user"); // Redirect to theme page |             await router.replace("/user"); // Redirect to theme page | ||||||
|         } catch (_error) { |         } catch (error) { | ||||||
|             // FIXME console.error("OIDC callback error:", error); |             errorMessage.value = `OIDC callback error: ${error}`; | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <p>Logging you in...</p> |     <p v-if="!errorMessage">Logging you in...</p> | ||||||
|  |     <p v-else>{{ errorMessage }}</p> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped></style> | <style scoped></style> | ||||||
|  |  | ||||||
|  | @ -1,7 +1,68 @@ | ||||||
| <script setup lang="ts"></script> | <script setup lang="ts"> | ||||||
|  |     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  |     import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; | ||||||
|  |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|  |     import { useGetAllLearningPathsByThemeQuery } from "@/queries/learning-paths.ts"; | ||||||
|  |     import { computed, ref } from "vue"; | ||||||
|  |     import { useI18n } from "vue-i18n"; | ||||||
|  |     import { useThemeQuery } from "@/queries/themes.ts"; | ||||||
|  | 
 | ||||||
|  |     const props = defineProps<{ theme: string }>(); | ||||||
|  | 
 | ||||||
|  |     const { locale } = useI18n(); | ||||||
|  |     const language = computed(() => locale.value); | ||||||
|  | 
 | ||||||
|  |     const themeQueryResult = useThemeQuery(language); | ||||||
|  | 
 | ||||||
|  |     const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme)); | ||||||
|  | 
 | ||||||
|  |     const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeQuery(() => props.theme); | ||||||
|  | 
 | ||||||
|  |     const { t } = useI18n(); | ||||||
|  |     const searchFilter = ref(""); | ||||||
|  | 
 | ||||||
|  |     function filterLearningPaths(learningPaths: LearningPath[]): LearningPath[] { | ||||||
|  |         return learningPaths.filter( | ||||||
|  |             (it) => | ||||||
|  |                 it.title.toLowerCase().includes(searchFilter.value.toLowerCase()) || | ||||||
|  |                 it.description.toLowerCase().includes(searchFilter.value.toLowerCase()), | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <main></main> |     <div class="container"> | ||||||
|  |         <using-query-result :query-result="themeQueryResult"> | ||||||
|  |             <h1>{{ currentThemeInfo!!.title }}</h1> | ||||||
|  |             <p>{{ currentThemeInfo!!.description }}</p> | ||||||
|  |             <div class="search-field-container"> | ||||||
|  |                 <v-text-field | ||||||
|  |                     class="search-field" | ||||||
|  |                     :label="t('search')" | ||||||
|  |                     append-inner-icon="mdi-magnify" | ||||||
|  |                     v-model="searchFilter" | ||||||
|  |                 ></v-text-field> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <using-query-result | ||||||
|  |                 :query-result="learningPathsForThemeQueryResult" | ||||||
|  |                 v-slot="{ data }: { data: LearningPath[] }" | ||||||
|  |             > | ||||||
|  |                 <learning-paths-grid :learning-paths="filterLearningPaths(data)"></learning-paths-grid> | ||||||
|  |             </using-query-result> | ||||||
|  |         </using-query-result> | ||||||
|  |     </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped></style> | <style scoped> | ||||||
|  |     .search-field-container { | ||||||
|  |         display: block; | ||||||
|  |         margin: 20px; | ||||||
|  |     } | ||||||
|  |     .search-field { | ||||||
|  |         max-width: 300px; | ||||||
|  |     } | ||||||
|  |     .container { | ||||||
|  |         padding: 20px; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  |  | ||||||
							
								
								
									
										51
									
								
								frontend/src/views/learning-paths/LearningObjectView.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/src/views/learning-paths/LearningObjectView.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  |     import { Language } from "@/data-objects/language.ts"; | ||||||
|  |     import type { UseQueryReturnType } from "@tanstack/vue-query"; | ||||||
|  |     import { useLearningObjectHTMLQuery } from "@/queries/learning-objects.ts"; | ||||||
|  |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|  | 
 | ||||||
|  |     const props = defineProps<{ hruid: string; language: Language; version: number }>(); | ||||||
|  | 
 | ||||||
|  |     const learningObjectHtmlQueryResult: UseQueryReturnType<Document, Error> = useLearningObjectHTMLQuery( | ||||||
|  |         () => props.hruid, | ||||||
|  |         () => props.language, | ||||||
|  |         () => props.version, | ||||||
|  |     ); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <using-query-result | ||||||
|  |         :query-result="learningObjectHtmlQueryResult as UseQueryReturnType<Document, Error>" | ||||||
|  |         v-slot="learningPathHtml: { data: Document }" | ||||||
|  |     > | ||||||
|  |         <div | ||||||
|  |             class="learning-object-container" | ||||||
|  |             v-html="learningPathHtml.data.body.innerHTML" | ||||||
|  |         ></div> | ||||||
|  |     </using-query-result> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  |     .learning-object-container { | ||||||
|  |         padding: 20px; | ||||||
|  |     } | ||||||
|  |     :deep(hr) { | ||||||
|  |         margin-top: 10px; | ||||||
|  |         margin-bottom: 10px; | ||||||
|  |     } | ||||||
|  |     :deep(li) { | ||||||
|  |         margin-left: 30px; | ||||||
|  |         margin-top: 5px; | ||||||
|  |         margin-bottom: 5px; | ||||||
|  |     } | ||||||
|  |     :deep(img) { | ||||||
|  |         max-width: 80%; | ||||||
|  |     } | ||||||
|  |     :deep(h2), | ||||||
|  |     :deep(h3), | ||||||
|  |     :deep(h4), | ||||||
|  |     :deep(h5), | ||||||
|  |     :deep(h6) { | ||||||
|  |         margin-top: 10px; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
							
								
								
									
										229
									
								
								frontend/src/views/learning-paths/LearningPathPage.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								frontend/src/views/learning-paths/LearningPathPage.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,229 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  |     import { Language } from "@/data-objects/language.ts"; | ||||||
|  |     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  |     import { computed, type ComputedRef, ref } from "vue"; | ||||||
|  |     import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; | ||||||
|  |     import { useRoute } from "vue-router"; | ||||||
|  |     import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; | ||||||
|  |     import { useI18n } from "vue-i18n"; | ||||||
|  |     import LearningPathSearchField from "@/components/LearningPathSearchField.vue"; | ||||||
|  |     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||||
|  |     import { useLearningObjectListForPathQuery } from "@/queries/learning-objects.ts"; | ||||||
|  |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|  |     import authService from "@/services/auth/auth-service.ts"; | ||||||
|  |     import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; | ||||||
|  | 
 | ||||||
|  |     const route = useRoute(); | ||||||
|  |     const { t } = useI18n(); | ||||||
|  | 
 | ||||||
|  |     const props = defineProps<{ hruid: string; language: Language; learningObjectHruid?: string }>(); | ||||||
|  | 
 | ||||||
|  |     interface Personalization { | ||||||
|  |         forStudent?: string; | ||||||
|  |         forGroup?: string; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const personalization = computed(() => { | ||||||
|  |         if (route.query.forStudent || route.query.forGroup) { | ||||||
|  |             return { | ||||||
|  |                 forStudent: route.query.forStudent, | ||||||
|  |                 forGroup: route.query.forGroup, | ||||||
|  |             } as Personalization; | ||||||
|  |         } | ||||||
|  |         return { | ||||||
|  |             forStudent: authService.authState.user?.profile?.preferred_username, | ||||||
|  |         } as Personalization; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, personalization); | ||||||
|  | 
 | ||||||
|  |     const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data); | ||||||
|  | 
 | ||||||
|  |     const nodesList: ComputedRef<LearningPathNode[] | null> = computed( | ||||||
|  |         () => learningPathQueryResult.data.value?.nodesAsList ?? null, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     const currentNode = computed(() => { | ||||||
|  |         const currentHruid = props.learningObjectHruid; | ||||||
|  |         return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const nextNode = computed(() => { | ||||||
|  |         if (!currentNode.value || !nodesList.value) return undefined; | ||||||
|  |         const currentIndex = nodesList.value?.indexOf(currentNode.value); | ||||||
|  |         return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex + 1] : undefined; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const previousNode = computed(() => { | ||||||
|  |         if (!currentNode.value || !nodesList.value) return undefined; | ||||||
|  |         const currentIndex = nodesList.value?.indexOf(currentNode.value); | ||||||
|  |         return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex - 1] : undefined; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const navigationDrawerShown = ref(true); | ||||||
|  | 
 | ||||||
|  |     function isLearningObjectCompleted(learningObject: LearningObject): boolean { | ||||||
|  |         if (learningObjectListQueryResult.isSuccess) { | ||||||
|  |             return ( | ||||||
|  |                 learningPathQueryResult.data.value?.nodesAsList?.find( | ||||||
|  |                     (it) => | ||||||
|  |                         it.learningobjectHruid === learningObject.key && | ||||||
|  |                         it.version === learningObject.version && | ||||||
|  |                         it.language === learningObject.language, | ||||||
|  |                 )?.done ?? false | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     type NavItemState = "teacherExclusive" | "completed" | "notCompleted"; | ||||||
|  | 
 | ||||||
|  |     const ICONS: Record<NavItemState, string> = { | ||||||
|  |         teacherExclusive: "mdi-information", | ||||||
|  |         completed: "mdi-checkbox-marked-circle-outline", | ||||||
|  |         notCompleted: "mdi-checkbox-blank-circle-outline", | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const COLORS: Record<NavItemState, string | undefined> = { | ||||||
|  |         teacherExclusive: "info", | ||||||
|  |         completed: "success", | ||||||
|  |         notCompleted: undefined, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     function getNavItemState(learningObject: LearningObject): NavItemState { | ||||||
|  |         if (learningObject.teacherExclusive) { | ||||||
|  |             return "teacherExclusive"; | ||||||
|  |         } else if (isLearningObjectCompleted(learningObject)) { | ||||||
|  |             return "completed"; | ||||||
|  |         } | ||||||
|  |         return "notCompleted"; | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <using-query-result | ||||||
|  |         :query-result="learningPathQueryResult" | ||||||
|  |         v-slot="learningPath: { data: LearningPath }" | ||||||
|  |     > | ||||||
|  |         <v-navigation-drawer | ||||||
|  |             v-model="navigationDrawerShown" | ||||||
|  |             :width="350" | ||||||
|  |         > | ||||||
|  |             <v-list-item> | ||||||
|  |                 <template v-slot:title> | ||||||
|  |                     <div class="learning-path-title">{{ learningPath.data.title }}</div> | ||||||
|  |                 </template> | ||||||
|  |                 <template v-slot:subtitle> | ||||||
|  |                     <div>{{ learningPath.data.description }}</div> | ||||||
|  |                 </template> | ||||||
|  |             </v-list-item> | ||||||
|  |             <v-list-item> | ||||||
|  |                 <template v-slot:subtitle> | ||||||
|  |                     <p> | ||||||
|  |                         <v-icon | ||||||
|  |                             :color="COLORS.notCompleted" | ||||||
|  |                             :icon="ICONS.notCompleted" | ||||||
|  |                         ></v-icon> | ||||||
|  |                         {{ t("legendNotCompletedYet") }} | ||||||
|  |                     </p> | ||||||
|  |                     <p> | ||||||
|  |                         <v-icon | ||||||
|  |                             :color="COLORS.completed" | ||||||
|  |                             :icon="ICONS.completed" | ||||||
|  |                         ></v-icon> | ||||||
|  |                         {{ t("legendCompleted") }} | ||||||
|  |                     </p> | ||||||
|  |                     <p> | ||||||
|  |                         <v-icon | ||||||
|  |                             :color="COLORS.teacherExclusive" | ||||||
|  |                             :icon="ICONS.teacherExclusive" | ||||||
|  |                         ></v-icon> | ||||||
|  |                         {{ t("legendTeacherExclusive") }} | ||||||
|  |                     </p> | ||||||
|  |                 </template> | ||||||
|  |             </v-list-item> | ||||||
|  |             <v-divider></v-divider> | ||||||
|  |             <div v-if="props.learningObjectHruid"> | ||||||
|  |                 <using-query-result | ||||||
|  |                     :query-result="learningObjectListQueryResult" | ||||||
|  |                     v-slot="learningObjects: { data: LearningObject[] }" | ||||||
|  |                 > | ||||||
|  |                     <template v-for="node in learningObjects.data"> | ||||||
|  |                         <v-list-item | ||||||
|  |                             link | ||||||
|  |                             :to="{ path: node.key, query: route.query }" | ||||||
|  |                             :title="node.title" | ||||||
|  |                             :active="node.key === props.learningObjectHruid" | ||||||
|  |                             :key="node.key" | ||||||
|  |                             v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'" | ||||||
|  |                         > | ||||||
|  |                             <template v-slot:prepend> | ||||||
|  |                                 <v-icon | ||||||
|  |                                     :color="COLORS[getNavItemState(node)]" | ||||||
|  |                                     :icon="ICONS[getNavItemState(node)]" | ||||||
|  |                                 ></v-icon> | ||||||
|  |                             </template> | ||||||
|  |                             <template v-slot:append> {{ node.estimatedTime }}' </template> | ||||||
|  |                         </v-list-item> | ||||||
|  |                     </template> | ||||||
|  |                 </using-query-result> | ||||||
|  |             </div> | ||||||
|  |         </v-navigation-drawer> | ||||||
|  |         <div class="control-bar-above-content"> | ||||||
|  |             <v-btn | ||||||
|  |                 :icon="navigationDrawerShown ? 'mdi-menu-open' : 'mdi-menu'" | ||||||
|  |                 class="navigation-drawer-toggle-button" | ||||||
|  |                 variant="plain" | ||||||
|  |                 @click="navigationDrawerShown = !navigationDrawerShown" | ||||||
|  |             ></v-btn> | ||||||
|  |             <div class="search-field-container"> | ||||||
|  |                 <learning-path-search-field></learning-path-search-field> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |         <learning-object-view | ||||||
|  |             :hruid="currentNode.learningobjectHruid" | ||||||
|  |             :language="currentNode.language" | ||||||
|  |             :version="currentNode.version" | ||||||
|  |             v-if="currentNode" | ||||||
|  |         ></learning-object-view> | ||||||
|  |         <div class="navigation-buttons-container"> | ||||||
|  |             <v-btn | ||||||
|  |                 prepend-icon="mdi-chevron-left" | ||||||
|  |                 variant="text" | ||||||
|  |                 :disabled="!previousNode" | ||||||
|  |                 :to="previousNode ? { path: previousNode.learningobjectHruid, query: route.query } : undefined" | ||||||
|  |             > | ||||||
|  |                 {{ t("previous") }} | ||||||
|  |             </v-btn> | ||||||
|  |             <v-btn | ||||||
|  |                 append-icon="mdi-chevron-right" | ||||||
|  |                 variant="text" | ||||||
|  |                 :disabled="!nextNode" | ||||||
|  |                 :to="nextNode ? { path: nextNode.learningobjectHruid, query: route.query } : undefined" | ||||||
|  |             > | ||||||
|  |                 {{ t("next") }} | ||||||
|  |             </v-btn> | ||||||
|  |         </div> | ||||||
|  |     </using-query-result> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  |     .learning-path-title { | ||||||
|  |         white-space: normal; | ||||||
|  |     } | ||||||
|  |     .search-field-container { | ||||||
|  |         min-width: 250px; | ||||||
|  |     } | ||||||
|  |     .control-bar-above-content { | ||||||
|  |         margin-left: 5px; | ||||||
|  |         margin-right: 5px; | ||||||
|  |         margin-bottom: -30px; | ||||||
|  |         display: flex; | ||||||
|  |         justify-content: space-between; | ||||||
|  |     } | ||||||
|  |     .navigation-buttons-container { | ||||||
|  |         padding: 20px; | ||||||
|  |         display: flex; | ||||||
|  |         justify-content: space-between; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
							
								
								
									
										48
									
								
								frontend/src/views/learning-paths/LearningPathSearchPage.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/views/learning-paths/LearningPathSearchPage.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  |     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  |     import { useRoute } from "vue-router"; | ||||||
|  |     import { computed } from "vue"; | ||||||
|  |     import { useI18n } from "vue-i18n"; | ||||||
|  |     import LearningPathSearchField from "@/components/LearningPathSearchField.vue"; | ||||||
|  |     import { useSearchLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||||
|  |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|  |     import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; | ||||||
|  | 
 | ||||||
|  |     const route = useRoute(); | ||||||
|  |     const { t } = useI18n(); | ||||||
|  | 
 | ||||||
|  |     const query = computed(() => route.query.query as string | undefined); | ||||||
|  | 
 | ||||||
|  |     const searchQueryResults = useSearchLearningPathQuery(query); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <div class="search-field-container"> | ||||||
|  |         <learning-path-search-field class="search-field"></learning-path-search-field> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <using-query-result | ||||||
|  |         :query-result="searchQueryResults" | ||||||
|  |         v-slot="{ data }: { data: LearningPath[] }" | ||||||
|  |     > | ||||||
|  |         <learning-paths-grid :learning-paths="data"></learning-paths-grid> | ||||||
|  |     </using-query-result> | ||||||
|  |     <div content="empty-state-container"> | ||||||
|  |         <v-empty-state | ||||||
|  |             v-if="!query" | ||||||
|  |             icon="mdi-magnify" | ||||||
|  |             :title="t('enterSearchTerm')" | ||||||
|  |             :text="t('enterSearchTermDescription')" | ||||||
|  |         ></v-empty-state> | ||||||
|  |     </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  |     .search-field-container { | ||||||
|  |         display: block; | ||||||
|  |         margin: 20px; | ||||||
|  |     } | ||||||
|  |     .search-field { | ||||||
|  |         max-width: 300px; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
		Reference in a new issue
	
	 Gabriellvl
						Gabriellvl