Merge branch 'dev' into lint-action-setup
This commit is contained in:
commit
a4d34afcb3
32 changed files with 3116 additions and 2918 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
10
backend/config.ts
Normal 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';
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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
10
backend/src/config.ts
Normal 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';
|
60
backend/src/controllers/learningObjects.ts
Normal file
60
backend/src/controllers/learningObjects.ts
Normal 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' });
|
||||
}
|
||||
}
|
62
backend/src/controllers/learningPaths.ts
Normal file
62
backend/src/controllers/learningPaths.ts
Normal 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' });
|
||||
}
|
||||
}
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
98
backend/src/interfaces/learningPath.ts
Normal file
98
backend/src/interfaces/learningPath.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
27
backend/src/routes/learningObjects.ts
Normal file
27
backend/src/routes/learningObjects.ts
Normal 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;
|
27
backend/src/routes/learningPaths.ts
Normal file
27
backend/src/routes/learningPaths.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
134
backend/src/services/learningObjects.ts
Normal file
134
backend/src/services/learningObjects.ts
Normal 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[];
|
||||
}
|
61
backend/src/services/learningPaths.ts
Normal file
61
backend/src/services/learningPaths.ts
Normal 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 ?? [];
|
||||
}
|
43
backend/src/util/apiHelper.ts
Normal file
43
backend/src/util/apiHelper.ts
Normal 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;
|
||||
}
|
||||
}
|
19
backend/src/util/translationHelper.ts
Normal file
19
backend/src/util/translationHelper.ts
Normal 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;
|
||||
}
|
||||
}
|
BIN
frontend/public/assets/home/inclusive.png
Normal file
BIN
frontend/public/assets/home/inclusive.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 183 KiB |
BIN
frontend/public/assets/home/innovative.png
Normal file
BIN
frontend/public/assets/home/innovative.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 93 KiB |
BIN
frontend/public/assets/home/research_based.png
Normal file
BIN
frontend/public/assets/home/research_based.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/assets/home/socially_relevant.png
Normal file
BIN
frontend/public/assets/home/socially_relevant.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
20
frontend/src/utils/base64ToImage.ts
Normal file
20
frontend/src/utils/base64ToImage.ts
Normal 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}`;
|
||||
}
|
1
frontend/tests/base64/base64Sample.txt
Normal file
1
frontend/tests/base64/base64Sample.txt
Normal file
File diff suppressed because one or more lines are too long
24
frontend/tests/base64/base64ToImage.test.ts
Normal file
24
frontend/tests/base64/base64ToImage.test.ts
Normal 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
5065
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue