Merge branch 'dev' into github-actions/testing
This commit is contained in:
commit
05fa51603a
236 changed files with 10405 additions and 2452 deletions
36
frontend/Dockerfile
Normal file
36
frontend/Dockerfile
Normal file
|
@ -0,0 +1,36 @@
|
|||
FROM node:22 AS build-stage
|
||||
|
||||
# install simple http server for serving static content
|
||||
RUN npm install -g http-server
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
|
||||
COPY package*.json ./
|
||||
COPY ./frontend/package.json ./frontend/
|
||||
|
||||
RUN npm install --silent
|
||||
|
||||
# Build the frontend
|
||||
|
||||
# Root tsconfig.json
|
||||
COPY tsconfig.json ./
|
||||
COPY assets ./assets/
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend ./
|
||||
|
||||
RUN npx vite build
|
||||
|
||||
FROM nginx:stable AS production-stage
|
||||
|
||||
COPY config/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
COPY --from=build-stage /app/assets /usr/share/nginx/html/assets
|
||||
COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
|
@ -19,7 +19,16 @@ See [Vite Configuration Reference](https://vite.dev/config/).
|
|||
## Project Setup
|
||||
|
||||
```sh
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start necessary services for development
|
||||
cd ../ # Go to the root of the repository
|
||||
docker compose up -d
|
||||
# Start the backend
|
||||
cd backend
|
||||
cp .env.development.example .env.development.local
|
||||
npm run dev # or npm run build && npm run start
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dwengo-1-frontend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.1",
|
||||
"description": "Frontend for Dwengo-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
@ -16,11 +16,14 @@
|
|||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vuetify": "^3.7.12",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@tanstack/vue-query": "^5.69.0",
|
||||
"axios": "^1.8.2",
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"axios": "^1.8.2"
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"vuetify": "^3.7.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
|
@ -39,7 +42,7 @@
|
|||
"jsdom": "^26.0.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vite": "^6.1.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.2",
|
||||
"vitest": "^3.0.5",
|
||||
"vue-tsc": "^2.2.2"
|
||||
|
|
|
@ -1,10 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
auth.loadUser();
|
||||
import MenuBar from "@/components/MenuBar.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed } from "vue";
|
||||
|
||||
const route = useRoute();
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user);
|
||||
|
||||
auth.loadUser().catch((_error) => {
|
||||
// TODO Could not load user!
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<v-app>
|
||||
<menu-bar v-if="showMenuBar"></menu-bar>
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
@ -1,9 +1,80 @@
|
|||
<script setup lang="ts">
|
||||
// This component contains a list with all themes and will be shown on a student's and teacher's homepage.
|
||||
import ThemeCard from "@/components/ThemeCard.vue";
|
||||
import { ref, watchEffect, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts";
|
||||
import { useThemeQuery } from "@/queries/themes.ts";
|
||||
|
||||
const props = defineProps({
|
||||
selectedTheme: { type: String, required: true },
|
||||
selectedAge: { type: String, required: true },
|
||||
});
|
||||
|
||||
const { locale } = useI18n();
|
||||
const language = computed(() => locale.value);
|
||||
|
||||
const { data: allThemes, isLoading, error } = useThemeQuery(language);
|
||||
|
||||
const allCards = ref([]);
|
||||
const cards = ref([]);
|
||||
|
||||
watchEffect(() => {
|
||||
const themes = allThemes.value ?? [];
|
||||
allCards.value = themes;
|
||||
|
||||
if (props.selectedTheme) {
|
||||
cards.value = themes.filter(
|
||||
(theme) =>
|
||||
THEMESITEMS[props.selectedTheme]?.includes(theme.key) &&
|
||||
AGE_TO_THEMES[props.selectedAge]?.includes(theme.key),
|
||||
);
|
||||
} else {
|
||||
cards.value = themes;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
<v-container>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="text-center py-10"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="text-center py-10 text-error"
|
||||
>
|
||||
<v-icon large>mdi-alert-circle</v-icon>
|
||||
<p>Error loading: {{ error.message }}</p>
|
||||
</div>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="4"
|
||||
class="d-flex"
|
||||
>
|
||||
<ThemeCard
|
||||
:path="card.key"
|
||||
:title="card.title"
|
||||
:description="card.description"
|
||||
:image="card.image"
|
||||
class="fill-height"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
@ -1,150 +1,210 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const route = useRoute();
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
|
||||
// Import assets
|
||||
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
// 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 role = auth.authState.activeRole;
|
||||
|
||||
const userId = route.params.id as string;
|
||||
|
||||
const role = isTeacher ? "teacher" : "student";
|
||||
const name = "Kurt Cobain";
|
||||
const initials = name
|
||||
const name: string = auth.authState.user!.profile.name!;
|
||||
const initials: string = name
|
||||
.split(" ")
|
||||
.map((n) => {
|
||||
return n[0];
|
||||
})
|
||||
.map((n) => n[0])
|
||||
.join("");
|
||||
|
||||
// Available languages
|
||||
const languages = ref([
|
||||
{ name: "English", code: "en" },
|
||||
{ name: "Nederlands", code: "nl" },
|
||||
{ name: "Français", code: "fr" },
|
||||
{ name: "Deutsch", code: "de" },
|
||||
]);
|
||||
|
||||
// Logic to change the language of the website to the selected language
|
||||
const changeLanguage = (langCode: string) => {
|
||||
function changeLanguage(langCode: string): void {
|
||||
locale.value = langCode;
|
||||
localStorage.setItem("user-lang", langCode);
|
||||
console.log(langCode);
|
||||
};
|
||||
}
|
||||
|
||||
// Contains functionality to let the collapsed menu appear and disappear when the screen size varies
|
||||
const drawer = ref(false);
|
||||
|
||||
// When the user wants to logout, a popup is shown to verify this
|
||||
// If verified, the user should be logged out
|
||||
async function performLogout(): Promise<void> {
|
||||
await auth.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{ t(`${role}`) }}
|
||||
</p>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="`/${role}/${userId}/assignment`"
|
||||
class="menu_item"
|
||||
>
|
||||
{{ t("assignments") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="`/${role}/${userId}/class`"
|
||||
class="menu_item"
|
||||
>{{ t("classes") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="`/${role}/${userId}/discussion`"
|
||||
class="menu_item"
|
||||
>{{ t("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>
|
||||
<v-app-bar
|
||||
class="app-bar"
|
||||
app
|
||||
>
|
||||
<v-app-bar-nav-icon
|
||||
class="menu_collapsed"
|
||||
@click="drawer = !drawer"
|
||||
/>
|
||||
<router-link
|
||||
to="/user"
|
||||
class="dwengo_home"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
class="dwengo_logo"
|
||||
alt="Dwengo logo"
|
||||
:src="dwengoLogo"
|
||||
/>
|
||||
<p class="caption">
|
||||
{{ t(`${role}`) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="right">
|
||||
<li>
|
||||
<router-link :to="`/login`">
|
||||
<v-tooltip
|
||||
:text="t('logout')"
|
||||
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
|
||||
</router-link>
|
||||
<v-toolbar-items class="menu">
|
||||
<v-btn
|
||||
class="menu_item"
|
||||
variant="text"
|
||||
to="/user/assignment"
|
||||
>
|
||||
{{ t("assignments") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="menu_item"
|
||||
variant="text"
|
||||
to="/user/class"
|
||||
>
|
||||
{{ t("classes") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="menu_item"
|
||||
variant="text"
|
||||
to="/user/discussion"
|
||||
>
|
||||
{{ t("discussions") }}
|
||||
</v-btn>
|
||||
<v-menu open-on-hover>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
>
|
||||
</li>
|
||||
</div>
|
||||
</nav>
|
||||
</main>
|
||||
<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>
|
||||
</v-toolbar-items>
|
||||
<v-spacer></v-spacer>
|
||||
<v-dialog max-width="500">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
:rounded="true"
|
||||
variant="text"
|
||||
>
|
||||
<v-tooltip
|
||||
:text="t('logout')"
|
||||
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>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card :title="t('logoutVerification')">
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn
|
||||
:text="t('cancel')"
|
||||
@click="isActive.value = false"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
:text="t('logout')"
|
||||
@click="performLogout"
|
||||
to="/login"
|
||||
></v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
<v-avatar
|
||||
size="large"
|
||||
color="#0e6942"
|
||||
class="user-button"
|
||||
>{{ initials }}</v-avatar
|
||||
>
|
||||
</v-app-bar>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
temporary
|
||||
app
|
||||
>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
to="/user/assignment"
|
||||
link
|
||||
>
|
||||
<v-list-item-title class="menu_item">{{ t("assignments") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
to="/user/class"
|
||||
link
|
||||
>
|
||||
<v-list-item-title class="menu_item">{{ t("classes") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
to="/user/discussion"
|
||||
link
|
||||
>
|
||||
<v-list-item-title class="menu_item">{{ t("discussions") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-bar {
|
||||
background-color: #f6faf2;
|
||||
}
|
||||
.menu {
|
||||
background-color: #f6faf2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.right {
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
.user-button {
|
||||
margin-right: 10px;
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.right li {
|
||||
|
@ -182,9 +242,24 @@
|
|||
color: #0e6942;
|
||||
text-decoration: none;
|
||||
font-size: large;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
nav a.router-link-active {
|
||||
font-weight: bold;
|
||||
@media (max-width: 700px) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
.caption {
|
||||
font-size: smaller;
|
||||
}
|
||||
.dwengo_logo {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 701px) {
|
||||
.menu_collapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
77
frontend/src/components/ThemeCard.vue
Normal file
77
frontend/src/components/ThemeCard.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps<{
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="theme-card d-flex flex-column"
|
||||
:to="`theme/${path}`"
|
||||
link
|
||||
>
|
||||
<v-card-title class="title-container">
|
||||
<v-img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
height="40px"
|
||||
width="40px"
|
||||
contain
|
||||
class="title-image"
|
||||
></v-img>
|
||||
<span class="title">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="description flex-grow-1">{{ description }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
:to="`theme/${path}`"
|
||||
variant="text"
|
||||
>
|
||||
{{ t("read-more") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.title-image {
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex-grow: 1;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,8 @@
|
|||
export const apiConfig = {
|
||||
baseUrl: window.location.hostname == "localhost" ? "http://localhost:3000" : window.location.origin,
|
||||
baseUrl:
|
||||
window.location.hostname === "localhost" && !(window.location.port === "80" || window.location.port === "")
|
||||
? "http://localhost:3000/api"
|
||||
: window.location.origin + "/api",
|
||||
};
|
||||
|
||||
export const loginRoute = "/login";
|
||||
|
|
73
frontend/src/controllers/base-controller.ts
Normal file
73
frontend/src/controllers/base-controller.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { apiConfig } from "@/config.ts";
|
||||
|
||||
export class BaseController {
|
||||
protected baseUrl: string;
|
||||
|
||||
constructor(basePath: string) {
|
||||
this.baseUrl = `${apiConfig.baseUrl}/${basePath}`;
|
||||
}
|
||||
|
||||
protected async get<T>(path: string, queryParams?: Record<string, string | number | boolean>): Promise<T> {
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (queryParams) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
query.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
url += `?${query.toString()}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
protected async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
protected async delete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
protected async put<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
}
|
14
frontend/src/controllers/controllers.ts
Normal file
14
frontend/src/controllers/controllers.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { ThemeController } from "@/controllers/themes.ts";
|
||||
|
||||
export function controllerGetter<T>(factory: new () => T): () => T {
|
||||
let instance: T | undefined;
|
||||
|
||||
return (): T => {
|
||||
if (!instance) {
|
||||
instance = new factory();
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
}
|
||||
|
||||
export const getThemeController = controllerGetter(ThemeController);
|
16
frontend/src/controllers/themes.ts
Normal file
16
frontend/src/controllers/themes.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { BaseController } from "@/controllers/base-controller.ts";
|
||||
|
||||
export class ThemeController extends BaseController {
|
||||
constructor() {
|
||||
super("theme");
|
||||
}
|
||||
|
||||
async getAll(language: string | null = null): Promise<unknown> {
|
||||
const query = language ? { language } : undefined;
|
||||
return this.get("/", query);
|
||||
}
|
||||
|
||||
async getHruidsByKey(themeKey: string): Promise<string[]> {
|
||||
return this.get<string[]>(`/${encodeURIComponent(themeKey)}`);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,43 @@
|
|||
{
|
||||
"welcome": "Willkommen"
|
||||
"welcome": "Willkommen",
|
||||
"student": "schüler",
|
||||
"teacher": "lehrer",
|
||||
"assignments": "Aufgaben",
|
||||
"classes": "Klasses",
|
||||
"discussions": "Diskussionen",
|
||||
"login": "einloggen",
|
||||
"logout": "ausloggen",
|
||||
"cancel": "kündigen",
|
||||
"logoutVerification": "Sind Sie sicher, dass Sie sich abmelden wollen?",
|
||||
"homeTitle": "Unsere Stärken",
|
||||
"homeIntroduction1": "Wir entwickeln innovative Workshops und Bildungsressourcen, die wir in Zusammenarbeit mit Lehrern und Freiwilligen Schülern auf der ganzen Welt zur Verfügung stellen. Unsere Train-the-Trainer-Sitzungen ermöglichen es ihnen, unsere praktischen Workshops an die Schüler weiterzugeben.",
|
||||
"homeIntroduction2": "Wir fügen allen unseren Projekten ständig neue Projekte und Methoden 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 achten stets auf die Inklusion.",
|
||||
"innovative": "Innovativ",
|
||||
"researchBased": "Forschungsbasiert",
|
||||
"inclusive": "Inclusiv",
|
||||
"sociallyRelevant": "Gesellschaftlich relevant",
|
||||
"translate": "übersetzen",
|
||||
"themes": "Themen",
|
||||
"choose-theme": "Wähle ein thema",
|
||||
"choose-age": "Alter auswählen",
|
||||
"theme-options": {
|
||||
"all": "Alle themen",
|
||||
"culture": "Kultur",
|
||||
"electricity-and-mechanics": "Elektrizität und Mechanik",
|
||||
"nature-and-climate": "Natur und Klima",
|
||||
"agriculture": "Landwirtschaft",
|
||||
"society": "Gesellschaft",
|
||||
"math": "Mathematik",
|
||||
"technology": "Technologie",
|
||||
"algorithms": "Algorithmisches Denken"
|
||||
},
|
||||
"age-options": {
|
||||
"all": "Alle altersgruppen",
|
||||
"primary-school": "Grundschule",
|
||||
"lower-secondary": "12-14 jahre alt",
|
||||
"upper-secondary": "14-16 jahre alt",
|
||||
"high-school": "16-18 jahre alt",
|
||||
"older": "18 und älter"
|
||||
},
|
||||
"read-more": "Mehr lesen"
|
||||
}
|
||||
|
|
|
@ -2,8 +2,42 @@
|
|||
"welcome": "Welcome",
|
||||
"student": "student",
|
||||
"teacher": "teacher",
|
||||
"assignments": "assignments",
|
||||
"classes": "classes",
|
||||
"discussions": "discussions",
|
||||
"logout": "log out"
|
||||
"assignments": "Assignments",
|
||||
"classes": "Classes",
|
||||
"discussions": "Discussions",
|
||||
"logout": "log out",
|
||||
"cancel": "cancel",
|
||||
"logoutVerification": "Are you sure you want to log out?",
|
||||
"homeTitle": "Our strengths",
|
||||
"homeIntroduction1": "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.",
|
||||
"homeIntroduction2": "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.",
|
||||
"innovative": "Innovative",
|
||||
"researchBased": "Research-based",
|
||||
"inclusive": "Inclusive",
|
||||
"sociallyRelevant": "Socially relevant",
|
||||
"login": "log in",
|
||||
"translate": "translate",
|
||||
"themes": "Themes",
|
||||
"choose-theme": "Select a theme",
|
||||
"choose-age": "Select age",
|
||||
"theme-options": {
|
||||
"all": "All themes",
|
||||
"culture": "Culture",
|
||||
"electricity-and-mechanics": "Electricity and mechanics",
|
||||
"nature-and-climate": "Nature and climate",
|
||||
"agriculture": "Agriculture",
|
||||
"society": "Society",
|
||||
"math": "Math",
|
||||
"technology": "Technology",
|
||||
"algorithms": "Algorithms"
|
||||
},
|
||||
"age-options": {
|
||||
"all": "All ages",
|
||||
"primary-school": "Primary school",
|
||||
"lower-secondary": "12-14 years old",
|
||||
"upper-secondary": "14-16 years old",
|
||||
"high-school": "16-18 years old",
|
||||
"older": "18 and older"
|
||||
},
|
||||
"read-more": "Read more"
|
||||
}
|
||||
|
|
|
@ -1,3 +1,43 @@
|
|||
{
|
||||
"welcome": "Bienvenue"
|
||||
"welcome": "Bienvenue",
|
||||
"student": "élève",
|
||||
"teacher": "enseignant",
|
||||
"assignments": "Travails",
|
||||
"classes": "Classes",
|
||||
"discussions": "Discussions",
|
||||
"login": "se connecter",
|
||||
"logout": "se déconnecter",
|
||||
"cancel": "annuler",
|
||||
"logoutVerification": "Êtes-vous sûr de vouloir vous déconnecter ?",
|
||||
"homeTitle": "Nos atouts",
|
||||
"homeIntroduction1": "Nous développons des ateliers innovants et des ressources éducatives que nous mettons à la disposition des élèves du monde entier en collaboration avec des enseignants et des bénévoles. Nos sessions de formation des formateurs leur permettent d'offrir nos ateliers pratiques aux élèves.",
|
||||
"homeIntroduction2": "Nous ajoutons continuellement de nouveaux projets et de nouvelles méthodologies à tous nos projets. Pour ces projets, nous recherchons toujours un thème socialement pertinent. En outre, nous veillons à ce que notre matériel didactique soit basé sur la recherche scientifique et nous gardons toujours un œil sur l'inclusivité.",
|
||||
"innovative": "Innovatif",
|
||||
"researchBased": "Fondé sur la recherche",
|
||||
"inclusive": "Inclusif",
|
||||
"sociallyRelevant": "Socialement pertinent",
|
||||
"translate": "traduire",
|
||||
"themes": "Thèmes",
|
||||
"choose-theme": "Choisis un thème",
|
||||
"choose-age": "Choisis un âge",
|
||||
"theme-options": {
|
||||
"all": "Tous les thèmes",
|
||||
"culture": "Culture",
|
||||
"electricity-and-mechanics": "Electricité et méchanique",
|
||||
"nature-and-climate": "Nature et climat",
|
||||
"agriculture": "Agriculture",
|
||||
"society": "Société",
|
||||
"math": "Math",
|
||||
"technology": "Technologie",
|
||||
"algorithms": "Algorithmes"
|
||||
},
|
||||
"age-options": {
|
||||
"all": "Tous les âges",
|
||||
"primary-school": "Ecole primaire",
|
||||
"lower-secondary": "12-14 ans",
|
||||
"upper-secondary": "14-16 ans",
|
||||
"high-school": "16-18 ans",
|
||||
"older": "18 et plus"
|
||||
},
|
||||
"read-more": "En savoir plus"
|
||||
}
|
||||
|
|
|
@ -2,8 +2,42 @@
|
|||
"welcome": "Welkom",
|
||||
"student": "leerling",
|
||||
"teacher": "leerkracht",
|
||||
"assignments": "opdrachten",
|
||||
"classes": "klassen",
|
||||
"discussions": "discussies",
|
||||
"logout": "log uit"
|
||||
"assignments": "Opdrachten",
|
||||
"classes": "Klassen",
|
||||
"discussions": "Discussies",
|
||||
"logout": "log uit",
|
||||
"cancel": "annuleren",
|
||||
"logoutVerification": "Bent u zeker dat u wilt uitloggen?",
|
||||
"homeTitle": "Onze sterke punten",
|
||||
"homeIntroduction1": "We ontwikkelen innovatieve workshops en leermiddelen en bieden deze aan studenten over de hele wereld in samenwerking met leerkrachten en vrijwilligers. Onze train-de-trainer sessies stellen hen in staat om onze hands-on workshops naar de leerlingen te brengen.",
|
||||
"homeIntroduction2": "We voegen voortdurend nieuwe projecten en methodologieën toe aan al onze projecten. Voor deze projecten zoeken we altijd een maatschappelijk relevant thema. Daarnaast zorgen we ervoor dat ons didactisch materiaal gebaseerd is op wetenschappelijk onderzoek en houden we inclusiviteit altijd in het oog.",
|
||||
"innovative": "Innovatief",
|
||||
"researchBased": "Onderzoeksgedreven",
|
||||
"inclusive": "Inclusief",
|
||||
"sociallyRelevant": "Maatschappelijk relevant",
|
||||
"login": "log in",
|
||||
"translate": "vertalen",
|
||||
"themes": "Lesthema's",
|
||||
"choose-theme": "Kies een thema",
|
||||
"choose-age": "Kies een leeftijd",
|
||||
"theme-options": {
|
||||
"all": "Alle thema's",
|
||||
"culture": "Taal en kunst",
|
||||
"electricity-and-mechanics": "Elektriciteit en mechanica",
|
||||
"nature-and-climate": "Natuur en klimaat",
|
||||
"agriculture": "Land-en tuinbouw",
|
||||
"society": "Maatschappij en welzijn",
|
||||
"math": "Wiskunde",
|
||||
"technology": "Technologie",
|
||||
"algorithms": "Algoritmes"
|
||||
},
|
||||
"age-options": {
|
||||
"all": "Alle leeftijden",
|
||||
"primary-school": "Lagere school",
|
||||
"lower-secondary": "1e graad secundair",
|
||||
"upper-secondary": "2e graad secundair",
|
||||
"high-school": "3e graad secundair",
|
||||
"older": "Hoger onderwijs"
|
||||
},
|
||||
"read-more": "Lees meer"
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import i18n from "./i18n/i18n.ts";
|
|||
// Components
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
|
@ -24,6 +25,18 @@ const vuetify = createVuetify({
|
|||
components,
|
||||
directives,
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.use(vuetify);
|
||||
app.use(i18n);
|
||||
app.use(VueQueryPlugin, { queryClient });
|
||||
|
||||
app.mount("#app");
|
||||
|
|
24
frontend/src/queries/themes.ts
Normal file
24
frontend/src/queries/themes.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { getThemeController } from "@/controllers/controllers";
|
||||
import { type MaybeRefOrGetter, toValue } from "vue";
|
||||
|
||||
const themeController = getThemeController();
|
||||
|
||||
export function useThemeQuery(language: MaybeRefOrGetter<string>): UseQueryReturnType<unknown, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["themes", language],
|
||||
queryFn: async () => {
|
||||
const lang = toValue(language);
|
||||
return themeController.getAll(lang);
|
||||
},
|
||||
enabled: () => Boolean(toValue(language)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useThemeHruidsQuery(themeKey: string | null): UseQueryReturnType<unknown, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["theme-hruids", themeKey],
|
||||
queryFn: async () => themeController.getHruidsByKey(themeKey!),
|
||||
enabled: Boolean(themeKey),
|
||||
});
|
||||
}
|
|
@ -1,13 +1,4 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import MenuBar from "@/components/MenuBar.vue";
|
||||
import StudentHomepage from "@/views/StudentHomepage.vue";
|
||||
import StudentAssignments from "@/views/assignments/StudentAssignments.vue";
|
||||
import StudentClasses from "@/views/classes/StudentClasses.vue";
|
||||
import StudentDiscussions from "@/views/discussions/StudentDiscussions.vue";
|
||||
import TeacherHomepage from "@/views/TeacherHomepage.vue";
|
||||
import TeacherAssignments from "@/views/assignments/TeacherAssignments.vue";
|
||||
import TeacherClasses from "@/views/classes/TeacherClasses.vue";
|
||||
import TeacherDiscussions from "@/views/discussions/TeacherDiscussions.vue";
|
||||
import SingleAssignment from "@/views/assignments/SingleAssignment.vue";
|
||||
import SingleClass from "@/views/classes/SingleClass.vue";
|
||||
import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue";
|
||||
|
@ -16,6 +7,12 @@ import CreateClass from "@/views/classes/CreateClass.vue";
|
|||
import CreateAssignment from "@/views/assignments/CreateAssignment.vue";
|
||||
import CreateDiscussion from "@/views/discussions/CreateDiscussion.vue";
|
||||
import CallbackPage from "@/views/CallbackPage.vue";
|
||||
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 UserHomePage from "@/views/homepage/UserHomePage.vue";
|
||||
import SingleTheme from "@/views/SingleTheme.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
@ -23,106 +20,111 @@ const router = createRouter({
|
|||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: () => import("../views/HomePage.vue"),
|
||||
component: async (): Promise<unknown> => import("../views/HomePage.vue"),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "LoginPage",
|
||||
component: () => import("../views/LoginPage.vue"),
|
||||
component: async (): Promise<unknown> => import("../views/LoginPage.vue"),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: "/callback",
|
||||
component: CallbackPage,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
|
||||
{
|
||||
path: "/student/:id",
|
||||
component: MenuBar,
|
||||
path: "/user",
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: "home",
|
||||
name: "StudentHomePage",
|
||||
component: StudentHomepage,
|
||||
path: "",
|
||||
name: "UserHomePage",
|
||||
component: UserHomePage,
|
||||
},
|
||||
{
|
||||
path: "assignment",
|
||||
name: "StudentAssignments",
|
||||
component: StudentAssignments,
|
||||
name: "UserAssignments",
|
||||
component: UserAssignments,
|
||||
},
|
||||
{
|
||||
path: "class",
|
||||
name: "StudentClasses",
|
||||
component: StudentClasses,
|
||||
name: "UserClasses",
|
||||
component: UserClasses,
|
||||
},
|
||||
{
|
||||
path: "discussion",
|
||||
name: "StudentDiscussions",
|
||||
component: StudentDiscussions,
|
||||
name: "UserDiscussions",
|
||||
component: UserDiscussions,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "/teacher/:id",
|
||||
component: MenuBar,
|
||||
children: [
|
||||
{
|
||||
path: "home",
|
||||
name: "TeacherHomepage",
|
||||
component: TeacherHomepage,
|
||||
},
|
||||
{
|
||||
path: "assignment",
|
||||
name: "TeacherAssignments",
|
||||
component: TeacherAssignments,
|
||||
},
|
||||
{
|
||||
path: "class",
|
||||
name: "TeacherClasses",
|
||||
component: TeacherClasses,
|
||||
},
|
||||
{
|
||||
path: "discussion",
|
||||
name: "TeacherDiscussions",
|
||||
component: TeacherDiscussions,
|
||||
},
|
||||
],
|
||||
path: "/theme/:id",
|
||||
name: "Theme",
|
||||
component: SingleTheme,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/assignment/create",
|
||||
name: "CreateAssigment",
|
||||
component: CreateAssignment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/assignment/:id",
|
||||
name: "SingleAssigment",
|
||||
component: SingleAssignment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/class/create",
|
||||
name: "CreateClass",
|
||||
component: CreateClass,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/class/:id",
|
||||
name: "SingleClass",
|
||||
component: SingleClass,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/discussion/create",
|
||||
name: "CreateDiscussion",
|
||||
component: CreateDiscussion,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/discussion/:id",
|
||||
name: "SingleDiscussion",
|
||||
component: SingleDiscussion,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/:catchAll(.*)",
|
||||
name: "NotFound",
|
||||
component: NotFound,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// Verify if user is logged in before accessing certain routes
|
||||
if (to.meta.requiresAuth) {
|
||||
if (!authState.isLoggedIn.value) {
|
||||
//Next("/login");
|
||||
next();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import apiClient from "@/services/api-client.ts";
|
||||
import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts";
|
||||
import type { UserManagerSettings } from "oidc-client-ts";
|
||||
|
||||
export const AUTH_CONFIG_ENDPOINT = "auth/config";
|
||||
|
||||
/**
|
||||
* Fetch the authentication configuration from the backend.
|
||||
*/
|
||||
export async function loadAuthConfig() {
|
||||
const authConfig = (await apiClient.get<FrontendAuthConfig>("auth/config")).data;
|
||||
export async function loadAuthConfig(): Promise<Record<string, UserManagerSettings>> {
|
||||
const authConfigResponse = await apiClient.get<FrontendAuthConfig>(AUTH_CONFIG_ENDPOINT);
|
||||
const authConfig = authConfigResponse.data;
|
||||
return {
|
||||
student: {
|
||||
authority: authConfig.student.authority,
|
||||
|
|
|
@ -5,33 +5,19 @@
|
|||
import { computed, reactive } from "vue";
|
||||
import type { AuthState, Role, UserManagersForRoles } from "@/services/auth/auth.d.ts";
|
||||
import { User, UserManager } from "oidc-client-ts";
|
||||
import { loadAuthConfig } from "@/services/auth/auth-config-loader.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 router from "@/router";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
const authConfig = await loadAuthConfig();
|
||||
|
||||
const userManagers: UserManagersForRoles = {
|
||||
student: new UserManager(authConfig.student),
|
||||
teacher: new UserManager(authConfig.teacher),
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the information about who is currently logged in from the IDP.
|
||||
*/
|
||||
async function loadUser(): Promise<User | null> {
|
||||
const activeRole = authStorage.getActiveRole();
|
||||
if (!activeRole) {
|
||||
return null;
|
||||
}
|
||||
const user = await userManagers[activeRole].getUser();
|
||||
authState.user = user;
|
||||
authState.accessToken = user?.access_token || null;
|
||||
authState.activeRole = activeRole || null;
|
||||
return user;
|
||||
async function getUserManagers(): Promise<UserManagersForRoles> {
|
||||
const authConfig = await loadAuthConfig();
|
||||
return {
|
||||
student: new UserManager(authConfig.student),
|
||||
teacher: new UserManager(authConfig.teacher),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -43,12 +29,27 @@ const authState = reactive<AuthState>({
|
|||
activeRole: authStorage.getActiveRole() || null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Load the information about who is currently logged in from the IDP.
|
||||
*/
|
||||
async function loadUser(): Promise<User | null> {
|
||||
const activeRole = authStorage.getActiveRole();
|
||||
if (!activeRole) {
|
||||
return null;
|
||||
}
|
||||
const user = await (await getUserManagers())[activeRole].getUser();
|
||||
authState.user = user;
|
||||
authState.accessToken = user?.access_token || null;
|
||||
authState.activeRole = activeRole || null;
|
||||
return user;
|
||||
}
|
||||
|
||||
const isLoggedIn = computed(() => authState.user !== null);
|
||||
|
||||
/**
|
||||
* Redirect the user to the login page where he/she can choose whether to log in as a student or teacher.
|
||||
*/
|
||||
async function initiateLogin() {
|
||||
async function initiateLogin(): Promise<void> {
|
||||
await router.push(loginRoute);
|
||||
}
|
||||
|
||||
|
@ -59,7 +60,7 @@ async function initiateLogin() {
|
|||
async function loginAs(role: Role): Promise<void> {
|
||||
// Storing it in local storage so that it won't be lost when redirecting outside of the app.
|
||||
authStorage.setActiveRole(role);
|
||||
await userManagers[role].signinRedirect();
|
||||
await (await getUserManagers())[role].signinRedirect();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -70,26 +71,26 @@ async function handleLoginCallback(): Promise<void> {
|
|||
if (!activeRole) {
|
||||
throw new Error("Login callback received, but the user is not logging in!");
|
||||
}
|
||||
authState.user = (await userManagers[activeRole].signinCallback()) || null;
|
||||
authState.user = (await (await getUserManagers())[activeRole].signinCallback()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an expired authorization token.
|
||||
*/
|
||||
async function renewToken() {
|
||||
async function renewToken(): Promise<User | null> {
|
||||
const activeRole = authStorage.getActiveRole();
|
||||
if (!activeRole) {
|
||||
console.log("Can't renew the token: Not logged in!");
|
||||
// FIXME console.log("Can't renew the token: Not logged in!");
|
||||
await initiateLogin();
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await userManagers[activeRole].signinSilent();
|
||||
} catch (error) {
|
||||
console.log("Can't renew the token:");
|
||||
console.log(error);
|
||||
return await (await getUserManagers())[activeRole].signinSilent();
|
||||
} catch (_error) {
|
||||
// FIXME console.log("Can't renew the token: " + error);
|
||||
await initiateLogin();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -98,7 +99,7 @@ async function renewToken() {
|
|||
async function logout(): Promise<void> {
|
||||
const activeRole = authStorage.getActiveRole();
|
||||
if (activeRole) {
|
||||
await userManagers[activeRole].signoutRedirect();
|
||||
await (await getUserManagers())[activeRole].signoutRedirect();
|
||||
authStorage.deleteActiveRole();
|
||||
}
|
||||
}
|
||||
|
@ -107,12 +108,12 @@ async function logout(): Promise<void> {
|
|||
apiClient.interceptors.request.use(
|
||||
async (reqConfig) => {
|
||||
const token = authState?.user?.access_token;
|
||||
if (token) {
|
||||
if (token && reqConfig.url !== AUTH_CONFIG_ENDPOINT) {
|
||||
reqConfig.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return reqConfig;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
async (error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// Registering interceptor to refresh the token when a request failed because it was expired.
|
||||
|
@ -120,8 +121,8 @@ apiClient.interceptors.response.use(
|
|||
(response) => response,
|
||||
async (error: AxiosError<{ message?: string }>) => {
|
||||
if (error.response?.status === 401) {
|
||||
if (error.response!.data.message === "token_expired") {
|
||||
console.log("Access token expired, trying to refresh...");
|
||||
if (error.response.data.message === "token_expired") {
|
||||
// FIXME console.log("Access token expired, trying to refresh...");
|
||||
await renewToken();
|
||||
return apiClient(error.config!); // Retry the request
|
||||
} // Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login.
|
||||
|
|
|
@ -12,7 +12,7 @@ export default {
|
|||
* Set the role the user is currently logged in as from the local persistent storage.
|
||||
* This should happen when the user logs in with another account.
|
||||
*/
|
||||
setActiveRole(role: Role) {
|
||||
setActiveRole(role: Role): void {
|
||||
localStorage.setItem("activeRole", role);
|
||||
},
|
||||
|
||||
|
@ -20,7 +20,7 @@ export default {
|
|||
* Remove the saved current role from the local persistent storage.
|
||||
* This should happen when the user is logged out.
|
||||
*/
|
||||
deleteActiveRole() {
|
||||
deleteActiveRole(): void {
|
||||
localStorage.removeItem("activeRole");
|
||||
},
|
||||
};
|
||||
|
|
17
frontend/src/services/auth/auth.d.ts
vendored
17
frontend/src/services/auth/auth.d.ts
vendored
|
@ -1,22 +1,25 @@
|
|||
import { type User, UserManager } from "oidc-client-ts";
|
||||
|
||||
export type AuthState = {
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
activeRole: Role | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type FrontendAuthConfig = {
|
||||
export interface FrontendAuthConfig {
|
||||
student: FrontendIdpConfig;
|
||||
teacher: FrontendIdpConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export type FrontendIdpConfig = {
|
||||
export interface FrontendIdpConfig {
|
||||
authority: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
responseType: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Role = "student" | "teacher";
|
||||
export type UserManagersForRoles = { student: UserManager; teacher: UserManager };
|
||||
export interface UserManagersForRoles {
|
||||
student: UserManager;
|
||||
teacher: UserManager;
|
||||
}
|
||||
|
|
64
frontend/src/utils/constants.ts
Normal file
64
frontend/src/utils/constants.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
export const THEMES_KEYS = [
|
||||
"kiks",
|
||||
"art",
|
||||
"socialrobot",
|
||||
"agriculture",
|
||||
"wegostem",
|
||||
"computational_thinking",
|
||||
"math_with_python",
|
||||
"python_programming",
|
||||
"stem",
|
||||
"care",
|
||||
"chatbot",
|
||||
"physical_computing",
|
||||
"algorithms",
|
||||
"basics_ai",
|
||||
];
|
||||
|
||||
export const THEMESITEMS: Record<string, string[]> = {
|
||||
all: THEMES_KEYS,
|
||||
culture: ["art", "wegostem", "chatbot"],
|
||||
"electricity-and-mechanics": ["socialrobot", "wegostem", "stem", "physical_computing"],
|
||||
"nature-and-climate": ["kiks", "agriculture"],
|
||||
agriculture: ["agriculture"],
|
||||
society: ["kiks", "socialrobot", "care", "chatbot"],
|
||||
math: ["kiks", "math_with_python", "python_programming", "stem", "care", "basics_ai"],
|
||||
technology: ["socialrobot", "wegostem", "computational_thinking", "stem", "physical_computing", "basics_ai"],
|
||||
algorithms: ["math_with_python", "python_programming", "stem", "algorithms", "basics_ai"],
|
||||
};
|
||||
|
||||
export const AGEITEMS = ["all", "primary-school", "lower-secondary", "upper-secondary", "high-school", "older"];
|
||||
|
||||
export const AGE_TO_THEMES: Record<string, string[]> = {
|
||||
all: THEMES_KEYS,
|
||||
"primary-school": ["wegostem", "computational_thinking", "physical_computing"],
|
||||
"lower-secondary": ["socialrobot", "art", "wegostem", "computational_thinking", "physical_computing"],
|
||||
"upper-secondary": [
|
||||
"kiks",
|
||||
"art",
|
||||
"socialrobot",
|
||||
"agriculture",
|
||||
"computational_thinking",
|
||||
"math_with_python",
|
||||
"python_programming",
|
||||
"stem",
|
||||
"care",
|
||||
"chatbot",
|
||||
"algorithms",
|
||||
"basics_ai",
|
||||
],
|
||||
"high-school": [
|
||||
"kiks",
|
||||
"art",
|
||||
"agriculture",
|
||||
"computational_thinking",
|
||||
"math_with_python",
|
||||
"python_programming",
|
||||
"stem",
|
||||
"care",
|
||||
"chatbot",
|
||||
"algorithms",
|
||||
"basics_ai",
|
||||
],
|
||||
older: ["kiks", "computational_thinking", "algorithms", "basics_ai"],
|
||||
};
|
|
@ -8,9 +8,9 @@
|
|||
onMounted(async () => {
|
||||
try {
|
||||
await auth.handleLoginCallback();
|
||||
await router.replace("/"); // Redirect to home (or dashboard)
|
||||
} catch (error) {
|
||||
console.error("OIDC callback error:", error);
|
||||
await router.replace("/user"); // Redirect to theme page
|
||||
} catch (_error) {
|
||||
// FIXME console.error("OIDC callback error:", error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,28 +1,197 @@
|
|||
<script setup lang="ts">
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
import apiClient from "@/services/api-client.ts";
|
||||
import { ref } from "vue";
|
||||
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const testResponse = ref(null);
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
async function testAuthenticated() {
|
||||
testResponse.value = await apiClient.get("/auth/testAuthenticatedOnly");
|
||||
const languages = ref([
|
||||
{ name: "English", code: "en" },
|
||||
{ name: "Nederlands", code: "nl" },
|
||||
{ name: "Deutsch", code: "de" },
|
||||
{ name: "français", code: "fr" },
|
||||
]);
|
||||
|
||||
// Logic to change the language of the website to the selected language
|
||||
function changeLanguage(langCode: string): void {
|
||||
locale.value = langCode;
|
||||
localStorage.setItem("user-lang", langCode);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<!-- TODO Placeholder implementation to test the login - replace by a more beautiful page later -->
|
||||
<b>Welcome to the dwengo homepage</b>
|
||||
<div v-if="auth.isLoggedIn.value">
|
||||
<p>Hello {{ auth.authState.user?.profile.name }}!</p>
|
||||
<p>
|
||||
Your access token for the backend is: <code>{{ auth.authState.user?.access_token }}</code>
|
||||
</p>
|
||||
<div class="layout">
|
||||
<div class="container_left">
|
||||
<img
|
||||
:src="dwengoLogo"
|
||||
alt="Dwengo logo"
|
||||
style="align-self: center"
|
||||
/>
|
||||
<h1>{{ t("homeTitle") }}</h1>
|
||||
<p class="info">
|
||||
{{ t("homeIntroduction1") }}
|
||||
</p>
|
||||
<p class="info">{{ t("homeIntroduction2") }}</p>
|
||||
<v-btn
|
||||
size="large"
|
||||
density="comfortable"
|
||||
style="font-weight: bolder; color: white; align-self: center"
|
||||
color="#88BD28"
|
||||
to="/login"
|
||||
>
|
||||
{{ t("login") }}
|
||||
<v-icon
|
||||
end
|
||||
size="x-large"
|
||||
>
|
||||
mdi-menu-right
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="container_middle">
|
||||
<div class="img_small">
|
||||
<v-img
|
||||
height="125"
|
||||
width="125"
|
||||
src="/assets/home/innovative.png"
|
||||
></v-img>
|
||||
<h2 class="big">{{ t("innovative") }}</h2>
|
||||
</div>
|
||||
<div class="img_small">
|
||||
<v-img
|
||||
height="125"
|
||||
width="125"
|
||||
src="/assets/home/research_based.png"
|
||||
></v-img>
|
||||
<h2 class="big">{{ t("researchBased") }}</h2>
|
||||
</div>
|
||||
<div class="img_small">
|
||||
<v-img
|
||||
height="125"
|
||||
width="125"
|
||||
src="/assets/home/inclusive.png"
|
||||
></v-img>
|
||||
<h2 class="big">{{ t("sociallyRelevant") }}</h2>
|
||||
</div>
|
||||
<div class="img_small">
|
||||
<v-img
|
||||
height="125"
|
||||
width="125"
|
||||
src="/assets/home/socially_relevant.png"
|
||||
></v-img>
|
||||
<h2 class="big">{{ t("inclusive") }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container_right">
|
||||
<v-menu open-on-hover>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
>
|
||||
{{ t("translate") }}
|
||||
<v-icon
|
||||
end
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-btn @click="testAuthenticated">Send test request</v-btn>
|
||||
<p v-if="testResponse">Response from the test request: {{ testResponse }}</p>
|
||||
</main>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.container_left {
|
||||
width: 600px;
|
||||
background-color: #f6faf2;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container_middle {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container_right {
|
||||
position: absolute;
|
||||
top: 2%;
|
||||
right: 100px;
|
||||
}
|
||||
|
||||
.img_small {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 300px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
align-self: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.info {
|
||||
text-align: center;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.container_left {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container_right {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,16 +2,16 @@
|
|||
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
|
||||
import auth from "@/services/auth/auth-service.ts";
|
||||
|
||||
function loginAsStudent() {
|
||||
auth.loginAs("student");
|
||||
async function loginAsStudent(): Promise<void> {
|
||||
await auth.loginAs("student");
|
||||
}
|
||||
|
||||
function loginAsTeacher() {
|
||||
auth.loginAs("teacher");
|
||||
async function loginAsTeacher(): Promise<void> {
|
||||
await auth.loginAs("teacher");
|
||||
}
|
||||
|
||||
function performLogout() {
|
||||
auth.logout();
|
||||
async function performLogout(): Promise<void> {
|
||||
await auth.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -70,6 +70,7 @@
|
|||
You are currently logged in as {{ auth.authState.user!.profile.name }} ({{ auth.authState.activeRole }})
|
||||
</p>
|
||||
<v-btn @click="performLogout">Logout</v-btn>
|
||||
<v-btn to="/user">home</v-btn>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
|
7
frontend/src/views/classes/UserClasses.vue
Normal file
7
frontend/src/views/classes/UserClasses.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
7
frontend/src/views/discussions/UserDiscussions.vue
Normal file
7
frontend/src/views/discussions/UserDiscussions.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main></main>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
123
frontend/src/views/homepage/UserHomePage.vue
Normal file
123
frontend/src/views/homepage/UserHomePage.vue
Normal file
|
@ -0,0 +1,123 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { THEMESITEMS, AGE_TO_THEMES } from "@/utils/constants.ts";
|
||||
import BrowseThemes from "@/components/BrowseThemes.vue";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const selectedThemeKey = ref<string>("all");
|
||||
const selectedAgeKey = ref<string>("all");
|
||||
|
||||
const allThemes = ref(Object.keys(THEMESITEMS));
|
||||
const availableThemes = ref([...allThemes.value]);
|
||||
|
||||
const allAges = ref(Object.keys(AGE_TO_THEMES));
|
||||
const availableAges = ref([...allAges.value]);
|
||||
|
||||
// Reset selection when language changes
|
||||
watch(locale, () => {
|
||||
selectedThemeKey.value = "all";
|
||||
selectedAgeKey.value = "all";
|
||||
});
|
||||
|
||||
watch(selectedThemeKey, () => {
|
||||
if (selectedThemeKey.value === "all") {
|
||||
availableAges.value = [...allAges.value]; // Reset to all ages
|
||||
} else {
|
||||
const themes = THEMESITEMS[selectedThemeKey.value];
|
||||
availableAges.value = allAges.value.filter((age) =>
|
||||
AGE_TO_THEMES[age]?.some((theme) => themes.includes(theme)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedAgeKey, () => {
|
||||
if (selectedAgeKey.value === "all") {
|
||||
availableThemes.value = [...allThemes.value]; // Reset to all themes
|
||||
} else {
|
||||
const themes = AGE_TO_THEMES[selectedAgeKey.value];
|
||||
availableThemes.value = allThemes.value.filter((theme) =>
|
||||
THEMESITEMS[theme]?.some((theme) => themes.includes(theme)),
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-container">
|
||||
<h1 class="title">{{ t("themes") }}</h1>
|
||||
<v-container class="dropdowns">
|
||||
<v-select
|
||||
class="v-select"
|
||||
:label="t('choose-theme')"
|
||||
:items="availableThemes.map((theme) => ({ title: t(`theme-options.${theme}`), value: theme }))"
|
||||
v-model="selectedThemeKey"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
class="v-select"
|
||||
:label="t('choose-age')"
|
||||
:items="availableAges.map((age) => ({ key: age, label: t(`age-options.${age}`), value: age }))"
|
||||
v-model="selectedAgeKey"
|
||||
item-title="label"
|
||||
item-value="key"
|
||||
variant="outlined"
|
||||
></v-select>
|
||||
</v-container>
|
||||
|
||||
<BrowseThemes
|
||||
:selectedTheme="selectedThemeKey ?? ''"
|
||||
:selectedAge="selectedAgeKey ?? ''"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-container {
|
||||
min-height: 100vh;
|
||||
min-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 50rem;
|
||||
margin-left: 1rem;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropdowns {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 5rem;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.v-select {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.dropdowns {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -2,7 +2,6 @@ import { fileURLToPath, URL } from "node:url";
|
|||
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vueDevTools from "vite-plugin-vue-devtools";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue