Merge pull request #141 from SELab-2/feat/user-homepage
feat: Homepagina van gebruiker
This commit is contained in:
		
						commit
						bd0a6e8e95
					
				
					 25 changed files with 1293 additions and 142 deletions
				
			
		|  | @ -28,9 +28,9 @@ curricula_page: | ||||||
|         contact: '' |         contact: '' | ||||||
|         teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y |         teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y | ||||||
|     basics_ai: |     basics_ai: | ||||||
|         title: Basisprincipes van AI |         title: Grundlagen der KI | ||||||
|         sub_title: Basisprincipes van AI |         sub_title: Grundlagen der KI | ||||||
|         description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' |         description: 'Dieses Thema bündelt verschiedene Aktivitäten, in denen die grundlegenden Prinzipien der künstlichen Intelligenz (KI) behandelt werden. Die Schüler lernen, was KI ist, wie sie funktioniert und wie sie in verschiedenen Bereichen angewendet werden kann.' | ||||||
|         contact: '' |         contact: '' | ||||||
|     kiks: |     kiks: | ||||||
|         title: KI und Klima |         title: KI und Klima | ||||||
|  |  | ||||||
|  | @ -28,10 +28,11 @@ curricula_page: | ||||||
|         contact: '' |         contact: '' | ||||||
|         teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y |         teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y | ||||||
|     basics_ai: |     basics_ai: | ||||||
|         title: Basisprincipes van AI |         title: Basics of AI | ||||||
|         sub_title: Basisprincipes van AI |         sub_title: Basics of AI | ||||||
|         description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' |         description: 'This theme brings together various activities covering the basic principles of Artificial Intelligence (AI). Students learn what AI is, how it works, and how it can be applied in different domains.' | ||||||
|         contact: '' |         contact: '' | ||||||
|  | 
 | ||||||
