Merge branch 'dev' into lint-action-setup

This commit is contained in:
Tibo De Peuter 2025-03-04 21:45:46 +01:00
commit a4d34afcb3
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
32 changed files with 3116 additions and 2918 deletions

View file

@ -1,5 +1,22 @@
# translate theme pages
strengths:
title: Unsere Stärken
innovative: Innovativ
research_based: Forschungsbasiert
inclusive: Inclusiv
socially_relevant: Gesellschaftlich relevant
innovative_text: Wir fügen ständig neue Projekte hinzu und gebrauchen neue Methoden für alle unsere Projekte.
research_based_text: Alle Lernpakete basieren auf fundierter wissenschaftlicher Forschung.
inclusive_text: Wir konzentrieren uns darauf, alle Kinder zu erreichen, mit besonderem Augenmerk auf die Geschlechterinklusion und die soziale Inklusion.
socially_relevant_text: Wir suchen Projekte, die zu aktuellen Ereignissen und gesellschaftlichen Themen passen.
summary: We develop innovative workshops and educational resources, and we provide them to students around the globe in collaboration with teachers and volunteers. Our train-the-trainer sessions enable them to bring our hands-on workshops to the students.
main: Wir fügen kontinuierlich neue Projekte und Methoden zu all unseren Projekten hinzu. Für diese Projekte suchen wir immer nach einem gesellschaftlich relevanten Thema. Darüber hinaus stellen wir sicher, dass unser didaktisches Material auf wissenschaftlicher Forschung basiert, und wir achten immer auf Inklusivität.
quote:
text: Du machst etwas Praktisches, du lernst mit Hardware zu arbeiten und du kannst etwas Neues schaffen.
name: Matthias und Bruno
affiliation: 4. Jahr der weiterführenden Schule
curricula_page:
title: Unsere Unterrichtsthemen
read_more: Lees meer

View file

@ -1,5 +1,23 @@
# translate theme pages
strengths:
title: Our strengths
innovative: Innovative
research_based: Research-based
inclusive: Inclusive
socially_relevant: Socially relevant
innovative_text: We continuously add new projects and apply new methodologies in our projects.
research_based_text: All learning materials of Dwengo are based on profound scientific research.
inclusive_text: We target all children, making gender inclusion and accessibility for disadvantaged children a priority.
socially_relevant_text: We look for projects that fit current events and are socially relevant.
summary: We develop innovative workshops and educational resources, and we provide them to students around the globe in collaboration with teachers and volunteers. Our train-the-trainer sessions enable them to bring our hands-on workshops to the students.
main: We continuously add new projects and methodologies to all our projects. For these projects, we always look for a socially relevant theme. Additionally, we ensure that our didactic material is based on scientific research and always keep an eye on inclusivity.
quote:
text: You make something practical, learn how to use the hardware and create something new.
name: Matthias and Bruno
affiliation: Grade 10
curricula_page:
title: Our teaching topics
read_more: Read more

View file

@ -1,5 +1,22 @@
# translate theme pages
strengths:
title: Nos atouts
innovative: Innovatif
research_based: Fondé sur la recherche
inclusive: Inclusif
socially_relevant: Socialement pertinent
innovative_text: On ajoute fréquemment de nouveaux projets et utilise de nouvelles méthodologies dans les projets.
research_based_text: Tout le matériel de cours de Dwengo ASBL est fondé sur la recherche scientifique profonde.
inclusive_text: On se concentre à atteindre tous les enfants avec une attention particulière pour l'égalite de genre et l'inclusion sociale.
socially_relevant_text: Nous recherchons des projects qui s'inscrivent dans l'actualité et les thèmes sociaux.
summary: Nous développons des ateliers innovants et des ressources éducatives, et nous les fournissons aux étudiants du monde entier en collaboration avec les enseignants et les bénévoles.Nos séances de train-Trainer leur permettent d'amener nos ateliers pratiques aux étudiants.
main: Nous ajoutons toujours de nouveaux projets et méthodologies à tous nos projets.Nous recherchons toujours un thème socialement pertinent pour ces projets.De plus, nous nous assurons toujours que notre matériel didactique est basé sur la recherche scientifique et nous gardons également un œil sur l'inclusivité.
quote:
text: Vous faites quelque chose de pratique, vous apprenez à travailler avec du matériel et vous pouvez exécuter quelque chose de nouveau Cre & Euml;
name: Matthias en Bruno
affiliation: 4e milieu
curricula_page:
title: Notre sujets d'enseignement
read_more: 'Lees meer'

