diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index eb8865a8..2a64edd7 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -1,12 +1,16 @@ import { Request, Response } from 'express'; import { FALLBACK_LANG } from '../config.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 attachmentService from '../services/learning-objects/attachment-service.js'; -import { NotFoundError } from '@mikro-orm/core'; 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'; +import { + FilteredLearningObject, + LearningObjectIdentifier, + LearningPathIdentifier, +} from '@dwengo-1/common/interfaces/learning-content'; function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { if (!req.params.hruid) { @@ -47,6 +51,11 @@ export async function getLearningObject(req: Request, res: Response): Promise const attachment = await attachmentService.getAttachment(learningObjectId, name); 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); } diff --git a/frontend/src/components/BrowseThemes.vue b/frontend/src/components/BrowseThemes.vue index 97ff8352..805d2720 100644 --- a/frontend/src/components/BrowseThemes.vue +++ b/frontend/src/components/BrowseThemes.vue @@ -1,9 +1,10 @@ - - - - diff --git a/frontend/src/components/LearningPathSearchField.vue b/frontend/src/components/LearningPathSearchField.vue new file mode 100644 index 00000000..b8b71960 --- /dev/null +++ b/frontend/src/components/LearningPathSearchField.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/frontend/src/components/LearningPathsGrid.vue b/frontend/src/components/LearningPathsGrid.vue new file mode 100644 index 00000000..865c7166 --- /dev/null +++ b/frontend/src/components/LearningPathsGrid.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/components/UsingQueryResult.vue b/frontend/src/components/UsingQueryResult.vue new file mode 100644 index 00000000..27271184 --- /dev/null +++ b/frontend/src/components/UsingQueryResult.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 5456c9e8..72d71819 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -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 { - protected baseUrl: string; +export abstract class BaseController { + protected basePath: string; - constructor(basePath: string) { - this.baseUrl = `${apiConfig.baseUrl}/${basePath}`; + protected constructor(basePath: string) { + this.basePath = basePath; } - protected async get(path: string, queryParams?: Record): Promise { - 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()}`; + private static assertSuccessResponse(response: AxiosResponse): void { + if (response.status / 100 !== 2) { + throw new HttpErrorResponseException(response); } + } - 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 get(path: string, queryParams?: QueryParams, responseType?: ResponseType): Promise { + const response = await apiClient.get(this.absolutePathFor(path), { params: queryParams, responseType }); + BaseController.assertSuccessResponse(response); + return response.data; } protected async post(path: string, body: unknown): Promise { - 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(); + const response = await apiClient.post(this.absolutePathFor(path), body); + BaseController.assertSuccessResponse(response); + return response.data; } protected async delete(path: string): Promise { - 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(); + const response = await apiClient.delete(this.absolutePathFor(path)); + BaseController.assertSuccessResponse(response); + return response.data; } protected async put(path: string, body: unknown): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + const response = await apiClient.put(this.absolutePathFor(path), body); + 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(); + private absolutePathFor(path: string): string { + return "/" + this.basePath + path; } } + +type QueryParams = Record; diff --git a/frontend/src/controllers/controllers.ts b/frontend/src/controllers/controllers.ts index 639a8f1c..39392a7d 100644 --- a/frontend/src/controllers/controllers.ts +++ b/frontend/src/controllers/controllers.ts @@ -1,4 +1,6 @@ import { ThemeController } from "@/controllers/themes.ts"; +import { LearningObjectController } from "@/controllers/learning-objects.ts"; +import { LearningPathController } from "@/controllers/learning-paths.ts"; export function controllerGetter(factory: new () => T): () => T { let instance: T | undefined; @@ -12,3 +14,5 @@ export function controllerGetter(factory: new () => T): () => T { } export const getThemeController = controllerGetter(ThemeController); +export const getLearningObjectController = controllerGetter(LearningObjectController); +export const getLearningPathController = controllerGetter(LearningPathController); diff --git a/frontend/src/controllers/learning-objects.ts b/frontend/src/controllers/learning-objects.ts new file mode 100644 index 00000000..d62ba1f4 --- /dev/null +++ b/frontend/src/controllers/learning-objects.ts @@ -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 { + return this.get(`/${hruid}`, { language, version }); + } + + async getHTML(hruid: string, language: Language, version: number): Promise { + return this.get(`/${hruid}/html`, { language, version }, "document"); + } +} diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts new file mode 100644 index 00000000..15967d28 --- /dev/null +++ b/frontend/src/controllers/learning-paths.ts @@ -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 { + const dtos = await this.get("/", { search: query }); + return dtos.map((dto) => LearningPath.fromDTO(dto)); + } + async getBy( + hruid: string, + language: Language, + options?: { forGroup?: string; forStudent?: string }, + ): Promise { + const dtos = await this.get("/", { + hruid, + language, + forGroup: options?.forGroup, + forStudent: options?.forStudent, + }); + return LearningPath.fromDTO(single(dtos)); + } + async getAllByTheme(theme: string): Promise { + const dtos = await this.get("/", { theme }); + return dtos.map((dto) => LearningPath.fromDTO(dto)); + } +} diff --git a/frontend/src/data-objects/language.ts b/frontend/src/data-objects/language.ts new file mode 100644 index 00000000..cb7b7fd1 --- /dev/null +++ b/frontend/src/data-objects/language.ts @@ -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", +} diff --git a/frontend/src/data-objects/learning-objects/educational-goal.ts b/frontend/src/data-objects/learning-objects/educational-goal.ts new file mode 100644 index 00000000..05c7a786 --- /dev/null +++ b/frontend/src/data-objects/learning-objects/educational-goal.ts @@ -0,0 +1,4 @@ +export interface EducationalGoal { + source: string; + id: string; +} diff --git a/frontend/src/data-objects/learning-objects/learning-object.ts b/frontend/src/data-objects/learning-objects/learning-object.ts new file mode 100644 index 00000000..1205e8fe --- /dev/null +++ b/frontend/src/data-objects/learning-objects/learning-object.ts @@ -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; +} diff --git a/frontend/src/data-objects/learning-objects/return-value.ts b/frontend/src/data-objects/learning-objects/return-value.ts new file mode 100644 index 00000000..56b89380 --- /dev/null +++ b/frontend/src/data-objects/learning-objects/return-value.ts @@ -0,0 +1,4 @@ +export interface ReturnValue { + callback_url: string; + callback_schema: Record; +} diff --git a/frontend/src/data-objects/learning-paths/learning-path-dto.ts b/frontend/src/data-objects/learning-paths/learning-path-dto.ts new file mode 100644 index 00000000..60e713aa --- /dev/null +++ b/frontend/src/data-objects/learning-paths/learning-path-dto.ts @@ -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; +} diff --git a/frontend/src/data-objects/learning-paths/learning-path-node.ts b/frontend/src/data-objects/learning-paths/learning-path-node.ts new file mode 100644 index 00000000..99bac8db --- /dev/null +++ b/frontend/src/data-objects/learning-paths/learning-path-node.ts @@ -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, + }); + } +} diff --git a/frontend/src/data-objects/learning-paths/learning-path.ts b/frontend/src/data-objects/learning-paths/learning-path.ts new file mode 100644 index 00000000..d764d123 --- /dev/null +++ b/frontend/src/data-objects/learning-paths/learning-path.ts @@ -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]; + } +} diff --git a/frontend/src/data-objects/theme.ts b/frontend/src/data-objects/theme.ts new file mode 100644 index 00000000..c54f1cec --- /dev/null +++ b/frontend/src/data-objects/theme.ts @@ -0,0 +1,8 @@ +export interface Theme { + key: string; + title: string; + description: string; + + // URL of the image + image: string; +} diff --git a/frontend/src/exception/http-error-response-exception.ts b/frontend/src/exception/http-error-response-exception.ts new file mode 100644 index 00000000..c519f4fd --- /dev/null +++ b/frontend/src/exception/http-error-response-exception.ts @@ -0,0 +1,9 @@ +import type { AxiosResponse } from "axios"; + +export class HttpErrorResponseException extends Error { + public statusCode: number; + constructor(public response: AxiosResponse) { + super((response.data as { message: string })?.message || JSON.stringify(response.data)); + this.statusCode = response.status; + } +} diff --git a/frontend/src/exception/invalid-response-exception.ts b/frontend/src/exception/invalid-response-exception.ts new file mode 100644 index 00000000..5cbd35b3 --- /dev/null +++ b/frontend/src/exception/invalid-response-exception.ts @@ -0,0 +1,5 @@ +export class InvalidResponseException extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/frontend/src/exception/not-found-exception.ts b/frontend/src/exception/not-found-exception.ts new file mode 100644 index 00000000..fd5b29a6 --- /dev/null +++ b/frontend/src/exception/not-found-exception.ts @@ -0,0 +1,5 @@ +export class NotFoundException extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index 956e8b96..d20154a4 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -39,5 +39,17 @@ "high-school": "16-18 jahre alt", "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" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index cf7a19cb..8d7ed775 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -2,10 +2,22 @@ "welcome": "Welcome", "student": "student", "teacher": "teacher", - "assignments": "Assignments", - "classes": "Classes", - "discussions": "Discussions", + "assignments": "assignments", + "classes": "classes", + "discussions": "discussions", "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", "logoutVerification": "Are you sure you want to log out?", "homeTitle": "Our strengths", diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 0b4041a4..2dfbb949 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -1,5 +1,17 @@ { "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", "teacher": "enseignant", "assignments": "Travails", diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 9f944a5a..80cdd8e1 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -2,10 +2,22 @@ "welcome": "Welkom", "student": "leerling", "teacher": "leerkracht", - "assignments": "Opdrachten", - "classes": "Klassen", - "discussions": "Discussies", + "assignments": "opdrachten", + "classes": "klassen", + "discussions": "discussies", "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", "logoutVerification": "Bent u zeker dat u wilt uitloggen?", "homeTitle": "Onze sterke punten", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 5945a2ab..b5315634 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -10,6 +10,7 @@ import i18n from "./i18n/i18n.ts"; // Components import App from "./App.vue"; import router from "./router"; +import { aliases, mdi } from "vuetify/iconsets/mdi"; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; const app = createApp(App); @@ -24,6 +25,13 @@ document.head.appendChild(link); const vuetify = createVuetify({ components, directives, + icons: { + defaultSet: "mdi", + aliases, + sets: { + mdi, + }, + }, }); const queryClient = new QueryClient({ diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts new file mode 100644 index 00000000..3ff801b4 --- /dev/null +++ b/frontend/src/queries/learning-objects.ts @@ -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, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, +): UseQueryReturnType { + 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, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, +): UseQueryReturnType { + 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, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_OBJECT_KEY, "onPath", learningPath], + queryFn: async () => { + const learningObjects: Promise[] = []; + 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)), + }); +} diff --git a/frontend/src/queries/learning-paths.ts b/frontend/src/queries/learning-paths.ts new file mode 100644 index 00000000..3d8e6fcf --- /dev/null +++ b/frontend/src/queries/learning-paths.ts @@ -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, + language: MaybeRefOrGetter, + options?: MaybeRefOrGetter<{ forGroup?: string; forStudent?: string }>, +): UseQueryReturnType { + 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, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme], + queryFn: async () => learningPathController.getAllByTheme(toValue(theme)), + enabled: () => Boolean(toValue(theme)), + }); +} + +export function useSearchLearningPathQuery( + query: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_PATH_KEY, "search", query], + queryFn: async () => { + const queryVal = toValue(query)!; + return learningPathController.search(queryVal); + }, + enabled: () => Boolean(toValue(query)), + }); +} diff --git a/frontend/src/queries/themes.ts b/frontend/src/queries/themes.ts index a8c2a87c..a9c50f23 100644 --- a/frontend/src/queries/themes.ts +++ b/frontend/src/queries/themes.ts @@ -1,10 +1,11 @@ import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; import { getThemeController } from "@/controllers/controllers"; import { type MaybeRefOrGetter, toValue } from "vue"; +import type { Theme } from "@/data-objects/theme.ts"; const themeController = getThemeController(); -export function useThemeQuery(language: MaybeRefOrGetter): UseQueryReturnType { +export function useThemeQuery(language: MaybeRefOrGetter): UseQueryReturnType { return useQuery({ queryKey: ["themes", language], queryFn: async () => { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index dc9cd03c..23695680 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -11,8 +11,11 @@ import UserDiscussions from "@/views/discussions/UserDiscussions.vue"; import UserClasses from "@/views/classes/UserClasses.vue"; import UserAssignments from "@/views/classes/UserAssignments.vue"; 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 SingleTheme from "@/views/SingleTheme.vue"; +import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -63,9 +66,10 @@ const router = createRouter({ }, { - path: "/theme/:id", + path: "/theme/:theme", name: "Theme", component: SingleTheme, + props: true, meta: { requiresAuth: true }, }, { @@ -104,6 +108,31 @@ const router = createRouter({ component: SingleDiscussion, 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(.*)", name: "NotFound", diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client/api-client.ts similarity index 100% rename from frontend/src/services/api-client.ts rename to frontend/src/services/api-client/api-client.ts diff --git a/frontend/src/services/auth/auth-config-loader.ts b/frontend/src/services/auth/auth-config-loader.ts index 1d1b46af..83dc7608 100644 --- a/frontend/src/services/auth/auth-config-loader.ts +++ b/frontend/src/services/auth/auth-config-loader.ts @@ -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 { UserManagerSettings } from "oidc-client-ts"; diff --git a/frontend/src/services/auth/auth-service.ts b/frontend/src/services/auth/auth-service.ts index f038f545..977e1dbf 100644 --- a/frontend/src/services/auth/auth-service.ts +++ b/frontend/src/services/auth/auth-service.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 authStorage from "./auth-storage.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 type { AxiosError } from "axios"; diff --git a/frontend/src/utils/response-assertions.ts b/frontend/src/utils/response-assertions.ts new file mode 100644 index 00000000..1de05eb7 --- /dev/null +++ b/frontend/src/utils/response-assertions.ts @@ -0,0 +1,14 @@ +import { NotFoundException } from "@/exception/not-found-exception.ts"; +import { InvalidResponseException } from "@/exception/invalid-response-exception.ts"; + +export function single(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.`, + ); + } +} diff --git a/frontend/src/views/CallbackPage.vue b/frontend/src/views/CallbackPage.vue index 45a0134f..06f814e8 100644 --- a/frontend/src/views/CallbackPage.vue +++ b/frontend/src/views/CallbackPage.vue @@ -1,22 +1,25 @@ diff --git a/frontend/src/views/SingleTheme.vue b/frontend/src/views/SingleTheme.vue index 1a35a59f..6924cc1c 100644 --- a/frontend/src/views/SingleTheme.vue +++ b/frontend/src/views/SingleTheme.vue @@ -1,7 +1,68 @@ - + - + diff --git a/frontend/src/views/learning-paths/LearningObjectView.vue b/frontend/src/views/learning-paths/LearningObjectView.vue new file mode 100644 index 00000000..25fd5672 --- /dev/null +++ b/frontend/src/views/learning-paths/LearningObjectView.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue new file mode 100644 index 00000000..2a86d08d --- /dev/null +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/frontend/src/views/learning-paths/LearningPathSearchPage.vue b/frontend/src/views/learning-paths/LearningPathSearchPage.vue new file mode 100644 index 00000000..44bc0306 --- /dev/null +++ b/frontend/src/views/learning-paths/LearningPathSearchPage.vue @@ -0,0 +1,48 @@ + + + + +