Frontend revamp with Vue

This commit is contained in:
João Vitória Silva
2024-02-16 18:53:11 +00:00
parent 0bc1e0ac91
commit 939f86be98
40 changed files with 4981 additions and 1 deletions

35
.gitignore vendored
View File

@@ -9,4 +9,37 @@ backend/logs/*.log
backend/*.log
# Frontend
frontend/img/users_img/*.*
frontend/img/users_img/*.*
# Frontend Vue.js
# Logs
frontend_vue/logs
frontend_vue/*.log
frontend_vue/npm-debug.log*
frontend_vue/yarn-debug.log*
frontend_vue/yarn-error.log*
frontend_vue/pnpm-debug.log*
frontend_vue/lerna-debug.log*
frontend_vue/node_modules
frontend_vue/.DS_Store
frontend_vue/dist
frontend_vue/dist-ssr
frontend_vue/coverage
frontend_vue/*.local
frontend_vue/README.md
frontend_vue/cypress/videos/
frontend_vue/cypress/screenshots/
# Editor directories and files
frontend_vue/.vscode/*
frontend_vue/!.vscode/extensions.json
frontend_vue/.idea
frontend_vue/*.suo
frontend_vue/*.ntvs*
frontend_vue/*.njsproj
frontend_vue/*.sln
frontend_vue/*.sw?
frontend_vue/*.tsbuildinfo

View File

@@ -0,0 +1,14 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

30
frontend_vue/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html data-bs-theme="dark" lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/logo/logo.png">
<link rel="apple-touch-icon" href="/logo/logo.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endurain</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script>
// Detects if the user has set their system to use dark mode
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
// Applies dark theme by setting data-bs-theme="dark"
document.documentElement.setAttribute('data-bs-theme', 'dark');
} else {
// Applies light theme or removes the attribute for default behavior
document.documentElement.setAttribute('data-bs-theme', 'light');
}
// Optional: Listen for changes in the preference and adjust on the fly
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
const newColorScheme = event.matches ? "dark" : "light";
document.documentElement.setAttribute('data-bs-theme', newColorScheme);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

4116
frontend_vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
frontend_vue/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend_vue",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"bootstrap": "^5.3.2",
"crypto-js": "^4.2.0",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-i18n": "^9.9.1",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/test-utils": "^2.4.4",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"jsdom": "^24.0.0",
"prettier": "^3.0.3",
"vite": "^5.0.11",
"vitest": "^1.2.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

13
frontend_vue/src/App.vue Normal file
View File

@@ -0,0 +1,13 @@
<script setup>
import { RouterView } from 'vue-router'
import NavbarComponent from './components/NavbarComponent.vue'
import FooterComponent from './components/FooterComponent.vue'
</script>
<template>
<NavbarComponent />
<main class="container mt-4">
<RouterView />
</main>
<FooterComponent />
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,3 @@
<template>
</template>

View File

@@ -0,0 +1,6 @@
<template>
<footer class="border-top py-3 my-4 bg-body-tertiar">
<p class="text-center text-muted">&copy; {{ new Date().getFullYear() === 2023 ? '2023' : '2023 - ' + new Date().getFullYear() }} Endurain <a href="https://github.com/joaovitoriasilva/endurain" role="button"><font-awesome-icon :icon="['fab', 'fa-github']" /></a> <a href="https://fosstodon.org/@endurain"><font-awesome-icon :icon="['fab', 'fa-mastodon']" /></a> v0.1.5</p>
<p class="text-center text-muted"><img src="/src/assets/strava/api_logo_cptblWith_strava_horiz_light.png" alt="Compatible with STRAVA image" height="25" /></p>
</footer>
</template>

View File

@@ -0,0 +1,77 @@
<template>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">Endurain</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav me-auto mb-2 mb-lg-0">
<!-- if is logged in -->
<a class="nav-link" href="#" v-if="isLoggedIn">
<font-awesome-icon :icon="['fas', 'fa-bicycle']" />
<span class="ms-1">
{{ $t("navbar.gear") }}
</span>
</a>
</div>
<div class="navbar-nav">
<span class="border-top d-sm-none d-block mb-2" v-if="isLoggedIn"></span>
<a class="nav-link" href="#" v-if="isLoggedIn">
<img :src="userMe.photo_path" alt="User Photo" width="24" height="24" class="rounded-circle align-top" v-if="userMe.photo_path">
<img src="/src/assets/avatar/male1.png" alt="Default Male Avatar" width="24" height="24" class="rounded-circle align-top" v-else-if="!userMe.photo_path && userMe.gender == 1">
<img src="/src/assets/avatar/female1.png" alt="Default Female Avatar" width="24" height="24" class="rounded-circle align-top" v-else>
<span class="ms-2">{{ $t("navbar.profile") }}</span>
</a>
<span class="border-top d-sm-none d-block" v-if="isLoggedIn"></span>
<a class="nav-link d-none d-sm-block" v-if="isLoggedIn">|</a>
<a class="nav-link" href="/settings" v-if="isLoggedIn">
<font-awesome-icon :icon="['fas', 'fa-gear']" />
<span class="ms-1">{{ $t("navbar.settings") }}</span>
</a>
<a class="nav-link" href="#" v-if="isLoggedIn" @click="handleLogout">
<font-awesome-icon :icon="['fas', 'fa-sign-out-alt']" />
<span class="ms-1">{{ $t("navbar.logout") }}</span>
</a>
<!-- if is not logged in -->
<a class="nav-link" href="/login" v-if="!isLoggedIn">
<font-awesome-icon :icon="['fas', 'fa-sign-in-alt']" />
<span class="ms-1">{{ $t("navbar.login") }}</span>
</a>
</div>
</div>
</div>
</nav>
<div class="alert alert-warning alert-dismissible d-flex align-items-center mx-2 my-2 justify-content-center"
role="alert">
<font-awesome-icon :icon="['fas', 'triangle-exclamation']" />
<div>
<span class="me-1">
{{ $t("navbar.warningZone") }}
</span>
</div>
</div>
</template>
<script>
import { auth } from '@/services/auth';
import { useRouter } from 'vue-router';
export default {
setup() {
const router = useRouter();
function handleLogout() {
auth.removeLoggedUser();
router.push('/login');
}
return {
isLoggedIn: auth.isTokenValid(localStorage.getItem('accessToken')),
userMe: JSON.parse(localStorage.getItem('userMe')),
handleLogout,
};
},
};
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div class="centered-card">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ $t("noItemsFoundComponent.title") }}</h5>
<h1 class="card-text"><font-awesome-icon :icon="['fas', 'fa-circle-info']" /></h1>
<p class="card-text">{{ $t("noItemsFoundComponent.subtitle") }}</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<template>
<div>
<span class="fw-lighter">{{ $t("userDistanceStats.thisWeekDistancesTitle") }}</span>
<div class="row mb-3">
<div class="col">
<span class="fw-lighter">{{ $t("userDistanceStats.distancesRun") }}</span>
<br>
<span>{{ thisWeekDistances && thisWeekDistances.run ? (thisWeekDistances.run / 1000).toFixed(2) + ' km' : '0 km' }}</span>
</div>
<div class="col border-start border-opacity-50">
<span class="fw-lighter">{{ $t("userDistanceStats.distancesBike") }}</span>
<br>
<span>{{ thisWeekDistances && thisWeekDistances.bike ? (thisWeekDistances.bike / 1000).toFixed(2) + ' km' : '0 km' }}</span>
</div>
<div class="col border-start border-opacity-50">
<span class="fw-lighter">{{ $t("userDistanceStats.distancesSwim") }}</span>
<br>
<span v-if="thisWeekDistances && thisWeekDistances.swim">
{{ thisWeekDistances.swim > 10000 ? (thisWeekDistances.swim / 1000).toFixed(2) + ' km' : thisWeekDistances.swim + ' m' }}
</span>
<span v-else>0 m</span>
</div>
</div>
</div>
<div>
<span class="fw-lighter">{{ $t("userDistanceStats.thisMonthDistancesTitle") }}</span>
<div class="row mb-3">
<div class="col">
<span class="fw-lighter">{{ $t("userDistanceStats.distancesRun") }}</span>
<br>
<span>{{ thisMonthDistances && thisMonthDistances.run ? (thisMonthDistances.run / 1000).toFixed(2) + ' km' : '0 km' }}</span>
</div>
<div class="col border-start border-opacity-50">
<span class="fw-lighter">{{ $t("userDistanceStats.distancesBike") }}</span>
<br>
<span>{{ thisMonthDistances && thisMonthDistances.bike ? (thisMonthDistances.bike / 1000).toFixed(2) + ' km' : '0 km' }}</span>
</div>
<div class="col border-start border-opacity-50">
<span class="fw-lighter">{{ $t("userDistanceStats.distancesSwim") }}</span>
<br>
<span v-if="thisMonthDistances && thisMonthDistances.swim">
{{ thisMonthDistances.swim > 10000 ? (thisMonthDistances.swim / 1000).toFixed(2) + ' km' : thisMonthDistances.swim + ' m' }}
</span>
<span v-else>0 m</span>
</div>
</div>
</div>
</template>
<script>
import { computed } from 'vue';
import { useUserStore } from '@/stores/user';
export default {
setup() {
const userStore = useUserStore();
// Access both stats directly from the store
const thisWeekDistances = computed(() => userStore.thisWeekDistances);
const thisMonthDistances = computed(() => userStore.thisMonthDistances);
return {
thisWeekDistances,
thisMonthDistances,
};
},
};
</script>

View File

@@ -0,0 +1,8 @@
{
"gear": "Gear",
"profile": "Profile",
"settings": "Settings",
"login": "Login",
"logout": "Logout",
"warningZone": "Alpha software, some features might not work has expected"
}

View File

@@ -0,0 +1,4 @@
{
"title" :"Ops...",
"subtitle" :"No records found"
}

View File

@@ -0,0 +1,7 @@
{
"thisMonthDistancesTitle" :"This month distances",
"thisWeekDistancesTitle" :"This week distances",
"distancesRun" :"Run",
"distancesBike" :"Bike",
"distancesSwim" :"Swim"
}

View File

@@ -0,0 +1,6 @@
{
"title": "Endurain",
"buttonAddActivity": "Add Activity",
"radioUserActivities": "My activities",
"radioFollowerActivities": "Followers activities"
}

View File

@@ -0,0 +1,14 @@
{
"sessionExpired": "User session expired",
"error401": "Invalid username or password",
"error403": "You do not have permission to access this resource",
"error500": "It was not possible to connect to the server. Please try again later",
"errorUndefined": "It was not possible to connect to the server. Please try again later",
"subtitle": "Sign-in bellow",
"username": "Username",
"password": "Password",
"neverExpires": "Remember sign in",
"signInButton": "Sign in",
"signUpText": "Looking for signing up?",
"signUpButton": "Sign up"
}

View File

@@ -0,0 +1,28 @@
import { createI18n } from 'vue-i18n';
// Importing translations
import enNavbarComponent from './en/components/navbarComponent.json';
import enUserDistanceStatsComponent from './en/components/userDistanceStatsComponent.json';
import enNoItemsFoundComponent from './en/components/noItemsFoundComponent.json';
import enHomeView from './en/homeView.json';
import enLoginView from './en/loginView.json';
// Constructing the messages structure
const messages = {
en: {
navbar: enNavbarComponent,
userDistanceStats: enUserDistanceStatsComponent,
noItemsFoundComponent: enNoItemsFoundComponent,
home: enHomeView,
login: enLoginView,
},
};
// Creating the Vue I18n instance
const i18n = createI18n({
locale: 'en', // Default locale
fallbackLocale: 'en', // Fallback locale
messages,
});
export default i18n;

31
frontend_vue/src/main.js Normal file
View File

@@ -0,0 +1,31 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import "bootstrap/dist/css/bootstrap.min.css"
import "bootstrap"
import App from './App.vue'
import router from './router'
import i18n from './i18n';
/* import the fontawesome core */
import { library } from '@fortawesome/fontawesome-svg-core';
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
/* import icons */
import { fas } from '@fortawesome/free-solid-svg-icons';
import { fab } from '@fortawesome/free-brands-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons';
/* add icons to the library */
library.add(fas, fab, far);
const app = createApp(App)
app.use(createPinia())
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(router)
app.use(i18n);
app.mount('#app')