View file

@ -1,5 +1,22 @@
# translate theme pages
strengths:
title: Verrijk je lessen met AI en robotica!
innovative: Innovatief
research_based: Onderzoeksgedreven
inclusive: Inclusief
socially_relevant: Maatschappelijk relevant
innovative_text: We voegen steeds nieuwe projecten en methodieken toe aan onze projecten.
research_based_text: Alle lespakketten van Dwengo vzw zijn gebaseerd op gedegen wetenschappelijk onderzoek.
inclusive_text: We richten ons op alle kinderen en jongeren met een specifieke aandacht voor het evenwicht tussen meisjes en jongens en leerlingen uit kansengroepen.
socially_relevant_text: We zoeken projecten uit die passen binnen de actualiteit en maatschappelijke thema's.
summary: ""
main: Al onze pakketten zijn gebruiksvriendelijk, maatschappelijk relevant, wetenschappelijk onderbouwd, én inclusief. Leerkrachten over de hele wereld gingen hiermee reeds aan de slag. Jij ook? Verken de lesthema's op onze website!
quote:
text: Je maakt iets praktisch, je leert werken met hardware en je kan zelf iets nieuws creëren.
name: Matthias en Bruno
affiliation: 4e middelbaar
curricula_page:
title: Onze lesthema's
read_more: Lees meer

10
backend/config.ts Normal file
View file

@ -0,0 +1,10 @@
// Can be placed in dotenv but found it redundant
// Import dotenv from "dotenv";
// Load .env file
// Dotenv.config();
export const DWENGO_API_BASE = 'https://dwengo.org/backend/api';
export const FALLBACK_LANG = 'nl';

View file

@ -14,14 +14,15 @@
"test:unit": "vitest"
},
"dependencies": {
"@mikro-orm/core": "6.4.6",
"@mikro-orm/postgresql": "6.4.6",
"@mikro-orm/core": "^6.4.6",
"@mikro-orm/postgresql": "^6.4.6",
"@mikro-orm/reflection": "^6.4.6",
"@types/js-yaml": "^4.0.9",
"axios": "^1.8.1",
"@mikro-orm/sqlite": "6.4.6",
"@mikro-orm/reflection": "6.4.6",
"dotenv": "^16.4.7",
"express": "^5.0.1",
"uuid": "^11.1.0",
"express": "^5.0.1",
"js-yaml": "^4.1.0",
"@types/js-yaml": "^4.0.9"
},

View file

