diff --git a/frontend/src/components/UsingRemoteResource.vue b/frontend/src/components/UsingRemoteResource.vue
new file mode 100644
index 00000000..6bc19234
--- /dev/null
+++ b/frontend/src/components/UsingRemoteResource.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json
index a1a699e5..b9a087fc 100644
--- a/frontend/src/i18n/locale/de.json
+++ b/frontend/src/i18n/locale/de.json
@@ -1,3 +1,4 @@
{
- "welcome": "Willkommen"
+ "welcome": "Willkommen",
+ "error_title": "Fehler"
}
diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json
index c75bfc5d..a04b3fa5 100644
--- a/frontend/src/i18n/locale/en.json
+++ b/frontend/src/i18n/locale/en.json
@@ -5,5 +5,6 @@
"assignments": "assignments",
"classes": "classes",
"discussions": "discussions",
- "logout": "log out"
+ "logout": "log out",
+ "error_title": "Error"
}
diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json
index 86fe964d..1d59c4dd 100644
--- a/frontend/src/i18n/locale/fr.json
+++ b/frontend/src/i18n/locale/fr.json
@@ -1,3 +1,4 @@
{
- "welcome": "Bienvenue"
+ "welcome": "Bienvenue",
+ "error_title": "Erreur"
}
diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json
index 97ec9b49..576a95b4 100644
--- a/frontend/src/i18n/locale/nl.json
+++ b/frontend/src/i18n/locale/nl.json
@@ -5,5 +5,6 @@
"assignments": "opdrachten",
"classes": "klassen",
"discussions": "discussies",
- "logout": "log uit"
+ "logout": "log uit",
+ "error_title": "Fout"
}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index e4843dae..556395c3 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";
const app = createApp(App);
@@ -23,6 +24,13 @@ document.head.appendChild(link);
const vuetify = createVuetify({
components,
directives,
+ icons: {
+ defaultSet: "mdi",
+ aliases,
+ sets: {
+ mdi
+ }
+ }
});
app.use(vuetify);
app.use(i18n);
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/api-client/api-exceptions.d.ts b/frontend/src/services/api-client/api-exceptions.d.ts
new file mode 100644
index 00000000..28df1bc1
--- /dev/null
+++ b/frontend/src/services/api-client/api-exceptions.d.ts
@@ -0,0 +1,10 @@
+import type {AxiosResponse} from "axios";
+
+export class HttpErrorStatusException extends Error {
+ public readonly statusCode: number;
+
+ constructor(response: AxiosResponse) {
+ super(`${response.statusText} (${response.status})`);
+ this.statusCode = response.status;
+ }
+}
diff --git a/frontend/src/services/api-client/endpoints/delete-endpoint.ts b/frontend/src/services/api-client/endpoints/delete-endpoint.ts
new file mode 100644
index 00000000..554e1855
--- /dev/null
+++ b/frontend/src/services/api-client/endpoints/delete-endpoint.ts
@@ -0,0 +1,10 @@
+import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts";
+import {RemoteResource} from "@/services/api-client/remote-resource.ts";
+
+export class DeleteEndpoint extends RestEndpoint {
+ readonly method = "GET";
+
+ public delete(pathParams: PP, queryParams: QP): RemoteResource {
+ return super.request(pathParams, queryParams, undefined);
+ }
+}
diff --git a/frontend/src/services/api-client/endpoints/get-endpoint.ts b/frontend/src/services/api-client/endpoints/get-endpoint.ts
new file mode 100644
index 00000000..1d9a086f
--- /dev/null
+++ b/frontend/src/services/api-client/endpoints/get-endpoint.ts
@@ -0,0 +1,10 @@
+import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts";
+import {RemoteResource} from "@/services/api-client/remote-resource.ts";
+
+export class GetEndpoint extends RestEndpoint {
+ readonly method = "GET";
+
+ public get(pathParams: PP, queryParams: QP): RemoteResource {
+ return super.request(pathParams, queryParams, undefined);
+ }
+}
diff --git a/frontend/src/services/api-client/endpoints/post-endpoint.ts b/frontend/src/services/api-client/endpoints/post-endpoint.ts
new file mode 100644
index 00000000..6fde53e8
--- /dev/null
+++ b/frontend/src/services/api-client/endpoints/post-endpoint.ts
@@ -0,0 +1,10 @@
+import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts";
+import {RemoteResource} from "@/services/api-client/remote-resource.ts";
+
+export class PostEndpoint extends RestEndpoint {
+ readonly method = "POST";
+
+ public post(pathParams: PP, queryParams: QP, body: B): RemoteResource {
+ return super.request(pathParams, queryParams, body);
+ }
+}
diff --git a/frontend/src/services/api-client/endpoints/rest-endpoint.ts b/frontend/src/services/api-client/endpoints/rest-endpoint.ts
new file mode 100644
index 00000000..3f6bb5cf
--- /dev/null
+++ b/frontend/src/services/api-client/endpoints/rest-endpoint.ts
@@ -0,0 +1,30 @@
+import {RemoteResource} from "@/services/api-client/remote-resource.ts";
+import apiClient from "@/services/api-client/api-client.ts";
+import {HttpErrorStatusException} from "@/services/api-client/api-exceptions";
+
+export abstract class RestEndpoint {
+ public abstract readonly method: "GET" | "POST" | "PUT" | "DELETE";
+ constructor(public readonly url: string) {
+ }
+
+ protected request(pathParams: PP, queryParams: QP, body: B): RemoteResource {
+ let urlFilledIn = this.url;
+ urlFilledIn.replace(/:(\w+)([/$])/g, (_, key, after) =>
+ (key in pathParams ? encodeURIComponent(pathParams[key]) : `:${key}`) + after
+ );
+ return new RemoteResource(async () => {
+ const response = await apiClient.request({
+ url: urlFilledIn,
+ method: this.method,
+ params: queryParams,
+ data: body,
+ });
+ if (response.status / 100 !== 2) {
+ throw new HttpErrorStatusException(response);
+ }
+ return response.data;
+ });
+ }
+}
+
+export type Params = {[key: string]: string | number | boolean};
diff --git a/frontend/src/services/api-client/remote-resource.ts b/frontend/src/services/api-client/remote-resource.ts
new file mode 100644
index 00000000..b1add8b9
--- /dev/null
+++ b/frontend/src/services/api-client/remote-resource.ts
@@ -0,0 +1,70 @@
+export class RemoteResource {
+ static NOT_LOADED: NotLoadedState = {type: "notLoaded"};
+ static LOADING: LoadingState = {type: "loading"};
+
+ private state: NotLoadedState | LoadingState | ErrorState | SuccessState = RemoteResource.NOT_LOADED;
+
+ constructor(private readonly requestFn: () => Promise) {
+ }
+
+ public async request(): Promise {
+ this.state = RemoteResource.LOADING;
+ try {
+ let resource = await this.requestFn();
+ this.state = {
+ type: "success",
+ data: resource
+ };
+ return resource;
+ } catch (e: any) {
+ this.state = {
+ type: "error",
+ errorCode: e.statusCode,
+ message: e.message,
+ error: e
+ };
+ }
+ }
+
+ public startRequestInBackground(): RemoteResource {
+ this.request().then();
+ return this;
+ }
+
+ public get data(): T | undefined {
+ if (this.state.type === "success") {
+ return this.state.data;
+ }
+ }
+
+ public map(mappingFn: (content: T) => U): RemoteResource {
+ return new RemoteResource(async () => {
+ await this.request();
+ if (this.state.type === "success") {
+ return mappingFn(this.state.data);
+ } else if (this.state.type === "error") {
+ throw this.state.error;
+ } else {
+ throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " +
+ "This should never happen.");
+ }
+ });
+ }
+}
+
+type NotLoadedState = {
+ type: "notLoaded"
+};
+type LoadingState = {
+ type: "loading"
+};
+type ErrorState = {
+ type: "error",
+ errorCode?: number,
+ message?: string,
+ error: any
+};
+type SuccessState = {
+ type: "success",
+ data: T
+}
diff --git a/frontend/src/services/auth/auth-config-loader.ts b/frontend/src/services/auth/auth-config-loader.ts
index ce8a33ca..ef5b63c3 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";
/**
diff --git a/frontend/src/services/auth/auth-service.ts b/frontend/src/services/auth/auth-service.ts
index 2b3d2807..2963d8f4 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 { 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/services/learning-paths/language.ts b/frontend/src/services/learning-content/language.ts
similarity index 100%
rename from frontend/src/services/learning-paths/language.ts
rename to frontend/src/services/learning-content/language.ts
diff --git a/frontend/src/services/learning-content/learning-object-service.ts b/frontend/src/services/learning-content/learning-object-service.ts
new file mode 100644
index 00000000..076a662e
--- /dev/null
+++ b/frontend/src/services/learning-content/learning-object-service.ts
@@ -0,0 +1,13 @@
+import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts";
+import type {LearningObject} from "@/services/learning-content/learning-object.ts";
+import type {Language} from "@/services/learning-content/language.ts";
+import type {RemoteResource} from "@/services/api-client/remote-resource.ts";
+
+const getLearningObjectMetadataEndpoint = new GetEndpoint<{hruid: string}, {language: Language, version: number}, LearningObject>(
+ "/learningObject/:hruid"
+);
+
+export function getLearningObjectMetadata(hruid: string, language: Language, version: number): RemoteResource {
+ return getLearningObjectMetadataEndpoint
+ .get({hruid}, {language, version});
+}
diff --git a/frontend/src/services/learning-content/learning-object.ts b/frontend/src/services/learning-content/learning-object.ts
new file mode 100644
index 00000000..ff660420
--- /dev/null
+++ b/frontend/src/services/learning-content/learning-object.ts
@@ -0,0 +1,33 @@
+import type {Language} from "@/services/learning-content/language.ts";
+
+export interface EducationalGoal {
+ source: string;
+ id: string;
+}
+
+export interface ReturnValue {
+ callback_url: string;
+ callback_schema: Record;
+}
+
+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/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts
new file mode 100644
index 00000000..be9dffd5
--- /dev/null
+++ b/frontend/src/services/learning-content/learning-path-service.ts
@@ -0,0 +1,13 @@
+import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts";
+import {LearningPath, type LearningPathDTO} from "@/services/learning-content/learning-path.ts";
+import type {RemoteResource} from "@/services/api-client/remote-resource.ts";
+
+const searchLearningPathsEndpoint = new GetEndpoint<{}, {query: string}, LearningPathDTO[]>(
+ "/learningObjects/:query"
+);
+
+export function searchLearningPaths(query: string): RemoteResource {
+ return searchLearningPathsEndpoint
+ .get({}, {query: query})
+ .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto)));
+}
diff --git a/frontend/src/services/learning-content/learning-path.ts b/frontend/src/services/learning-content/learning-path.ts
new file mode 100644
index 00000000..7aefa28e
--- /dev/null
+++ b/frontend/src/services/learning-content/learning-path.ts
@@ -0,0 +1,118 @@
+import type {Language} from "@/services/learning-content/language.ts";
+import type {RemoteResource} from "@/services/api-client/remote-resource.ts";
+import type {LearningObject} from "@/services/learning-content/learning-object.ts";
+import {getLearningObjectMetadata} from "@/services/learning-content/learning-object-service.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;
+}
+
+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.
+}
+
+interface LearningPathTransitionDTO {
+ default: boolean;
+ _id: string;
+ next: {
+ _id: string;
+ hruid: string;
+ version: number;
+ language: string;
+ };
+}
+
+export class LearningPathNode {
+ public learningObject: RemoteResource
+
+ constructor(
+ 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
+ ) {
+ this.learningObject = getLearningObjectMetadata(learningobjectHruid, language, version);
+ }
+
+ static fromDTOAndOtherNodes(dto: LearningPathNodeDTO, otherNodes: LearningPathNodeDTO[]): LearningPathNode {
+ return new LearningPathNode(
+ dto.learningobject_hruid,
+ dto.version,
+ dto.language,
+ dto.transitions.map(transDto => {
+ let nextNodeDto = otherNodes.filter(it =>
+ it.learningobject_hruid === transDto.next.hruid
+ && it.language === transDto.next.language
+ && it.version === transDto.next.version
+ );
+ if (nextNodeDto.length !== 1) {
+ throw new Error(`Invalid learning path! There is a transition to node`
+ + `${transDto.next.hruid}/${transDto.next.language}/${transDto.next.version}, but there are`
+ + `${nextNodeDto.length} such nodes.`);
+ }
+ return {
+ next: LearningPathNode.fromDTOAndOtherNodes(nextNodeDto[0], otherNodes),
+ default: transDto.default
+ }
+ }),
+ new Date(dto.created_at),
+ new Date(dto.updatedAt),
+ )
+ }
+}
+
+export class LearningPath {
+ constructor(
+ 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
+ ) {
+ }
+
+ static fromDTO(dto: LearningPathDTO): LearningPath {
+ let startNodeDto = dto.nodes.filter(it => it.start_node);
+ if (startNodeDto.length !== 1) {
+ throw new Error(`Invalid learning path! Expected precisely one start node, but there were ${startNodeDto.length}.`);
+ }
+ return new LearningPath(
+ dto.language,
+ dto.hruid,
+ dto.title,
+ dto.description,
+ dto.num_nodes,
+ dto.num_nodes_left,
+ dto.keywords.split(' '),
+ {min: dto.min_age, max: dto.max_age},
+ LearningPathNode.fromDTOAndOtherNodes(startNodeDto[0], dto.nodes)
+ )
+ }
+}
diff --git a/frontend/src/services/learning-paths/learning-object.ts b/frontend/src/services/learning-paths/learning-object.ts
deleted file mode 100644
index cde6915e..00000000
--- a/frontend/src/services/learning-paths/learning-object.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type {Language} from "@/services/learning-paths/language.ts";
-
-export interface LearningPathIdentifier {
- hruid: string;
- language: Language;
-}
-
-export interface EducationalGoal {
- source: string;
- id: string;
-}
-
-export interface ReturnValue {
- callback_url: string;
- callback_schema: Record;
-}
-
-export interface LearningObjectMetadata {
- _id: string;
- uuid: string;
- hruid: string;
- version: number;
- language: Language;
- title: string;
- description: string;
- difficulty: number;
- estimated_time: number;
- available: boolean;
- teacher_exclusive: boolean;
- educational_goals: EducationalGoal[];
- keywords: string[];
- target_ages: number[];
- content_type: string; // Markdown, image, etc.
- content_location?: string;
- skos_concepts?: string[];
- return_value?: ReturnValue;
-}
diff --git a/frontend/src/services/learning-paths/learning-path-service.ts b/frontend/src/services/learning-paths/learning-path-service.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/frontend/src/services/learning-paths/learning-path.ts b/frontend/src/services/learning-paths/learning-path.ts
deleted file mode 100644
index 2347b04e..00000000
--- a/frontend/src/services/learning-paths/learning-path.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type {Language} from "@/services/learning-paths/language.ts";
-
-export interface LearningPath {
- 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: LearningObjectNode[];
- keywords: string;
- target_ages: number[];
- min_age: number;
- max_age: number;
- __order: number;
-}
-
-export interface LearningObjectNode {
- _id: string;
- learningobject_hruid: string;
- version: number;
- language: Language;
- start_node?: boolean;
- transitions: Transition[];
- 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 Transition {
- default: boolean;
- _id: string;
- next: {
- _id: string;
- hruid: string;
- version: number;
- language: string;
- };
-}
diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue
index e9d53770..c17ca147 100644
--- a/frontend/src/views/HomePage.vue
+++ b/frontend/src/views/HomePage.vue
@@ -1,6 +1,6 @@