View File

@@ -0,0 +1,48 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import { auth } from '@/services/auth';
//import { useAuthStore } from '@/stores/auth';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/login',
name: 'login',
component: LoginView
}
]
})
router.beforeEach((to, from, next) => {
const accessToken = localStorage.getItem('accessToken');
const tokenType = localStorage.getItem('tokenType');
if (!accessToken && to.path !== '/login') {
next('/login');
} else if (accessToken && tokenType) {
if (auth.isTokenValid(accessToken)) {
if (to.path === '/login') {
next('/');
} else {
next();
}
} else {
auth.removeLoggedUser();
next({ path: '/login', query: { sessionExpired: 'true' } });
}
} else {
next();
}
});
export default router;

View File

@@ -0,0 +1,57 @@
//const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:98/';
const API_URL = 'http://localhost:98/';
export const activities = {
async getUserThisWeekStats(id) {
const response = await fetch(`${API_URL}activities/user/${id}/thisweek/distances`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
},
});
if (!response.ok) {
throw new Error('' + response.status);
}
return response.json();
},
async getUserThisMonthStats(id) {
const response = await fetch(`${API_URL}activities/user/${id}/thismonth/distances`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
},
});
if (!response.ok) {
throw new Error('' + response.status);
}
return response.json();
},
async getUserActivitiesWithPagination(id, pageNumber, numRecords) {
const response = await fetch(`${API_URL}activities/user/${id}/page_number/${pageNumber}/num_records/${numRecords}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
},
});
if (!response.ok) {
throw new Error('' + response.status);
}
return response.json();
},
async getUserFollowersActivitiesWithPagination(id, pageNumber, numRecords) {
const response = await fetch(`${API_URL}activities/user/${id}/followed/page_number/${pageNumber}/num_records/${numRecords}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
},
});
if (!response.ok) {
throw new Error('' + response.status);
}
return response.json();
}
};