@ -3,14 +3,16 @@ import { initORM } from './orm.js';
import { EnvVars, getNumericEnvVar } from './util/envvars.js';
import themeRoutes from './routes/themes.js';
import learningPathRoutes from './routes/learningPaths.js';
import learningObjectRoutes from './routes/learningObjects.js';
import studentRouter from './routes/student';
import groupRouter from './routes/group';
import assignmentRouter from './routes/assignment';
import submissionRouter from './routes/submission';
import classRouter from './routes/class';
import questionRouter from './routes/question';
import loginRouter from './routes/login';
import studentRouter from './routes/student.js';
import groupRouter from './routes/group.js';
import assignmentRouter from './routes/assignment.js';
import submissionRouter from './routes/submission.js';
import classRouter from './routes/class.js';
import questionRouter from './routes/question.js';
import loginRouter from './routes/login.js';
const app: Express = express();
const port: string | number = getNumericEnvVar(EnvVars.Port);
@ -31,6 +33,8 @@ app.use('/question', questionRouter);
app.use('/login', loginRouter);
app.use('/theme', themeRoutes);
app.use('/learningPath', learningPathRoutes);
app.use('/learningObject', learningObjectRoutes);
async function startServer() {
await initORM();

10
backend/src/config.ts Normal file
View file

@ -0,0 +1,10 @@
// Can be placed in dotenv but found it redundant
// Import dotenv from "dotenv";
// Load .env file
// Dotenv.config();
export const DWENGO_API_BASE = 'https://dwengo.org/backend/api';
export const FALLBACK_LANG = 'nl';

View file

@ -0,0 +1,60 @@
import { Request, Response } from 'express';
import {
getLearningObjectById,
getLearningObjectIdsFromPath,
getLearningObjectsFromPath,
} from '../services/learningObjects.js';
import { FALLBACK_LANG } from '../config.js';
import { FilteredLearningObject } from '../interfaces/learningPath';
export async function getAllLearningObjects(
req: Request,
res: Response
): Promise<void> {
try {
const hruid = req.query.hruid as string;
const full = req.query.full === 'true';
const language = (req.query.language as string) || FALLBACK_LANG;
if (!hruid) {
res.status(400).json({ error: 'HRUID query is required.' });
return;
}
let learningObjects: FilteredLearningObject[] | string[];
if (full) {
learningObjects = await getLearningObjectsFromPath(hruid, language);
} else {
learningObjects = await getLearningObjectIdsFromPath(
hruid,
language
);
}
res.json(learningObjects);
} catch (error) {
console.error('Error fetching learning objects:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function getLearningObject(
req: Request,
res: Response
): Promise<void> {
try {
const { hruid } = req.params;
const language = (req.query.language as string) || FALLBACK_LANG;
if (!hruid) {
res.status(400).json({ error: 'HRUID parameter is required.' });
return;
}
const learningObject = await getLearningObjectById(hruid, language);
res.json(learningObject);
} catch (error) {
console.error('Error fetching learning object:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,62 @@
import { Request, Response } from 'express';
import { themes } from '../data/themes.js';
import { FALLBACK_LANG } from '../config.js';
import {
fetchLearningPaths,
searchLearningPaths,
} from '../services/learningPaths.js';
/**
* Fetch learning paths based on query parameters.
*/
export async function getLearningPaths(
req: Request,
res: Response
): Promise<void> {
try {
const hruids = req.query.hruid;
const themeKey = req.query.theme as string;
const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG;
let hruidList;
if (hruids) {
hruidList = Array.isArray(hruids)
? hruids.map(String)
: [String(hruids)];
} else if (themeKey) {
const theme = themes.find((t) => {
return t.title === themeKey;
});
if (theme) {
hruidList = theme.hruids;
} else {
res.status(404).json({
error: `Theme "${themeKey}" not found.`,
});
return;
}
} else if (searchQuery) {
const searchResults = await searchLearningPaths(
searchQuery,
language
);
res.json(searchResults);
return;
} else {
hruidList = themes.flatMap((theme) => {
return theme.hruids;
});
}
const learningPaths = await fetchLearningPaths(
hruidList,
language,
`HRUIDs: ${hruidList.join(', ')}`
);
res.json(learningPaths.data);
} catch (error) {
console.error('❌ Unexpected error fetching learning paths:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -1,40 +1,17 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { Request, Response } from 'express';
import { themes } from '../data/themes.js';
import { loadTranslations } from "../util/translationHelper.js";
import { FALLBACK_LANG } from '../config.js';
interface Translations {
curricula_page: {
[key: string]: { title: string; description?: string }; // Optioneel veld description
[key: string]: { title: string; description?: string };
};
}
/**
* Laadt de vertalingen uit een YAML-bestand
*/
function loadTranslations(language: string): Translations {
try {
const filePath = path.join(process.cwd(), '_i18n', `${language}.yml`);
const yamlFile = fs.readFileSync(filePath, 'utf8');
return yaml.load(yamlFile) as Translations;
} catch (error) {
console.error(
`Kan vertaling niet laden voor ${language}, fallback naar Nederlands`
);
console.error(error);
const fallbackPath = path.join(process.cwd(), '_i18n', 'nl.yml');
return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as Translations;
}
}
/**
* GET /themes Haalt de lijst met thema's op inclusief vertalingen
*/
export function getThemes(req: Request, res: Response) {
const language = (req.query.language as string)?.toLowerCase() || 'nl';
const translations = loadTranslations(language);
const translations = loadTranslations<Translations>(language);
const themeList = themes.map((theme) => {
return {
key: theme.title,
@ -48,9 +25,6 @@ export function getThemes(req: Request, res: Response) {
res.json(themeList);
}
/**
* GET /themes/:theme Geeft de HRUIDs terug voor een specifiek thema
*/
export function getThemeByTitle(req: Request, res: Response) {
const themeKey = req.params.theme;
const theme = themes.find((t) => {
@ -60,6 +34,6 @@ export function getThemeByTitle(req: Request, res: Response) {
if (theme) {
res.json(theme.hruids);
} else {
res.status(404).json({ error: 'Thema niet gevonden' });
res.status(404).json({ error: 'Theme not found' });
}
}

View file

@ -1,6 +1,6 @@
import { Entity, Enum, ManyToOne } from '@mikro-orm/core';
import { Student } from '../users/student.entity';
import { Class } from './class.entity';
import { Student } from '../users/student.entity.js';
import { Class } from './class.entity.js';
@Entity()
export class ClassJoinRequest {

View file

@ -1,6 +1,6 @@
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Question } from './question.entity';
import { Teacher } from '../users/teacher.entity';
import { Question } from './question.entity.js';
import { Teacher } from '../users/teacher.entity.js';
@Entity()
export class Answer {

View file

@ -0,0 +1,98 @@
export interface Transition {
default: boolean;
_id: string;
next: {
_id: string;
hruid: string;
version: number;
language: string;
};
}
export interface LearningObjectNode {
_id: string;
learningobject_hruid: string;
version: number;
language: string;
start_node?: boolean;
transitions: Transition[];
created_at: string;
updatedAt: string;
}
export interface LearningPath {
_id: string;
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 EducationalGoal {
source: string;
id: string;
}
export interface ReturnValue {
callback_url: string;
callback_schema: Record<string, any>;
}
export interface LearningObjectMetadata {
_id: string;
uuid: string;
hruid: string;
version: number;
language: string;
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;
}
export interface FilteredLearningObject {
key: string;
_id: string;
uuid: string;
version: number;
title: string;
htmlUrl: string;
language: string;
difficulty: number;
estimatedTime: number;
available: boolean;
teacherExclusive: boolean;
educationalGoals: EducationalGoal[];
keywords: string[];
description: string;
targetAges: number[];
contentType: string;
contentLocation?: string;
skosConcepts?: string[];
returnValue?: ReturnValue;
}
export interface LearningPathResponse {
success: boolean;
source: string;
data: LearningPath[] | null;
message?: string;
}

View file

@ -3,21 +3,45 @@ import { PostgreSqlDriver } from '@mikro-orm/postgresql';
import { EnvVars, getEnvVar, getNumericEnvVar } from './util/envvars.js';
import { SqliteDriver } from '@mikro-orm/sqlite';
const entities = ['dist/**/*.entity.js'];
const entitiesTs = ['src/**/*.entity.ts'];
// Import alle entity-bestanden handmatig
import { User } from './entities/users/user.entity.js';
import { Student } from './entities/users/student.entity.js';
import { Teacher } from './entities/users/teacher.entity.js';
import { Assignment } from './entities/assignments/assignment.entity.js';
import { Group } from './entities/assignments/group.entity.js';
import { Submission } from './entities/assignments/submission.entity.js';
import { Class } from './entities/classes/class.entity.js';
import { ClassJoinRequest } from './entities/classes/class-join-request.entity.js';
import { TeacherInvitation } from './entities/classes/teacher-invitation.entity.js';
import { Attachment } from './entities/content/attachment.entity.js';
import { LearningObject } from './entities/content/learning-object.entity.js';
import { LearningPath } from './entities/content/learning-path.entity.js';
import { Answer } from './entities/questions/answer.entity.js';
import { Question } from './entities/questions/question.entity.js';
const entities = [
User, Student, Teacher,
Assignment, Group, Submission,
Class, ClassJoinRequest, TeacherInvitation,
Attachment, LearningObject, LearningPath,
Answer, Question
];
function config(testingMode: boolean = false): Options {
if (testingMode) {
return {
driver: SqliteDriver,
dbName: getEnvVar(EnvVars.DbName),
entities: entities,
entitiesTs: entitiesTs,
// entitiesTs: entitiesTs,
// Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
// (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint)
dynamicImportProvider: (id) => {
return import(id);
},
dynamicImportProvider: (id) => import(id),
};
}
return {
@ -28,7 +52,7 @@ function config(testingMode: boolean = false): Options {
user: getEnvVar(EnvVars.DbUsername),
password: getEnvVar(EnvVars.DbPassword),
entities: entities,
entitiesTs: entitiesTs,
//entitiesTs: entitiesTs,
debug: true,
};
}

View file

@ -0,0 +1,27 @@
import express from 'express';
import {
getAllLearningObjects,
getLearningObject,
} from '../controllers/learningObjects.js';
const router = express.Router();
// DWENGO learning objects
// Queries: hruid(path), full, language
// Route to fetch list of learning objects based on hruid of learning path
// Route 1: list of object hruids
// Example 1: http://localhost:3000/learningObject?hruid=un_artificiele_intelligentie
// Route 2: list of object data
// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
router.get('/', getAllLearningObjects);
// Parameter: hruid of learning object
// Query: language
// Route to fetch data of one learning object based on its hruid
// Example: http://localhost:3000/learningObject/un_ai7
router.get('/:hruid', getLearningObject);
export default router;

View file

@ -0,0 +1,27 @@
import express from 'express';
import { getLearningPaths } from '../controllers/learningPaths.js';
const router = express.Router();
// DWENGO learning paths
// Route 1: no query
// Fetch all learning paths
// Example 1: http://localhost:3000/learningPath
// Unified route for fetching learning paths
// Route 2: Query: hruid (list), language
// Fetch learning paths based on hruid list
// Example 2: http://localhost:3000/learningPath?hruid=pn_werking&hruid=art1
// Query: search, language
// Route to fetch learning paths based on a searchterm
// Example 3: http://localhost:3000/learningPath?search=robot
// Query: theme, anguage
// Route to fetch learning paths based on a theme
// Example: http://localhost:3000/learningPath?theme=kiks
router.get('/', getLearningPaths);
export default router;

View file

@ -3,7 +3,12 @@ import { getThemes, getThemeByTitle } from '../controllers/themes.js';
const router = express.Router();
// Query: language
// Route to fetch list of {key, title, description, image} themes in their respective language
router.get('/', getThemes);
// Arg: theme (key)
// Route to fetch list of hruids based on theme
router.get('/:theme', getThemeByTitle);
export default router;

View file

@ -0,0 +1,134 @@
import { DWENGO_API_BASE } from '../config.js';
import { fetchWithLogging } from '../util/apiHelper.js';
import {
FilteredLearningObject,
LearningObjectMetadata,
LearningObjectNode,
LearningPathResponse,
} from '../interfaces/learningPath.js';
import { fetchLearningPaths } from './learningPaths.js';
function filterData(
data: LearningObjectMetadata,
htmlUrl: string
): FilteredLearningObject {
return {
key: data.hruid, // Hruid learningObject (not path)
_id: data._id,
uuid: data.uuid,
version: data.version,
title: data.title,
htmlUrl, // Url to fetch html content
language: data.language,
difficulty: data.difficulty,
estimatedTime: data.estimated_time,
available: data.available,
teacherExclusive: data.teacher_exclusive,
educationalGoals: data.educational_goals, // List with learningObjects
keywords: data.keywords, // For search
description: data.description, // For search (not an actual description)
targetAges: data.target_ages,
contentType: data.content_type, // Markdown, image, audio, etc.
contentLocation: data.content_location, // If content type extern
skosConcepts: data.skos_concepts,
returnValue: data.return_value, // Callback response information
};
}
/**
* Fetches a single learning object by its HRUID
*/
export async function getLearningObjectById(
hruid: string,
language: string
): Promise<FilteredLearningObject | null> {
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`;
const metadata = await fetchWithLogging<LearningObjectMetadata>(
metadataUrl,
`Metadata for Learning Object HRUID "${hruid}" (language ${language})`
);
if (!metadata) {
console.error(`⚠️ WARNING: Learning object "${hruid}" not found.`);
return null;
}
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`;
return filterData(metadata, htmlUrl);
}
/**
* Generic function to fetch learning objects (full data or just HRUIDs)
*/
async function fetchLearningObjects(
hruid: string,
full: boolean,
language: string
): Promise<FilteredLearningObject[] | string[]> {
try {
const learningPathResponse: LearningPathResponse =
await fetchLearningPaths(
[hruid],
language,
`Learning path for HRUID "${hruid}"`
);
if (
!learningPathResponse.success ||
!learningPathResponse.data?.length
) {
console.error(
`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`
);
return [];
}
const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes;
if (!full) {
return nodes.map((node) => {
return node.learningobject_hruid;
});
}
return await Promise.all(
nodes.map(async (node) => {
return getLearningObjectById(
node.learningobject_hruid,
language
);
})
).then((objects) => {
return objects.filter((obj): obj is FilteredLearningObject => {
return obj !== null;
});
});
} catch (error) {
console.error('❌ Error fetching learning objects:', error);
return [];
}
}
/**
* Fetch full learning object data (metadata)
*/
export async function getLearningObjectsFromPath(
hruid: string,
language: string
): Promise<FilteredLearningObject[]> {
return (await fetchLearningObjects(
hruid,
true,
language
)) as FilteredLearningObject[];
}
/**
* Fetch only learning object HRUIDs
*/
export async function getLearningObjectIdsFromPath(
hruid: string,
language: string
): Promise<string[]> {
return (await fetchLearningObjects(hruid, false, language)) as string[];
}

View file

@ -0,0 +1,61 @@
import { fetchWithLogging } from '../util/apiHelper.js';
import { DWENGO_API_BASE } from '../config.js';
import {
LearningPath,
LearningPathResponse,
} from '../interfaces/learningPath.js';
export async function fetchLearningPaths(
hruids: string[],
language: string,
source: string
): Promise<LearningPathResponse> {
if (hruids.length === 0) {
return {
success: false,
source,
data: null,
message: `No HRUIDs provided for ${source}.`,
};
}
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(
apiUrl,
`Learning paths for ${source}`,
params
);
if (!learningPaths || learningPaths.length === 0) {
console.error(`⚠️ WARNING: No learning paths found for ${source}.`);
return {
success: false,
source,
data: [],
message: `No learning paths found for ${source}.`,
};
}
return {
success: true,
source,
data: learningPaths,
};
}
export async function searchLearningPaths(
query: string,
language: string
): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(
apiUrl,
`Search learning paths with query "${query}"`,
params
);
return searchResults ?? [];
}

View file

@ -0,0 +1,43 @@
import axios, { AxiosRequestConfig } from 'axios';
// !!!! when logger is done -> change
/**
* Utility function to fetch data from an API endpoint with error handling.
* Logs errors but does NOT throw exceptions to keep the system running.
*
* @param url The API endpoint to fetch from.
* @param description A short description of what is being fetched (for logging).
* @param params
* @returns The response data if successful, or null if an error occurs.
*/
export async function fetchWithLogging<T>(
url: string,
description: string,
params?: Record<string, any>
): Promise<T | null> {
try {
const config: AxiosRequestConfig = params ? { params } : {};
const response = await axios.get<T>(url, config);
return response.data;
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(
`❌ ERROR: ${description} not found (404) at "${url}".`
);
} else {
console.error(
`❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")`
);
}
} else {
console.error(
`❌ ERROR: Network or unexpected error when fetching ${description}:`,
error.message
);
}
return null;
}
}

View file

@ -0,0 +1,19 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import {FALLBACK_LANG} from "../../config";
export function loadTranslations<T>(language: string): T {
try {
const filePath = path.join(process.cwd(), '_i18n', `${language}.yml`);
const yamlFile = fs.readFileSync(filePath, 'utf8');
return yaml.load(yamlFile) as T;
} catch (error) {
console.error(
`Cannot load translation for ${language}, fallen back to dutch`
);
console.error(error);
const fallbackPath = path.join(process.cwd(), '_i18n', `${FALLBACK_LANG}.yml`);
return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as T;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -1,7 +1,186 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref } from "vue";
import { useRoute } from "vue-router";
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
const route = useRoute();
// Instantiate variables to use in html to render right
// Links and content dependent on the role (student or teacher)
const isTeacher = route.path.includes("teacher");
const userId = route.params.id as string;
const role = isTeacher ? "teacher" : "student";
const name = "Kurt Cobain";
const initials = name
.split(" ")
.map((n) => {
return n[0];
})
.join("");
const languages = ref([
{ name: "English", code: "en" },
{ name: "Nederlands", code: "nl" },
]);
// Logic to change the language of the website to the selected language
const changeLanguage = (langCode: string) => {
console.log(langCode);
};
</script>
<template>
<main></main>
<main>
<nav class="menu">
<div class="left">
<ul>
<li>
<router-link
:to="`/${role}/${userId}`"
class="dwengo_home"
>
<img
class="dwengo_logo"
:src="dwengoLogo"
/>
<p class="caption">
{{ role }}
</p>
</router-link>
</li>
<li>
<router-link
:to="`/${role}/${userId}/assignment`"
class="menu_item"
>
assignments
</router-link>
</li>
<li>
<router-link
:to="`/${role}/${userId}/class`"
class="menu_item"
>classes</router-link
>
</li>
<li>
<router-link
:to="`/${role}/${userId}/discussion`"
class="menu_item"
>discussions</router-link
>
</li>
<li>
<v-menu open-on-hover>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
>
<v-icon
icon="mdi-translate"
size="small"
color="#0e6942"
></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(language, index) in languages"
:key="index"
@click="changeLanguage(language.code)"
>
<v-list-item-title>{{ language.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</li>
</ul>
</div>
<div class="right">
<li>
<router-link :to="`/login`">
<v-tooltip
text="log out"
location="bottom"
>
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-logout"
size="x-large"
color="#0e6942"
></v-icon>
</template>
</v-tooltip>
</router-link>
</li>
<li>
<v-avatar
size="large"
color="#0e6942"
style="font-size: large; font-weight: bold"
>{{ initials }}</v-avatar
>
</li>
</div>
</nav>
</main>
</template>
<style scoped></style>
<style scoped>
.menu {
background-color: #f6faf2;
display: flex;
justify-content: space-between;
}
.right {
align-items: center;
padding: 10px;
}
.right li {
margin-left: 15px;
}
nav ul {
display: flex;
list-style-type: none;
margin: 0;
padding: 0;
gap: 15px;
align-items: center;
}
li {
display: inline;
}
.dwengo_home {
text-align: center;
text-decoration: none;
}
.dwengo_logo {
width: 150px;
}
.caption {
color: black;
margin-top: -25px;
}
.menu_item {
color: #0e6942;
text-decoration: none;
font-size: large;
}
nav a.router-link-active {
font-weight: bold;
}
</style>

View file

@ -14,6 +14,11 @@ const app = createApp(App);
app.use(router);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css";
document.head.appendChild(link);
const vuetify = createVuetify({
components,
directives,

View file

@ -0,0 +1,20 @@
/**
* Converts a Base64 string to a valid image source URL.
*
* @param base64String - The "image" field from the learning path JSON response.
* @returns A properly formatted data URL for use in an <img> tag.
*
* @example
* // Fetch the learning path data and extract the image
* const response = await fetch( learning path route );
* const data = await response.json();
* const base64String = data.image;
*
* // Use in an <img> element
* <img :src="convertBase64ToImageSrc(base64String)" alt="Learning Path Image" />
*/
export function convertBase64ToImageSrc(base64String: string): string {
return base64String.startsWith("data:image")
? base64String
: `data:image/png;base64,${base64String}`;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,24 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { convertBase64ToImageSrc } from '../../src/utils/base64ToImage.js';
import fs from 'fs';
import path from 'path';
let sampleBase64: string;
beforeAll(() => {
// Load base64 sample from text file
const filePath = path.resolve(__dirname, 'base64Sample.txt');
sampleBase64 = fs.readFileSync(filePath, 'utf8').trim();
});
describe('convertBase64ToImageSrc', () => {
it('should return the same string if it is already a valid data URL', () => {
const base64Image = `data:image/png;base64,${sampleBase64}`;
expect(convertBase64ToImageSrc(base64Image)).toBe(base64Image);
});
it('should correctly format a raw Base64 string as a PNG image URL', () => {
expect(convertBase64ToImageSrc(sampleBase64)).toBe(`data:image/png;base64,${sampleBase64}`);
});
});

5065
package-lock.json generated

File diff suppressed because it is too large Load diff