Frontend revamp with Vue
35
.gitignore
vendored
@@ -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
|
||||
14
frontend_vue/.eslintrc.cjs
Normal 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'
|
||||
}
|
||||
}
|
||||
8
frontend_vue/.prettierrc.json
Normal 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
@@ -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>
|
||||
8
frontend_vue/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4116
frontend_vue/package-lock.json
generated
Normal file
39
frontend_vue/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
frontend_vue/public/logo/logo.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
13
frontend_vue/src/App.vue
Normal 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>
|
||||
BIN
frontend_vue/src/assets/avatar/bicycle1.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend_vue/src/assets/avatar/bicycle2.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend_vue/src/assets/avatar/female1.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
frontend_vue/src/assets/avatar/male1.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
frontend_vue/src/assets/avatar/running_shoe1.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend_vue/src/assets/avatar/running_shoe2.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend_vue/src/assets/avatar/wetsuit1.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend_vue/src/assets/avatar/wetsuit2.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
3
frontend_vue/src/components/ActivitySummary.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
|
||||
</template>
|
||||
6
frontend_vue/src/components/FooterComponent.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<footer class="border-top py-3 my-4 bg-body-tertiar">
|
||||
<p class="text-center text-muted">© {{ 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>
|
||||
77
frontend_vue/src/components/NavbarComponent.vue
Normal 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>
|
||||
11
frontend_vue/src/components/NoItemsFoundComponents.vue
Normal 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>
|
||||
68
frontend_vue/src/components/UserDistanceStatsComponent.vue
Normal 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>
|
||||
8
frontend_vue/src/i18n/en/components/navbarComponent.json
Normal 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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title" :"Ops...",
|
||||
"subtitle" :"No records found"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"thisMonthDistancesTitle" :"This month distances",
|
||||
"thisWeekDistancesTitle" :"This week distances",
|
||||
"distancesRun" :"Run",
|
||||
"distancesBike" :"Bike",
|
||||
"distancesSwim" :"Swim"
|
||||
}
|
||||
6
frontend_vue/src/i18n/en/homeView.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "Endurain",
|
||||
"buttonAddActivity": "Add Activity",
|
||||
"radioUserActivities": "My activities",
|
||||
"radioFollowerActivities": "Followers activities"
|
||||
}
|
||||
14
frontend_vue/src/i18n/en/loginView.json
Normal 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"
|
||||
}
|
||||
28
frontend_vue/src/i18n/index.js
Normal 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
@@ -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')
|
||||
48
frontend_vue/src/router/index.js
Normal 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;
|
||||
57
frontend_vue/src/services/activities.js
Normal 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();
|
||||
}
|
||||
};
|
||||
51
frontend_vue/src/services/auth.js
Normal 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();
|
||||
},
|
||||
};
|
||||
18
frontend_vue/src/services/user.js
Normal 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();
|
||||
},
|
||||
};
|
||||
35
frontend_vue/src/stores/auth.js
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
30
frontend_vue/src/stores/user.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
82
frontend_vue/src/views/HomeView.vue
Normal 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>
|
||||
105
frontend_vue/src/views/LoginView.vue
Normal 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>
|
||||
16
frontend_vue/vite.config.js
Normal 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
14
frontend_vue/vitest.config.js
Normal 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))
|
||||
}
|
||||
})
|
||||
)
|
||||