View File

@@ -0,0 +1,51 @@
//const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:98/';
const API_URL = 'http://localhost:98/';
export const auth = {
isTokenValid(token) {
if (!token) {
return false;
}
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp;
const currentTime = Math.floor(Date.now() / 1000);
return exp > currentTime;
},
storeLoggedUser(token, userMe) {
localStorage.setItem('accessToken', token.access_token);
localStorage.setItem('tokenType', token.token_type);
localStorage.setItem('userMe', JSON.stringify(userMe));
},
removeLoggedUser() {
localStorage.clear();
//this.$router.push('/login');
},
async getToken(formData) {
const response = await fetch(`${API_URL}token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
});
if (!response.ok) {
throw new Error('' + response.status);
}
return response.json();
},
async getUserMe(token) {
const response = await fetch(`${API_URL}users/me`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
});
if (!response.ok) {
throw new Error('' + response.status);
}
return response.json();
},
};

View File

@@ -0,0 +1,18 @@
//const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:98/';
const API_URL = 'http://localhost:98/';
export const auth = {
async getUserMe(token) {
const response = await fetch(`${API_URL}users/me`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
});
if (!response.ok) {
throw new Error('' + response.status);
}
return response.json();
},
};

View File

@@ -0,0 +1,35 @@
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
isLoggedIn: localStorage.getItem('accessToken') ? true : false,
}),
actions: {
setUserLoggedIn(data) {
this.isLoggedIn = true;
// Save the access token and token type in localStorage
localStorage.setItem('accessToken', data.access_token);
localStorage.setItem('tokenType', data.token_type);
},
setUserLoggedOut() {
this.isLoggedIn = false;
// Clear localStorage items related to authentication
localStorage.removeItem('accessToken');
localStorage.removeItem('tokenType');
localStorage.removeItem('userMe');
},
isTokenValid(token) {
if (!token) {
return false;
}
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp;
const currentTime = Math.floor(Date.now() / 1000); // Current time in seconds
return exp > currentTime;
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineStore } from 'pinia';
import { activities } from '@/services/activities';
export const useUserStore = defineStore('user', {
state: () => ({
userMe: JSON.parse(localStorage.getItem('userMe')) || {},
thisWeekDistances: null,
thisMonthDistances: null,
userActivities: null,
followedUserActivities: null,
}),
actions: {
async fetchUserStats() {
try {
this.thisWeekDistances = await activities.getUserThisWeekStats(this.userMe.id);
this.thisMonthDistances = await activities.getUserThisMonthStats(this.userMe.id);
} catch (error) {
console.error("Failed to fetch data:", error);
}
},
async fetchUserActivitiesWithPagination(pageNumber, numRecords){
try {
this.userActivities = await activities.getUserActivitiesWithPagination(this.userMe.id, pageNumber, numRecords);
this.followedUserActivities = await activities.getUserFollowersActivitiesWithPagination(this.userMe.id, pageNumber, numRecords);
} catch (error) {
console.error("Failed to fetch data:", error);
}
}
}
});

View File

@@ -0,0 +1,82 @@
<template>
<div class="container mt-4">
<div class="row row-gap-3">
<!-- sidebar zone -->
<div class="col-lg-3 col-md-12">
<div class="d-none d-lg-block mt-3 mb-3 d-flex justify-content-center">
<!-- user name and photo zone -->
<div class="justify-content-center d-flex">
<img :src="userMe.photo_path" alt="User Photo" width="120" height="120" class="rounded-circle" v-if="userMe.photo_path">
<img src="/src/assets/avatar/male1.png" alt="Default Male Avatar" width="120" height="120" class="rounded-circle" v-else-if="!userMe.photo_path && userMe.gender == 1">
<img src="/src/assets/avatar/female1.png" alt="Default Female Avatar" width="120" height="120" class="rounded-circle" v-else>
</div>
<div class="text-center mt-3 mb-3 fw-bold">
<a href="">
<span>{{ userMe.name }}</span>
</a>
</div>
<!-- user stats zone -->
<UserDistanceStatsComponent />
</div>
<a class="w-100 btn btn-primary" href="#" role="button" data-bs-toggle="modal" data-bs-target="#addActivityModal">
{{ $t("home.buttonAddActivity") }}
</a>
</div>
<!-- activities zone -->
<div class="col">
<!-- radio button -->
<div class="btn-group mb-3 d-flex" role="group" aria-label="Activities radio toggle button group">
<!-- user activities -->
<input type="radio" class="btn-check" name="btnradio" id="btnRadioUserActivities" autocomplete="off" value="userActivities" v-model="selectedActivity">
<label class="btn btn-outline-primary w-100" for="btnRadioUserActivities">{{ $t("home.radioUserActivities") }}</label>
<!-- user followers activities -->
<input type="radio" class="btn-check" name="btnradio" id="btnRadioFollowersActivities" autocomplete="off" value="followersActivities" v-model="selectedActivity">
<label class="btn btn-outline-primary w-100" for="btnRadioFollowersActivities">{{ $t("home.radioFollowerActivities") }}</label>
</div>
<!-- user activities -->
<div id="userActivitiesDiv" v-show="selectedActivity === 'userActivities'">
<NoItemsFoundComponent v-if="userActivities"/>
</div>
<!-- user followers activities -->
<div id="followersActivitiesDiv" v-show="selectedActivity === 'followersActivities'">
<NoItemsFoundComponent v-if="followedUserActivities"/>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { useUserStore } from '@/stores/user';
import UserDistanceStatsComponent from '@/components/UserDistanceStatsComponent.vue';
import NoItemsFoundComponent from '@/components/NoItemsFoundComponents.vue';
export default {
components: {
UserDistanceStatsComponent,
NoItemsFoundComponent,
},
setup() {
const userStore = useUserStore();
const selectedActivity = ref('userActivities');
onMounted(() => {
userStore.fetchUserStats();
userStore.fetchUserActivitiesWithPagination(1, 5);
});
return {
selectedActivity,
userMe: userStore.userMe,
thisWeekDistances: userStore.thisWeekDistances,
thisMonthDistances: userStore.thisMonthDistances,
userActivities: userStore.userActivities,
followedUserActivities: userStore.followedUserActivities,
};
},
};
</script>

View File

@@ -0,0 +1,105 @@
<template>
<div class="form-signin w-100 m-auto text-center p-5" style="max-width: 500px">
<!-- Error alerts -->
<div class="alert alert-danger alert-dismissible d-flex align-items-center" role="alert" v-if="errorCode">
<font-awesome-icon :icon="['fas', 'fa-circle-exclamation']" />
<div class="ms-1">
<span v-if="errorCode === '401'">{{ $t("login.error401") }} (401)</span>
<span v-else-if="errorCode === '403'">{{ $t("login.error403") }} (403)</span>
<span v-else-if="errorCode === '500'">{{ $t("login.error500") }} (500)</span>
<span v-else>{{ $t("login.errorUndefined") }} (Undefined)</span>
<!--<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>-->
</div>
</div>
<!-- Info banners -->
<div class="alert alert-warning alert-dismissible d-flex align-items-center" role="alert" v-if="showSessionExpiredMessage">
<font-awesome-icon :icon="['fas', 'fa-triangle-exclamation']" />
<div class="ms-1">
<span>{{ $t("login.sessionExpired") }}</span>
<!--<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>-->
</div>
</div>
<form @submit.prevent="submitForm">
<h1>Endurain</h1>
<p>{{ $t("login.subtitle") }}</p>
<br>
<div class="form-floating">
<input type="text" class="form-control" id="floatingInput" placeholder="<?php echo $translationsLogin['login_insert_username']; ?>" name="loginUsername" v-model="username" required>
<label for="loginUsername">{{ $t("login.username") }}</label>
</div>
<br>
<div class="form-floating">
<input type="password" class="form-control" placeholder="<?php echo $translationsLogin['login_password']; ?>" name="loginPassword" v-model="password" required>
<label for="loginPassword">{{ $t("login.password") }}</label>
</div>
<br>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="loginNeverExpires" v-model="neverExpires">
<label class="form-check-label" for="loginNeverExpires">{{ $t("login.neverExpires") }}</label>
</div>
<br>
<button class="w-100 btn btn-lg btn-primary" type="submit">{{ $t("login.signInButton") }}</button>
<!--<div>
<br>
<p>{{ $t("login.signUpText") }}</p>
<button class="w-100 btn btn-lg btn-primary disabled" type="submit">{{ $t("login.signUpButton") }}></button>
</div>-->
</form>
</div>
</template>
<script>
import CryptoJS from 'crypto-js';
import { auth } from '@/services/auth';
export default {
data() {
return {
username: '',
password: '',
neverExpires: false,
errorCode: '',
showSessionExpiredMessage: false
};
},
created() {
if (this.$route.query.sessionExpired === 'true') {
this.showSessionExpiredMessage = true;
}
},
methods: {
async submitForm() {
// Hash the password using SHA-256
const hashedPassword = CryptoJS.SHA256(this.password).toString(CryptoJS.enc.Hex);
const formData = new URLSearchParams();
formData.append('username', this.username);
formData.append('password', hashedPassword);
formData.append('neverExpires', this.neverExpires);
try {
const token = await auth.getToken(formData);
const userMe = await auth.getUserMe(token.access_token);
auth.storeLoggedUser(token, userMe);
this.$router.push('/');
} catch (error) {
if (error.toString().includes('401')) {
this.errorCode = '401';
} else if (error.toString().includes('403')) {
this.errorCode = '403';
} else if (error.toString().includes('500')) {
this.errorCode = '500';
} else {
this.errorCode = 'undefined';
}
}
}
}
};
</script>

View File

@@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

View File

@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/*'],
root: fileURLToPath(new URL('./', import.meta.url))
}
})
)