|     kiks: |     kiks: | ||||||
|         title: AI and Climate |         title: AI and Climate | ||||||
|         sub_title: KIKS |         sub_title: KIKS | ||||||
|  |  | ||||||
|  | @ -28,9 +28,9 @@ curricula_page: | ||||||
|         contact: '' |         contact: '' | ||||||
|         teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y |         teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y | ||||||
|     basics_ai: |     basics_ai: | ||||||
|         title: Basisprincipes van AI |         title: Principes de base de l’IA | ||||||
|         sub_title: Basisprincipes van AI |         sub_title: Principes de base de l’IA | ||||||
|         description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' |         description: 'Ce thème rassemble différentes activités portant sur les principes fondamentaux de l’intelligence artificielle (IA). Les élèves apprennent ce qu’est l’IA, comment elle fonctionne et comment elle peut être appliquée dans divers domaines.' | ||||||
|         contact: '' |         contact: '' | ||||||
|     kiks: |     kiks: | ||||||
|         title: 'IA et changement climatique' |         title: 'IA et changement climatique' | ||||||
|  |  | ||||||
|  | @ -8,7 +8,7 @@ interface Translations { | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function getThemes(req: Request, res: Response) { | export function getThemesHandler(req: Request, res: Response) { | ||||||
|     const language = (req.query.language as string)?.toLowerCase() || 'nl'; |     const language = (req.query.language as string)?.toLowerCase() || 'nl'; | ||||||
|     const translations = loadTranslations<Translations>(language); |     const translations = loadTranslations<Translations>(language); | ||||||
|     const themeList = themes.map((theme) => ({ |     const themeList = themes.map((theme) => ({ | ||||||
|  | @ -21,8 +21,14 @@ export function getThemes(req: Request, res: Response) { | ||||||
|     res.json(themeList); |     res.json(themeList); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function getThemeByTitle(req: Request, res: Response) { | export function getHruidsByThemeHandler(req: Request, res: Response) { | ||||||
|     const themeKey = req.params.theme; |     const themeKey = req.params.theme; | ||||||
|  | 
 | ||||||
|  |     if (!themeKey) { | ||||||
|  |         res.status(400).json({ error: 'Missing required field: theme' }); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const theme = themes.find((t) => t.title === themeKey); |     const theme = themes.find((t) => t.title === themeKey); | ||||||
| 
 | 
 | ||||||
|     if (theme) { |     if (theme) { | ||||||
|  |  | ||||||
|  | @ -1,14 +1,14 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getThemes, getThemeByTitle } from '../controllers/themes.js'; | import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
| // Query: language
 | // Query: language
 | ||||||
| //  Route to fetch list of {key, title, description, image} themes in their respective language
 | //  Route to fetch list of {key, title, description, image} themes in their respective language
 | ||||||
| router.get('/', getThemes); | router.get('/', getThemesHandler); | ||||||
| 
 | 
 | ||||||
| // Arg: theme (key)
 | // Arg: theme (key)
 | ||||||
| //  Route to fetch list of hruids based on theme
 | //  Route to fetch list of hruids based on theme
 | ||||||
| router.get('/:theme', getThemeByTitle); | router.get('/:theme', getHruidsByThemeHandler); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -16,12 +16,14 @@ | ||||||
|         "test:e2e": "playwright test" |         "test:e2e": "playwright test" | ||||||
|     }, |     }, | ||||||
|     "dependencies": { |     "dependencies": { | ||||||
|  |         "@tanstack/react-query": "^5.69.0", | ||||||
|  |         "@tanstack/vue-query": "^5.69.0", | ||||||
|  |         "axios": "^1.8.2", | ||||||
|  |         "oidc-client-ts": "^3.1.0", | ||||||
|         "vue": "^3.5.13", |         "vue": "^3.5.13", | ||||||
|         "vue-i18n": "^11.1.2", |         "vue-i18n": "^11.1.2", | ||||||
|         "vue-router": "^4.5.0", |         "vue-router": "^4.5.0", | ||||||
|         "vuetify": "^3.7.12", |         "vuetify": "^3.7.12" | ||||||
|         "oidc-client-ts": "^3.1.0", |  | ||||||
|         "axios": "^1.8.2" |  | ||||||
|     }, |     }, | ||||||
|     "devDependencies": { |     "devDependencies": { | ||||||
|         "@playwright/test": "^1.50.1", |         "@playwright/test": "^1.50.1", | ||||||
|  |  | ||||||
|  | @ -1,9 +1,71 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     // This component contains a list with all themes and will be shown on a student's and teacher's homepage. | import ThemeCard from "@/components/ThemeCard.vue"; | ||||||
|  | import { ref, watchEffect, computed } from "vue"; | ||||||
|  | import { useI18n } from "vue-i18n"; | ||||||
|  | import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; | ||||||
|  | import { useThemeQuery } from "@/queries/themes.ts"; | ||||||
|  | 
 | ||||||
|  | const props = defineProps({ | ||||||
|  |     selectedTheme: { type: String, required: true }, | ||||||
|  |     selectedAge: { type: String, required: true } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const { locale } = useI18n(); | ||||||
|  | const language = computed(() => locale.value); | ||||||
|  | 
 | ||||||
|  | const { data: allThemes, isLoading, error } = useThemeQuery(language); | ||||||
|  | 
 | ||||||
|  | const allCards = ref([]); | ||||||
|  | const cards = ref([]); | ||||||
|  | 
 | ||||||
|  | watchEffect(() => { | ||||||
|  |     const themes = allThemes.value ?? []; | ||||||
|  |     allCards.value = themes; | ||||||
|  | 
 | ||||||
|  |     if (props.selectedTheme) { | ||||||
|  |         cards.value = themes.filter((theme) => | ||||||
|  |             THEMESITEMS[props.selectedTheme]?.includes(theme.key) && | ||||||
|  |             AGE_TO_THEMES[props.selectedAge]?.includes(theme.key) | ||||||
|  |         ); | ||||||
|  |     } else { | ||||||
|  |         cards.value = themes; | ||||||
|  |     } | ||||||
|  | }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| <template> | <template> | ||||||
|     <main></main> |     <v-container> | ||||||
|  |         <div v-if="isLoading" class="text-center py-10"> | ||||||
|  |             <v-progress-circular indeterminate color="primary" /> | ||||||
|  |             <p>Loading...</p> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div v-else-if="error" class="text-center py-10 text-error"> | ||||||
|  |             <v-icon large>mdi-alert-circle</v-icon> | ||||||
|  |             <p>Error loading: {{ error.message }}</p> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <v-row v-else> | ||||||
|  |             <v-col | ||||||
|  |                 v-for="card in cards" | ||||||
|  |                 :key="card.key" | ||||||
|  |                 cols="12" | ||||||
|  |                 sm="6" | ||||||
|  |                 md="4" | ||||||
|  |                 lg="4" | ||||||
|  |                 class="d-flex" | ||||||
|  |             > | ||||||
|  |                 <ThemeCard | ||||||
|  |                     :path="card.key" | ||||||
|  |                     :title="card.title" | ||||||
|  |                     :description="card.description" | ||||||
|  |                     :image="card.image" | ||||||
|  |                     class="fill-height" | ||||||
|  |                 /> | ||||||
|  |             </v-col> | ||||||
|  |         </v-row> | ||||||
|  |     </v-container> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped></style> | <style scoped></style> | ||||||
|  |  | ||||||
|  | @ -22,7 +22,7 @@ | ||||||
|         { name: "English", code: "en" }, |         { name: "English", code: "en" }, | ||||||
|         { name: "Nederlands", code: "nl" }, |         { name: "Nederlands", code: "nl" }, | ||||||
|         { name: "Français", code: "fr" }, |         { name: "Français", code: "fr" }, | ||||||
|         { name: "Deutsch", code: "de" }, |         { name: "Deutsch", code: "de" } | ||||||
|     ]); |     ]); | ||||||
| 
 | 
 | ||||||
|     // Logic to change the language of the website to the selected language |     // Logic to change the language of the website to the selected language | ||||||
|  | @ -296,6 +296,7 @@ | ||||||
|                 </li> |                 </li> | ||||||
|             </div> |             </div> | ||||||
|         </nav> |         </nav> | ||||||
|  |         <router-view /> | ||||||
|     </main> |     </main> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										74
									
								
								frontend/src/components/ThemeCard.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								frontend/src/components/ThemeCard.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,74 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { useI18n } from "vue-i18n"; | ||||||
|  | 
 | ||||||
|  | const { t } = useI18n(); | ||||||
|  | 
 | ||||||
|  | defineProps<{ | ||||||
|  |     path: string; | ||||||
|  |     title: string; | ||||||
|  |     description: string; | ||||||
|  |     image: string; | ||||||
|  | }>(); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <v-card | ||||||
|  |         variant="outlined" | ||||||
|  |         class="theme-card d-flex flex-column" | ||||||
|  |         :to="`theme/${path}`" | ||||||
|  |         link | ||||||
|  |     > | ||||||
|  |         <v-card-title class="title-container"> | ||||||
|  |             <v-img | ||||||
|  |                 v-if="image" | ||||||
|  |                 :src="image" | ||||||
|  |                 height="40px" | ||||||
|  |                 width="40px" | ||||||
|  |                 contain | ||||||
|  |                 class="title-image" | ||||||
|  |             ></v-img> | ||||||
|  |             <span class="title">{{ title }}</span> | ||||||
|  |         </v-card-title> | ||||||
|  |         <v-card-text class="description flex-grow-1">{{ description }}</v-card-text> | ||||||
|  |         <v-card-actions> | ||||||
|  |             <v-btn :to="`theme/${path}`" variant="text"> | ||||||
|  |                 {{ t("read-more") }} | ||||||
|  |             </v-btn> | ||||||
|  |         </v-card-actions> | ||||||
|  |     </v-card> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  | .theme-card { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     height: 100%; | ||||||
|  |     padding: 1rem; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .theme-card:hover { | ||||||
|  |     background-color: rgba(0, 0, 0, 0.03); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .title-container { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 10px; | ||||||
|  |     text-align: left; | ||||||
|  |     justify-content: flex-start; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .title-image { | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     border-radius: 5px; | ||||||
|  |     margin-left: 0; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .title { | ||||||
|  |     flex-grow: 1; | ||||||
|  |     white-space: normal; | ||||||
|  |     overflow-wrap: break-word; | ||||||
|  |     word-break: break-word; | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										73
									
								
								frontend/src/controllers/base-controller.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/src/controllers/base-controller.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | ||||||
|  | import {apiConfig} from "@/config.ts"; | ||||||
|  | 
 | ||||||
|  | export class BaseController { | ||||||
|  |     protected baseUrl: string; | ||||||
|  | 
 | ||||||
|  |     constructor(basePath: string) { | ||||||
|  |         this.baseUrl = `${apiConfig.baseUrl}/${basePath}`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     protected async get<T>(path: string, queryParams?: Record<string, any>): Promise<T> { | ||||||
|  |         let url = `${this.baseUrl}${path}`; | ||||||
|  |         if (queryParams) { | ||||||
|  |             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); | ||||||
|  |         if (!res.ok) { | ||||||
|  |             const errorData = await res.json().catch(() => ({})); | ||||||
|  |             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return res.json(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     protected async post<T>(path: string, body: unknown): Promise<T> { | ||||||
|  |         const res = await fetch(`${this.baseUrl}${path}`, { | ||||||
|  |             method: "POST", | ||||||
|  |             headers: { "Content-Type": "application/json" }, | ||||||
|  |             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> { | ||||||
|  |         const res = await fetch(`${this.baseUrl}${path}`, { | ||||||
|  |             method: "DELETE", | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         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> { | ||||||
|  |         const res = await fetch(`${this.baseUrl}${path}`, { | ||||||
|  |             method: "PUT", | ||||||
|  |             headers: { "Content-Type": "application/json" }, | ||||||
|  |             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(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										14
									
								
								frontend/src/controllers/controllers.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								frontend/src/controllers/controllers.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | ||||||
|  | import {ThemeController} from "@/controllers/themes.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); | ||||||
							
								
								
									
										16
									
								
								frontend/src/controllers/themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/src/controllers/themes.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | ||||||
|  | import {BaseController} from "@/controllers/base-controller.ts"; | ||||||
|  | 
 | ||||||
|  | export class ThemeController extends BaseController { | ||||||
|  |     constructor() { | ||||||
|  |         super("theme"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     getAll(language: string | null = null) { | ||||||
|  |         const query = language ? { language } : undefined; | ||||||
|  |         return this.get<any[]>("/", query); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     getHruidsByKey(themeKey: string) { | ||||||
|  |         return this.get<string[]>(`/${encodeURIComponent(themeKey)}`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -16,5 +16,28 @@ | ||||||
|     "researchBased": "Forschungsbasiert", |     "researchBased": "Forschungsbasiert", | ||||||
|     "inclusive": "Inclusiv", |     "inclusive": "Inclusiv", | ||||||
|     "sociallyRelevant": "Gesellschaftlich relevant", |     "sociallyRelevant": "Gesellschaftlich relevant", | ||||||
|     "translate": "übersetzen" |     "translate": "übersetzen", | ||||||
|  |     "themes": "Themen", | ||||||
|  |     "choose-theme": "Wähle ein thema", | ||||||
|  |     "choose-age": "Alter auswählen", | ||||||
|  |     "theme-options": { | ||||||
|  |         "all": "Alle themen", | ||||||
|  |         "culture": "Kultur", | ||||||
|  |         "electricity-and-mechanics": "Elektrizität und Mechanik", | ||||||
|  |         "nature-and-climate": "Natur und Klima", | ||||||
|  |         "agriculture": "Landwirtschaft", | ||||||
|  |         "society": "Gesellschaft", | ||||||
|  |         "math": "Mathematik", | ||||||
|  |         "technology": "Technologie", | ||||||
|  |         "algorithms": "Algorithmisches Denken" | ||||||
|  |     }, | ||||||
|  |     "age-options": { | ||||||
|  |         "all": "Alle altersgruppen", | ||||||
|  |         "primary-school": "Grundschule", | ||||||
|  |         "lower-secondary": "12-14 jahre alt", | ||||||
|  |         "upper-secondary": "14-16 jahre alt", | ||||||
|  |         "high-school": "16-18 jahre alt", | ||||||
|  |         "older": "18 und älter" | ||||||
|  |     }, | ||||||
|  |     "read-more": "Mehr lesen" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -15,6 +15,29 @@ | ||||||
|     "researchBased": "Research-based", |     "researchBased": "Research-based", | ||||||
|     "inclusive": "Inclusive", |     "inclusive": "Inclusive", | ||||||
|     "sociallyRelevant": "Socially relevant", |     "sociallyRelevant": "Socially relevant", | ||||||
|     "login": "login", |     "login": "log in", | ||||||
|     "translate": "translate" |     "translate": "translate", | ||||||
|  |     "themes": "Themes", | ||||||
|  |     "choose-theme": "Select a theme", | ||||||
|  |     "choose-age": "Select age", | ||||||
|  |     "theme-options": { | ||||||
|  |         "all": "All themes", | ||||||
|  |         "culture": "Culture", | ||||||
|  |         "electricity-and-mechanics": "Electricity and mechanics", | ||||||
|  |         "nature-and-climate": "Nature and climate", | ||||||
|  |         "agriculture": "Agriculture", | ||||||
|  |         "society": "Society", | ||||||
|  |         "math": "Math", | ||||||
|  |         "technology": "Technology", | ||||||
|  |         "algorithms": "Algorithms" | ||||||
|  |     }, | ||||||
|  |     "age-options": { | ||||||
|  |         "all": "All ages", | ||||||
|  |         "primary-school": "Primary school", | ||||||
|  |         "lower-secondary": "12-14 years old", | ||||||
|  |         "upper-secondary": "14-16 years old", | ||||||
|  |         "high-school": "16-18 years old", | ||||||
|  |         "older": "18 and older" | ||||||
|  |     }, | ||||||
|  |     "read-more": "Read more" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -16,5 +16,28 @@ | ||||||
|     "researchBased": "Fondé sur la recherche", |     "researchBased": "Fondé sur la recherche", | ||||||
|     "inclusive": "Inclusif", |     "inclusive": "Inclusif", | ||||||
|     "sociallyRelevant": "Socialement pertinent", |     "sociallyRelevant": "Socialement pertinent", | ||||||
|     "translate": "traduire" |     "translate": "traduire", | ||||||
|  |     "themes": "Thèmes", | ||||||
|  |     "choose-theme": "Choisis un thème", | ||||||
|  |     "choose-age": "Choisis un âge", | ||||||
|  |     "theme-options": { | ||||||
|  |         "all": "Tous les thèmes", | ||||||
|  |         "culture": "Culture", | ||||||
|  |         "electricity-and-mechanics": "Electricité et méchanique", | ||||||
|  |         "nature-and-climate": "Nature et climat", | ||||||
|  |         "agriculture": "Agriculture", | ||||||
|  |         "society": "Société", | ||||||
|  |         "math": "Math", | ||||||
|  |         "technology": "Technologie", | ||||||
|  |         "algorithms": "Algorithmes" | ||||||
|  |     }, | ||||||
|  |     "age-options": { | ||||||
|  |         "all": "Tous les âges", | ||||||
|  |         "primary-school": "Ecole primaire", | ||||||
|  |         "lower-secondary": "12-14 ans", | ||||||
|  |         "upper-secondary": "14-16 ans", | ||||||
|  |         "high-school": "16-18 ans", | ||||||
|  |         "older": "18 et plus" | ||||||
|  |     }, | ||||||
|  |     "read-more": "En savoir plus" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -15,6 +15,29 @@ | ||||||
|     "researchBased": "Onderzoeksgedreven", |     "researchBased": "Onderzoeksgedreven", | ||||||
|     "inclusive": "Inclusief", |     "inclusive": "Inclusief", | ||||||
|     "sociallyRelevant": "Maatschappelijk relevant", |     "sociallyRelevant": "Maatschappelijk relevant", | ||||||
|     "login": "inloggen", |     "login": "log in", | ||||||
|     "translate": "vertalen" |     "translate": "vertalen", | ||||||
|  |     "themes": "Lesthema's", | ||||||
|  |     "choose-theme": "Kies een thema", | ||||||
|  |     "choose-age": "Kies een leeftijd", | ||||||
|  |     "theme-options": { | ||||||
|  |         "all": "Alle thema's", | ||||||
|  |         "culture": "Taal en kunst", | ||||||
|  |         "electricity-and-mechanics": "Elektriciteit en mechanica", | ||||||
|  |         "nature-and-climate": "Natuur en klimaat", | ||||||
|  |         "agriculture": "Land-en tuinbouw", | ||||||
|  |         "society": "Maatschappij en welzijn", | ||||||
|  |         "math": "Wiskunde", | ||||||
|  |         "technology": "Technologie", | ||||||
|  |         "algorithms": "Algoritmes" | ||||||
|  |     }, | ||||||
|  |     "age-options": { | ||||||
|  |         "all": "Alle leeftijden", | ||||||
|  |         "primary-school": "Lagere school", | ||||||
|  |         "lower-secondary": "1e graad secundair", | ||||||
|  |         "upper-secondary": "2e graad secundair", | ||||||
|  |         "high-school": "3e graad secundair", | ||||||
|  |         "older": "Hoger onderwijs" | ||||||
|  |     }, | ||||||
|  |     "read-more": "Lees meer" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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 { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'; | ||||||
| 
 | 
 | ||||||
| const app = createApp(App); | const app = createApp(App); | ||||||
| 
 | 
 | ||||||
|  | @ -24,6 +25,18 @@ const vuetify = createVuetify({ | ||||||
|     components, |     components, | ||||||
|     directives, |     directives, | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | const queryClient = new QueryClient({ | ||||||
|  |     defaultOptions: { | ||||||
|  |         queries: { | ||||||
|  |             retry: 1, | ||||||
|  |             refetchOnWindowFocus: false, | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| app.use(vuetify); | app.use(vuetify); | ||||||
| app.use(i18n); | app.use(i18n); | ||||||
|  | app.use(VueQueryPlugin, { queryClient }); | ||||||
|  | 
 | ||||||
| app.mount("#app"); | app.mount("#app"); | ||||||
|  |  | ||||||
							
								
								
									
										25
									
								
								frontend/src/queries/themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/src/queries/themes.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | ||||||
|  | import { useQuery } from '@tanstack/vue-query'; | ||||||
|  | import { getThemeController } from '@/controllers/controllers'; | ||||||
|  | import {type MaybeRefOrGetter, toValue} from "vue"; | ||||||
|  | 
 | ||||||
|  | const themeController = getThemeController(); | ||||||
|  | 
 | ||||||
|  | export const useThemeQuery = (language: MaybeRefOrGetter<string>) => { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: ['themes', language], | ||||||
|  |         queryFn: () => { | ||||||
|  |             const lang = toValue(language); | ||||||
|  |             return themeController.getAll(lang); | ||||||
|  |         }, | ||||||
|  |         enabled: () => !!toValue(language), | ||||||
|  |     }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const useThemeHruidsQuery = (themeKey: string | null) => { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: ['theme-hruids', themeKey], | ||||||
|  |         queryFn: () => themeController.getHruidsByKey(themeKey!), | ||||||
|  |         enabled: !!themeKey, | ||||||
|  |     }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | @ -1,6 +1,5 @@ | ||||||
| import { createRouter, createWebHistory } from "vue-router"; | import { createRouter, createWebHistory } from "vue-router"; | ||||||
| import MenuBar from "@/components/MenuBar.vue"; | import MenuBar from "@/components/MenuBar.vue"; | ||||||
| import StudentHomepage from "@/views/homepage/StudentHomepage.vue"; |  | ||||||
| import SingleAssignment from "@/views/assignments/SingleAssignment.vue"; | import SingleAssignment from "@/views/assignments/SingleAssignment.vue"; | ||||||
| import SingleClass from "@/views/classes/SingleClass.vue"; | import SingleClass from "@/views/classes/SingleClass.vue"; | ||||||
| import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue"; | import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue"; | ||||||
|  | @ -13,6 +12,8 @@ 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 UserHomePage from "@/views/homepage/UserHomePage.vue"; | ||||||
|  | import SingleTheme from "@/views/SingleTheme.vue"; | ||||||
| 
 | 
 | ||||||
| const router = createRouter({ | const router = createRouter({ | ||||||
|     history: createWebHistory(import.meta.env.BASE_URL), |     history: createWebHistory(import.meta.env.BASE_URL), | ||||||
|  | @ -41,9 +42,9 @@ const router = createRouter({ | ||||||
|             meta: { requiresAuth: true }, |             meta: { requiresAuth: true }, | ||||||
|             children: [ |             children: [ | ||||||
|                 { |                 { | ||||||
|                     path: "home", |                     path: "", | ||||||
|                     name: "UserHomePage", |                     name: "UserHomePage", | ||||||
|                     component: StudentHomepage, |                     component: UserHomePage, | ||||||
|                 }, |                 }, | ||||||
|                 { |                 { | ||||||
|                     path: "assignment", |                     path: "assignment", | ||||||
|  | @ -63,6 +64,12 @@ const router = createRouter({ | ||||||
|             ], |             ], | ||||||
|         }, |         }, | ||||||
| 
 | 
 | ||||||
|  |         { | ||||||
|  |             path: "/theme/:id", | ||||||
|  |             name: "Theme", | ||||||
|  |             component: SingleTheme, | ||||||
|  |             meta: { requiresAuth: true }, | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|             path: "/assignment/create", |             path: "/assignment/create", | ||||||
|             name: "CreateAssigment", |             name: "CreateAssigment", | ||||||
|  | @ -112,7 +119,8 @@ router.beforeEach(async (to, from, next) => { | ||||||
|     // Verify if user is logged in before accessing certain routes
 |     // Verify if user is logged in before accessing certain routes
 | ||||||
|     if (to.meta.requiresAuth) { |     if (to.meta.requiresAuth) { | ||||||
|         if (!authState.isLoggedIn.value) { |         if (!authState.isLoggedIn.value) { | ||||||
|             next("/login"); |             //Next("/login");
 | ||||||
|  |             next(); | ||||||
|         } else { |         } else { | ||||||
|             next(); |             next(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
							
								
								
									
										37
									
								
								frontend/src/utils/constants.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/src/utils/constants.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | ||||||
|  | export const THEMES_KEYS = [ | ||||||
|  |     "kiks", "art", "socialrobot", "agriculture", "wegostem", | ||||||
|  |     "computational_thinking", "math_with_python", "python_programming", | ||||||
|  |     "stem", "care", "chatbot", "physical_computing", "algorithms", "basics_ai" | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | export const THEMESITEMS: Record<string, string[]> = { | ||||||
|  |     "all": THEMES_KEYS, | ||||||
|  |     "culture": ["art", "wegostem", "chatbot"], | ||||||
|  |     "electricity-and-mechanics": ["socialrobot", "wegostem", "stem", "physical_computing"], | ||||||
|  |     "nature-and-climate": ["kiks", "agriculture"], | ||||||
|  |     "agriculture": ["agriculture"], | ||||||
|  |     "society": ["kiks", "socialrobot", "care", "chatbot"], | ||||||
|  |     "math": ["kiks", "math_with_python", "python_programming", "stem", "care", "basics_ai"], | ||||||
|  |     "technology": ["socialrobot", "wegostem", "computational_thinking", "stem", "physical_computing", "basics_ai"], | ||||||
|  |     "algorithms": ["math_with_python", "python_programming", "stem", "algorithms", "basics_ai"], | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const AGEITEMS = [ | ||||||
|  |     "all", "primary-school", "lower-secondary", "upper-secondary", "high-school", "older" | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | export const AGE_TO_THEMES: Record<string, string[]> = { | ||||||
|  |     "all": THEMES_KEYS, | ||||||
|  |     "primary-school": ["wegostem", "computational_thinking", "physical_computing"], | ||||||
|  |     "lower-secondary": ["socialrobot", "art", "wegostem", "computational_thinking", "physical_computing"], | ||||||
|  |     "upper-secondary": ["kiks", "art", "socialrobot", "agriculture", | ||||||
|  |         "computational_thinking", "math_with_python", "python_programming", | ||||||
|  |         "stem", "care", "chatbot", "algorithms", "basics_ai"], | ||||||
|  |     "high-school": [ | ||||||
|  |         "kiks", "art", "agriculture", "computational_thinking", "math_with_python", "python_programming", | ||||||
|  |         "stem", "care", "chatbot", "algorithms", "basics_ai" | ||||||
|  |     ], | ||||||
|  |     "older": [ | ||||||
|  |         "kiks", "computational_thinking", "algorithms", "basics_ai" | ||||||
|  |     ] | ||||||
|  | }; | ||||||
							
								
								
									
										11
									
								
								frontend/src/views/SingleTheme.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/views/SingleTheme.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  | 
 | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  | <main></main> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  | 
 | ||||||
|  | </style> | ||||||
|  | @ -1,7 +0,0 @@ | ||||||
| <script setup lang="ts"></script> |  | ||||||
| 
 |  | ||||||
| <template> |  | ||||||
|     <main></main> |  | ||||||
| </template> |  | ||||||
| 
 |  | ||||||
| <style scoped></style> |  | ||||||
|  | @ -1,7 +0,0 @@ | ||||||
| <script setup lang="ts"></script> |  | ||||||
| 
 |  | ||||||
| <template> |  | ||||||
|     <main></main> |  | ||||||
| </template> |  | ||||||
| 
 |  | ||||||
| <style scoped></style> |  | ||||||
|  | @ -1,7 +1,125 @@ | ||||||
| <script setup lang="ts"></script> | <script setup lang="ts"> | ||||||
|  | import {ref, watch} from "vue"; | ||||||
|  |     import {useI18n} from "vue-i18n"; | ||||||
|  |     import {THEMESITEMS, AGE_TO_THEMES} from "@/utils/constants.ts"; | ||||||
|  |     import BrowseThemes from "@/components/BrowseThemes.vue"; | ||||||
|  | 
 | ||||||
|  |     const {t, locale} = useI18n(); | ||||||
|  | 
 | ||||||
|  |     const selectedThemeKey = ref<string>('all'); | ||||||
|  |     const selectedAgeKey = ref<string>('all'); | ||||||
|  | 
 | ||||||
|  |     const allThemes = ref(Object.keys(THEMESITEMS)); | ||||||
|  |     const availableThemes = ref([...allThemes.value]); | ||||||
|  | 
 | ||||||
|  |     const allAges = ref(Object.keys(AGE_TO_THEMES)); | ||||||
|  |     const availableAges = ref([...allAges.value]); | ||||||
|  | 
 | ||||||
|  |     // Reset selection when language changes | ||||||
|  |     watch(locale, () => { | ||||||
|  |         selectedThemeKey.value = 'all'; | ||||||
|  |         selectedAgeKey.value = 'all'; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     watch(selectedThemeKey, () => { | ||||||
|  |         if (selectedThemeKey.value === "all") { | ||||||
|  |             availableAges.value = [...allAges.value]; // Reset to all ages | ||||||
|  |         } else { | ||||||
|  |             const themes = THEMESITEMS[selectedThemeKey.value]; | ||||||
|  |             availableAges.value = allAges.value.filter(age => | ||||||
|  |                 AGE_TO_THEMES[age]?.some(theme => themes.includes(theme)) | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     watch(selectedAgeKey, () => { | ||||||
|  |         if (selectedAgeKey.value === "all") { | ||||||
|  |             availableThemes.value = [...allThemes.value]; // Reset to all themes | ||||||
|  |         } else { | ||||||
|  |             const themes = AGE_TO_THEMES[selectedAgeKey.value]; | ||||||
|  |             availableThemes.value = allThemes.value.filter(theme => | ||||||
|  |                 THEMESITEMS[theme]?.some(theme => themes.includes(theme)) | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <main></main> |     <div class="main-container"> | ||||||
|  |         <h1 class="title">{{ t("themes") }}</h1> | ||||||
|  |         <v-container class="dropdowns"> | ||||||
|  |             <v-select class="v-select" | ||||||
|  |                       :label="t('choose-theme')" | ||||||
|  |                       :items="availableThemes.map(theme => ({ title: t(`theme-options.${theme}`), value: theme }))" | ||||||
|  |                       v-model="selectedThemeKey" | ||||||
|  |                       item-title="title" | ||||||
|  |                       item-value="value" | ||||||
|  |                       variant="outlined" | ||||||
|  |             /> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |             <v-select | ||||||
|  |                 class="v-select" | ||||||
|  |                 :label="t('choose-age')" | ||||||
|  |                 :items="availableAges.map(age => ({ key: age, label: t(`age-options.${age}`), value: age }))" | ||||||
|  |                 v-model="selectedAgeKey" | ||||||
|  |                 item-title="label" | ||||||
|  |                 item-value="key" | ||||||
|  |                 variant="outlined" | ||||||
|  |             ></v-select> | ||||||
|  |         </v-container> | ||||||
|  | 
 | ||||||
|  |         <BrowseThemes :selectedTheme="selectedThemeKey ?? ''" :selectedAge="selectedAgeKey ?? ''"/> | ||||||
|  |     </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped></style> | <style scoped> | ||||||
|  | .main-container { | ||||||
|  |     min-height: 100vh; | ||||||
|  |     min-width: 100vw; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: flex-start; | ||||||
|  |     justify-content: flex-start; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .title { | ||||||
|  |     max-width: 50rem; | ||||||
|  |     margin-left: 1rem; | ||||||
|  |     margin-top: 1rem; | ||||||
|  |     text-align: center; | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: center; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | .dropdowns { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     gap: 5rem; | ||||||
|  |     width: 80%; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .v-select { | ||||||
|  |     flex: 1; | ||||||
|  |     min-width: 100px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |     .main-container { | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @media (max-width: 700px) { | ||||||
|  |     .dropdowns { | ||||||
|  |         flex-direction: column; | ||||||
|  |         gap: 1rem; | ||||||
|  |         width: 80%; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | </style> | ||||||
|  |  | ||||||
							
								
								
									
										802
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										802
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana