Merged dev branch

This commit is contained in:
Fmstrat
2022-07-18 14:34:58 -04:00
392 changed files with 20712 additions and 12757 deletions

View File

@ -0,0 +1,72 @@
<template>
<div class="about-text">
<div>
<p class="error-message" v-html="$t('about.FITTRACKEE_DESCRIPTION')" />
<p>
<i class="fa fa-book fa-padding" aria-hidden="true"></i>
<a
href="https://samr1.github.io/FitTrackee/"
target="_blank"
rel="noopener noreferrer"
>
{{ capitalize($t('common.DOCUMENTATION')) }}
</a>
</p>
<p>
<i class="fa fa-github fa-padding" aria-hidden="true"></i>
<a
href="https://github.com/SamR1/FitTrackee"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('about.SOURCE_CODE') }}
</a>
</p>
<p>
<i class="fa fa-balance-scale fa-padding" aria-hidden="true"></i>
<i18n-t keypath="about.FITTRACKEE_LICENSE">
<a
href="https://choosealicense.com/licenses/agpl-3.0/"
target="_blank"
rel="noopener noreferrer"
>
AGPLv3
</a>
</i18n-t>
</p>
<div v-if="appConfig.admin_contact">
<i class="fa fa-envelope-o fa-padding" aria-hidden="true"></i>
<a :href="`mailto:${appConfig.admin_contact}`">
{{ $t('about.CONTACT_ADMIN') }}
</a>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ComputedRef, computed, capitalize } from 'vue'
import { ROOT_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { useStore } from '@/use/useStore'
const store = useStore()
const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]
)
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
.about-text {
margin-top: 200px;
@media screen and (max-width: $small-limit) {
margin-top: 0;
}
.fa-padding {
padding-right: $default-padding;
}
}
</style>

View File

@ -4,6 +4,23 @@
<template #title>{{ $t('admin.APP_CONFIG.TITLE') }}</template>
<template #content>
<form class="admin-form" @submit.prevent="onSubmit">
<label for="admin_contact">
{{ $t('admin.APP_CONFIG.ADMIN_CONTACT') }}:
<input
class="no-contact"
v-if="!edition && !appData.admin_contact"
:value="$t('admin.APP_CONFIG.NO_CONTACT_EMAIL')"
disabled
/>
<input
v-else
id="admin_contact"
name="admin_contact"
type="email"
v-model="appData.admin_contact"
:disabled="!edition"
/>
</label>
<label for="max_users">
{{ $t('admin.APP_CONFIG.MAX_USERS_LABEL') }}:
<input
@ -89,6 +106,7 @@
reactive,
withDefaults,
onBeforeMount,
toRefs,
} from 'vue'
import { useRouter } from 'vue-router'
@ -104,11 +122,13 @@
const props = withDefaults(defineProps<Props>(), {
edition: false,
})
const { edition } = toRefs(props)
const store = useStore()
const router = useRouter()
const appData: TAppConfigForm = reactive({
admin_contact: '',
max_users: 0,
max_single_file_size: 0,
max_zip_file_size: 0,
@ -126,13 +146,13 @@
function updateForm(appConfig: TAppConfig) {
Object.keys(appData).map((key) => {
;['max_single_file_size', 'max_zip_file_size'].includes(key)
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(appData[key] = getFileSizeInMB(appConfig[key]))
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(appData[key] = appConfig[key])
['max_single_file_size', 'max_zip_file_size'].includes(key)
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(appData[key] = getFileSizeInMB(appConfig[key]))
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(appData[key] = appConfig[key])
})
}
function onCancel() {
@ -160,4 +180,7 @@
margin-right: $default-margin;
}
}
.no-contact {
font-style: italic;
}
</style>

View File

@ -11,7 +11,7 @@
{{ $t('admin.APPLICATION') }}
</router-link>
</dt>
<dd>
<dd class="application-config-details">
{{ $t('admin.UPDATE_APPLICATION_DESCRIPTION') }}<br />
<span class="registration-status">
{{
@ -22,6 +22,13 @@
)
}}
</span>
<span
class="email-sending-status"
v-if="!appConfig.is_email_sending_enabled"
>
<i class="fa fa-exclamation-triangle" aria-hidden="true" />
{{ $t('admin.EMAIL_SENDING_DISABLED') }}
</span>
</dd>
<dt>
<router-link to="/admin/sports">
@ -82,8 +89,13 @@
dd {
margin-bottom: $default-margin * 3;
}
.registration-status {
font-weight: bold;
.application-config-details {
display: flex;
flex-direction: column;
.email-sending-status,
.registration-status {
font-weight: bold;
}
}
}
}

View File

@ -6,6 +6,7 @@
<button class="top-button" @click.prevent="$router.push('/admin')">
{{ $t('admin.BACK_TO_ADMIN') }}
</button>
<UsersNameFilter @filterOnUsername="searchUsers" />
<FilterSelects
:sort="sortList"
:order_by="orderByList"
@ -13,7 +14,10 @@
message="admin.USERS.SELECTS.ORDER_BY"
@updateSelect="reloadUsers"
/>
<div class="responsive-table">
<div class="no-users" v-if="users.length === 0">
{{ $t('user.NO_USERS_FOUND') }}
</div>
<div class="responsive-table" v-else>
<table>
<thead>
<tr>
@ -26,6 +30,7 @@
<th>
{{ capitalize($t('workouts.WORKOUT', 0)) }}
</th>
<th>{{ $t('admin.ACTIVE') }}</th>
<th>{{ $t('user.ADMIN') }}</th>
<th>{{ $t('admin.ACTION') }}</th>
</tr>
@ -42,7 +47,7 @@
<span class="cell-heading">
{{ $t('user.USERNAME') }}
</span>
<router-link :to="`/users/${user.username}`">
<router-link :to="`/admin/users/${user.username}`">
{{ user.username }}
</router-link>
</td>
@ -69,6 +74,15 @@
</span>
{{ user.nb_workouts }}
</td>
<td class="text-center">
<span class="cell-heading">
{{ $t('admin.ACTIVE') }}
</span>
<i
:class="`fa fa${user.is_active ? '-check' : ''}-square-o`"
aria-hidden="true"
/>
</td>
<td class="text-center">
<span class="cell-heading">
{{ $t('user.ADMIN') }}
@ -119,6 +133,7 @@
import { format } from 'date-fns'
import {
ComputedRef,
Ref,
computed,
reactive,
watch,
@ -131,9 +146,10 @@
import FilterSelects from '@/components/Common/FilterSelects.vue'
import Pagination from '@/components/Common/Pagination.vue'
import UserPicture from '@/components/User/UserPicture.vue'
import UsersNameFilter from '@/components/Users/UsersNameFilter.vue'
import { AUTH_USER_STORE, ROOT_STORE, USERS_STORE } from '@/store/constants'
import { IPagination, TPaginationPayload } from '@/types/api'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile, IUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
import { getQuery, sortList } from '@/utils/api'
import { getDateWithTZ } from '@/utils/dates'
@ -143,6 +159,7 @@
const router = useRouter()
const orderByList: string[] = [
'is_active',
'admin',
'created_at',
'username',
@ -152,7 +169,7 @@
let query: TPaginationPayload = reactive(
getQuery(route.query, orderByList, defaultOrderBy)
)
const authUser: ComputedRef<IUserProfile> = computed(
const authUser: ComputedRef<IAuthUserProfile> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.AUTH_USER_PROFILE]
)
const users: ComputedRef<IUserProfile[]> = computed(
@ -170,6 +187,10 @@
function loadUsers(queryParams: TPaginationPayload) {
store.dispatch(USERS_STORE.ACTIONS.GET_USERS, queryParams)
}
function searchUsers(username: Ref<string>) {
reloadUsers('q', username.value)
}
function updateUser(username: string, admin: boolean) {
store.dispatch(USERS_STORE.ACTIONS.UPDATE_USER, {
username,
@ -203,6 +224,14 @@
.top-button {
display: none;
}
.no-users {
display: flex;
justify-content: center;
padding: $default-padding * 2 0;
font-weight: bold;
}
table {
td {
font-size: 1.1em;

View File

@ -10,12 +10,17 @@
#bike {
display: flex;
justify-content: center;
margin-top: 180px;
padding: $default-padding;
height: 100%;
.bike-img {
max-width: 40%;
max-width: 200px;
}
@media screen and (max-width: $small-limit) {
margin-top: $default-margin;
.bike-img {
max-width: 150px;
}
}
}
</style>

View File

@ -31,7 +31,7 @@
const emit = defineEmits(['updateValue'])
let text = ref('')
const text = ref('')
function updateText(event: Event & { target: HTMLInputElement }) {
emit('updateValue', event.target.value)

View File

@ -33,8 +33,8 @@
})
const route = useRoute()
let isOpen = ref(false)
let dropdownOptions = props.options.map((option) => option)
const isOpen = ref(false)
const dropdownOptions = props.options.map((option) => option)
function toggleDropdown() {
isOpen.value = !isOpen.value

View File

@ -0,0 +1,28 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -51 512 512">
<g id="error">
<path
class="error-page-img"
d="M 0 0 C 0 11.300781 0 399.777344 0 410 L 512 410 C 512 402.324219 512 2.425781 512 0 Z M 370 71 L 370 30 L 411 30 L 411 71 Z M 30 30 L 340 30 L 340 71 L 30 71 Z M 482 380 L 30 380 L 30 101 L 482 101 Z M 441 71 L 441 30 L 482 30 L 482 71 Z M 441 71 "
/>
<path
class="error-page-img"
d="M 325.519531 297.070312 C 294.328125 265.878906 294.328125 215.125 325.519531 183.929688 L 304.304688 162.71875 C 261.417969 205.605469 261.417969 275.390625 304.304688 318.28125 Z M 325.519531 297.070312 "
/>
<path
class="error-page-img"
d="M 197.089844 180 L 237.089844 180 L 237.089844 220 L 197.089844 220 Z M 197.089844 180 "
/>
<path
class="error-page-img"
d="M 197.089844 261 L 237.089844 261 L 237.089844 301 L 197.089844 301 Z M 197.089844 261 "
/>
</g>
</svg>
</template>
<script>
export default {
name: 'ErrorImg',
}
</script>

View File

@ -3,13 +3,15 @@
<ul class="pagination">
<li class="page-prev" :class="{ disabled: !pagination.has_prev }">
<router-link
v-slot="{ navigate }"
class="page-link"
:to="{ path, query: getQuery(pagination.page, -1) }"
:event="pagination.has_prev ? 'click' : ''"
:disabled="!pagination.has_prev"
>
<i class="fa fa-chevron-left" aria-hidden="true" />
{{ $t('api.PAGINATION.PREVIOUS') }}
<slot @click="pagination.has_next ? navigate : null">
{{ $t('api.PAGINATION.PREVIOUS') }}
<i class="fa fa-chevron-left" aria-hidden="true" />
</slot>
</router-link>
</li>
<li
@ -29,13 +31,15 @@
</li>
<li class="page-next" :class="{ disabled: !pagination.has_next }">
<router-link
v-slot="{ navigate }"
class="page-link"
:to="{ path, query: getQuery(pagination.page, 1) }"
:event="pagination.has_next ? 'click' : ''"
:disabled="!pagination.has_next"
>
{{ $t('api.PAGINATION.NEXT') }}
<i class="fa fa-chevron-right" aria-hidden="true" />
<slot @click="pagination.has_next ? navigate : null">
{{ $t('api.PAGINATION.NEXT') }}
<i class="fa fa-chevron-right" aria-hidden="true" />
</slot>
</router-link>
</li>
</ul>
@ -45,20 +49,23 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import { IPagination } from '@/types/api'
import { IPagination, TPaginationPayload } from '@/types/api'
import { TWorkoutsPayload } from '@/types/workouts'
import { rangePagination } from '@/utils/api'
interface Props {
pagination: IPagination
path: string
query: TWorkoutsPayload
query: TWorkoutsPayload | TPaginationPayload
}
const props = defineProps<Props>()
const { pagination, path, query } = toRefs(props)
function getQuery(page: number, cursor?: number): TWorkoutsPayload {
function getQuery(
page: number,
cursor?: number
): TWorkoutsPayload | TPaginationPayload {
const newQuery = Object.assign({}, query.value)
newQuery.page = cursor ? page + cursor : page
return newQuery
@ -92,6 +99,8 @@
&.disabled {
cursor: default;
a {
cursor: default;
pointer-events: none;
color: var(--disabled-color);
}
}

View File

@ -0,0 +1,95 @@
<template>
<div class="password-input">
<input
:id="id"
:disabled="disabled"
:placeholder="placeholder"
:required="required"
:type="showPassword ? 'text' : 'password'"
v-model="passwordValue"
minlength="8"
@input="updatePassword"
@invalid="invalidPassword"
/>
<div class="show-password" @click="togglePassword">
{{ $t(`user.${showPassword ? 'HIDE' : 'SHOW'}_PASSWORD`) }}
<i
class="fa"
:class="`fa-eye${showPassword ? '-slash' : ''}`"
aria-hidden="true"
/>
</div>
<div v-if="checkStrength" class="form-info">
<i class="fa fa-info-circle" aria-hidden="true" />
{{ $t('user.PASSWORD_INFO') }}
</div>
<PasswordStrength v-if="checkStrength" :password="passwordValue" />
</div>
</template>
<script setup lang="ts">
import { Ref, ref, toRefs, watch, withDefaults } from 'vue'
import PasswordStrength from '@/components/Common/PasswordStength.vue'
interface Props {
checkStrength?: boolean
disabled?: boolean
id?: string
password?: string
placeholder?: string
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
checkStrength: false,
disabled: false,
id: 'password',
password: '',
required: false,
})
const { checkStrength, disabled, id, password, placeholder, required } =
toRefs(props)
const showPassword: Ref<boolean> = ref(false)
const passwordValue: Ref<string> = ref('')
const emit = defineEmits(['updatePassword', 'passwordError'])
function togglePassword() {
showPassword.value = !showPassword.value
}
function updatePassword(event: Event & { target: HTMLInputElement }) {
emit('updatePassword', event.target.value)
}
function invalidPassword() {
emit('passwordError')
}
watch(
() => password.value,
(newPassword) => {
if (newPassword === '') {
passwordValue.value = ''
}
}
)
</script>
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
.password-input {
display: flex;
flex-direction: column;
.show-password {
font-style: italic;
font-size: 0.85em;
text-align: right;
margin-top: -0.75 * $default-margin;
padding-right: $default-padding;
cursor: pointer;
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="password-strength">
<input
class="password-slider"
:class="`strength-${passwordScore}`"
:style="{ backgroundSize: backgroundSize }"
type="range"
:value="passwordScore"
min="0"
max="4"
step="1"
/>
<div v-if="passwordStrength" class="password-strength-details">
<span class="password-strength-value">
{{ $t('user.PASSWORD_STRENGTH.LABEL') }}:
{{ $t(`user.PASSWORD_STRENGTH.${passwordStrength}`) }}
</span>
<div class="info-box" v-if="passwordSuggestions.length > 0">
<ul class="password-feedback">
<li v-for="suggestion in passwordSuggestions" :key="suggestion">
{{ $t(`user.PASSWORD_STRENGTH.SUGGESTIONS.${suggestion}`) }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { zxcvbn } from '@zxcvbn-ts/core'
import {
ComputedRef,
Ref,
computed,
ref,
onBeforeMount,
toRefs,
watch,
} from 'vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { useStore } from '@/use/useStore'
import { getPasswordStrength, setZxcvbnOptions } from '@/utils/password'
interface Props {
password: string
}
const props = defineProps<Props>()
const { password } = toRefs(props)
const store = useStore()
const language: ComputedRef<string> = computed(
() => store.getters[ROOT_STORE.GETTERS.LANGUAGE]
)
const isSuccess: ComputedRef<boolean> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.IS_SUCCESS]
)
const passwordScore: Ref<number> = ref(0)
const passwordStrength: Ref<string> = ref('')
const passwordSuggestions: Ref<string[]> = ref([])
const backgroundSize = ref('0% 100%')
onBeforeMount(async () => await setZxcvbnOptions(language.value))
function calculatePasswordStrength(password: string) {
const zxcvbnResult = zxcvbn(password)
passwordScore.value = zxcvbnResult.score
passwordStrength.value = getPasswordStrength(passwordScore.value)
passwordSuggestions.value = zxcvbnResult.feedback.suggestions
backgroundSize.value = (passwordScore.value * 100) / 4 + '% 100%'
}
watch(
() => language.value,
async (newLanguageValue) => {
await setZxcvbnOptions(newLanguageValue)
}
)
watch(
() => password.value,
async (newPassword) => {
if (isSuccess.value) {
passwordStrength.value = ''
} else {
calculatePasswordStrength(newPassword)
}
}
)
</script>
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
.password-strength {
cursor: default;
display: flex;
flex-direction: column;
@mixin slider-background-image($color) {
background: var(--password-bg-color);
background-image: -webkit-gradient(
linear,
20% 0%,
20% 100%,
color-stop(0%, $color),
color-stop(100%, $color)
);
background-image: -webkit-linear-gradient(left, $color 0%, $color 100%);
background-image: -moz-linear-gradient(left, $color 0%, $color 100%);
background-image: -o-linear-gradient(to right, $color 0%, $color 100%);
background-image: linear-gradient(to right, $color 0%, $color 100%);
background-repeat: no-repeat;
}
.password-slider {
-webkit-appearance: none;
appearance: none;
border: none;
border-radius: 8px;
height: 5px;
outline: none;
padding: 0;
}
.strength-0,
.strength-1 {
@include slider-background-image(var(--password-color-weak));
}
.strength-2 {
@include slider-background-image(var(--password-color-medium));
}
.strength-3 {
@include slider-background-image(var(--password-color-good));
}
.strength-4 {
@include slider-background-image(var(--password-color-strong));
}
.password-slider::-webkit-slider-thumb,
.password-slider::-moz-range-thumb {
opacity: 0;
}
.password-slider::-webkit-slider-thumb {
-webkit-appearance: none;
}
.password-slider::-moz-range-thumb {
appearance: none;
}
.password-strength-details {
margin-bottom: $default-margin * 0.5;
margin-top: -1 * $default-margin;
padding: 0 $default-padding;
.password-strength-value {
font-size: 0.85em;
}
.info-box {
padding: $default-padding * 0.1 $default-padding;
.password-feedback {
padding-left: $default-padding * 2;
}
}
}
}
</style>

View File

@ -55,7 +55,7 @@
function getSum(total: any, value: any): number {
return getNumber(total) + getNumber(value)
}
let chartData: ComputedRef<ChartData<'bar'>> = computed(() => ({
const chartData: ComputedRef<ChartData<'bar'>> = computed(() => ({
labels: props.labels,
// workaround to avoid dataset modification
datasets: JSON.parse(JSON.stringify(props.datasets)),

View File

@ -96,7 +96,7 @@
TStatisticsFromApi,
IStatisticsParams,
} from '@/types/statistics'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
import { formatStats } from '@/utils/statistics'
@ -111,7 +111,7 @@
required: true,
},
user: {
type: Object as PropType<IUserProfile>,
type: Object as PropType<IAuthUserProfile>,
required: true,
},
chartParams: {
@ -134,7 +134,7 @@
setup(props) {
const store = useStore()
let displayedData: Ref<TStatisticsDatasetKeys> = ref('total_distance')
const displayedData: Ref<TStatisticsDatasetKeys> = ref('total_distance')
const statistics: ComputedRef<TStatisticsFromApi> = computed(
() => store.getters[STATS_STORE.GETTERS.USER_STATS]
)
@ -169,7 +169,7 @@
}
function getApiParams(
chartParams: IStatisticsDateParams,
user: IUserProfile
user: IAuthUserProfile
): IStatisticsParams {
return {
from: format(chartParams.start, 'yyyy-MM-dd'),

View File

@ -53,7 +53,7 @@
const store = useStore()
const { sports, user } = toRefs(props)
let page = ref(1)
const page = ref(1)
const per_page = 5
const initWorkoutsCount =
props.user.nb_workouts >= per_page ? per_page : props.user.nb_workouts

View File

@ -33,7 +33,7 @@
import CalendarHeader from '@/components/Dashboard/UserCalendar/CalendarHeader.vue'
import { ROOT_STORE, WORKOUTS_STORE } from '@/store/constants'
import { ISport } from '@/types/sports'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { IWorkout, TWorkoutsPayload } from '@/types/workouts'
import { useStore } from '@/use/useStore'
import { getCalendarStartAndEnd } from '@/utils/dates'
@ -41,7 +41,7 @@
interface Props {
sports: ISport[]
user: IUserProfile
user: IAuthUserProfile
}
const props = defineProps<Props>()
@ -49,8 +49,8 @@
const { sports, user } = toRefs(props)
const dateFormat = 'yyyy-MM-dd'
let day = ref(new Date())
let calendarDates = ref(getCalendarStartAndEnd(day.value, props.user.weekm))
const day = ref(new Date())
const calendarDates = ref(getCalendarStartAndEnd(day.value, props.user.weekm))
const calendarWorkouts: ComputedRef<IWorkout[]> = computed(
() => store.getters[WORKOUTS_STORE.GETTERS.CALENDAR_WORKOUTS]
)

View File

@ -6,9 +6,13 @@
{{ sportTranslatedLabel }}
</template>
<template #content>
<div class="record" v-for="record in records.records" :key="record.id">
<div
class="record"
v-for="record in getTranslatedRecords(records.records)"
:key="record.id"
>
<span class="record-type">
{{ $t(`workouts.RECORD_${record.record_type}`) }}
{{ record.label }}
</span>
<span class="record-value">{{ record.value }}</span>
<span class="record-date">
@ -29,8 +33,10 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import { IRecordsBySports } from '@/types/workouts'
import { ICardRecord, IRecord, IRecordsBySports } from '@/types/workouts'
import { sortRecords } from '@/utils/records'
interface Props {
records: IRecordsBySports
@ -39,6 +45,19 @@
const props = defineProps<Props>()
const { records, sportTranslatedLabel } = toRefs(props)
const { t } = useI18n()
function getTranslatedRecords(records: IRecord[]): ICardRecord[] {
const translatedRecords: ICardRecord[] = []
records.map((record) => {
translatedRecords.push({
...record,
label: t(`workouts.RECORD_${record.record_type}`),
})
})
return translatedRecords.sort(sortRecords)
}
</script>
<style lang="scss" scoped>
@ -64,6 +83,7 @@
padding: $default-padding;
.record {
display: flex;
align-items: center;
justify-content: space-between;
span {
padding: 2px 5px;
@ -73,6 +93,7 @@
}
.record-value {
font-weight: bold;
white-space: nowrap;
padding-right: $default-padding * 2;
}
}

View File

@ -25,13 +25,13 @@
import RecordsCard from '@/components/Dashboard/UserRecords/RecordsCard.vue'
import { ISport } from '@/types/sports'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { getRecordsBySports } from '@/utils/records'
import { translateSports } from '@/utils/sports'
interface Props {
sports: ISport[]
user: IUserProfile
user: IAuthUserProfile
}
const props = defineProps<Props>()

View File

@ -34,10 +34,10 @@
import StatCard from '@/components/Common/StatCard.vue'
import { TUnit } from '@/types/units'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { convertDistance, units } from '@/utils/units'
interface Props {
user: IUserProfile
user: IAuthUserProfile
}
const props = defineProps<Props>()
@ -52,16 +52,19 @@
const distanceUnitTo: TUnit = user.value.imperial_units
? units[distanceUnitFrom].defaultTarget
: distanceUnitFrom
const totalDistance = user.value.imperial_units
? convertDistance(user.value.total_distance, distanceUnitFrom, distanceUnitTo, 2)
: parseFloat(user.value.total_distance.toFixed(2))
const totalDistance: ComputedRef<number> = computed(() =>
user.value.imperial_units
? convertDistance(user.value.total_distance, distanceUnitFrom, distanceUnitTo, 2)
: parseFloat(user.value.total_distance.toFixed(2)))
const ascentUnitFrom: TUnit = 'm'
const ascentUnitTo: TUnit = user.value.imperial_units
? units[ascentUnitFrom].defaultTarget
: ascentUnitFrom
const totalAscent = user.value.imperial_units
? convertDistance(user.value.total_ascent, ascentUnitFrom, ascentUnitTo, 2)
: parseFloat(user.value.total_ascent.toFixed(2))
const totalAscent: ComputedRef<number> = computed(() =>
user.value.imperial_units
? convertDistance(user.value.total_ascent, ascentUnitFrom, ascentUnitTo, 2)
: parseFloat(user.value.total_ascent.toFixed(2)))
function get_duration(total_duration: ComputedRef<string>) {
const duration = total_duration.value.match(/day/g)

View File

@ -7,21 +7,13 @@
</div>
<div class="footer-item bullet"></div>
<div class="footer-item">
<a
href="https://github.com/SamR1/FitTrackee"
target="_blank"
rel="noopener noreferrer"
>
source code
</a>
under
<a
href="https://choosealicense.com/licenses/agpl-3.0/"
target="_blank"
rel="noopener noreferrer"
>
AGPLv3 </a
>license
<router-link to="/about">
{{ $t('common.ABOUT') }}
</router-link>
</div>
<div class="footer-item bullet" v-if="adminContact"></div>
<div class="footer-item" v-if="adminContact">
<a :href="`mailto:${adminContact}`">{{ $t('common.CONTACT') }}</a>
</div>
<div class="footer-item bullet"></div>
<div class="footer-item">
@ -30,7 +22,7 @@
target="_blank"
rel="noopener noreferrer"
>
documentation
{{ $t('common.DOCUMENTATION') }}
</a>
</div>
</div>
@ -42,10 +34,11 @@
interface Props {
version: string
adminContact?: string
}
const props = defineProps<Props>()
const { version } = toRefs(props)
const { adminContact, version } = toRefs(props)
</script>
<style scoped lang="scss">

View File

@ -79,21 +79,19 @@
<script setup lang="ts">
import { ComputedRef, computed, ref, capitalize } from 'vue'
import { useI18n } from 'vue-i18n'
import UserPicture from '@/components/User/UserPicture.vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { IDropdownOption } from '@/types/forms'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
import { availableLanguages } from '@/utils/locales'
const emit = defineEmits(['menuInteraction'])
const { locale } = useI18n()
const store = useStore()
const authUser: ComputedRef<IUserProfile> = computed(
const authUser: ComputedRef<IAuthUserProfile> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.AUTH_USER_PROFILE]
)
const isAuthenticated: ComputedRef<boolean> = computed(
@ -102,7 +100,7 @@
const language: ComputedRef<string> = computed(
() => store.getters[ROOT_STORE.GETTERS.LANGUAGE]
)
let isMenuOpen = ref(false)
const isMenuOpen = ref(false)
function openMenu() {
isMenuOpen.value = true
@ -113,8 +111,10 @@
emit('menuInteraction', false)
}
function updateLanguage(option: IDropdownOption) {
locale.value = option.value.toString()
store.commit(ROOT_STORE.MUTATIONS.UPDATE_LANG, option.value)
store.dispatch(
ROOT_STORE.ACTIONS.UPDATE_APPLICATION_LANGUAGE,
option.value.toString()
)
}
function logout() {
store.dispatch(AUTH_USER_STORE.ACTIONS.LOGOUT)

View File

@ -2,32 +2,17 @@
<div id="no-config">
<div class="error-page">
<div class="error-img">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -51 512 512">
<g id="error">
<path
class="error-page-img"
d="M 0 0 C 0 11.300781 0 399.777344 0 410 L 512 410 C 512 402.324219 512 2.425781 512 0 Z M 370 71 L 370 30 L 411 30 L 411 71 Z M 30 30 L 340 30 L 340 71 L 30 71 Z M 482 380 L 30 380 L 30 101 L 482 101 Z M 441 71 L 441 30 L 482 30 L 482 71 Z M 441 71 "
/>
<path
class="error-page-img"
d="M 325.519531 297.070312 C 294.328125 265.878906 294.328125 215.125 325.519531 183.929688 L 304.304688 162.71875 C 261.417969 205.605469 261.417969 275.390625 304.304688 318.28125 Z M 325.519531 297.070312 "
/>
<path
class="error-page-img"
d="M 197.089844 180 L 237.089844 180 L 237.089844 220 L 197.089844 220 Z M 197.089844 180 "
/>
<path
class="error-page-img"
d="M 197.089844 261 L 237.089844 261 L 237.089844 301 L 197.089844 301 Z M 197.089844 261 "
/>
</g>
</svg>
<ErrorImg />
</div>
<p class="error-message" v-html="$t('error.APP_ERROR')" />
</div>
</div>
</template>
<script lang="ts" setup>
import ErrorImg from '@/components/Common/Images/ErrorImg.vue'
</script>
<style scoped lang="scss">
@import '~@/scss/vars.scss';
@ -49,12 +34,10 @@
width: 150px;
svg {
.error-page-img {
stroke: none;
fill-rule: nonzero;
fill: var(--app-color);
filter: var(--svg-filter);
}
stroke: none;
fill-rule: nonzero;
fill: var(--app-color);
filter: var(--svg-filter);
}
}

View File

@ -42,7 +42,7 @@
const emit = defineEmits(['arrowClick', 'timeFrameUpdate'])
let selectedTimeFrame = ref('month')
const selectedTimeFrame = ref('month')
const timeFrames = ['week', 'month', 'year']
function onUpdateTimeFrame(timeFrame: string) {

View File

@ -41,7 +41,7 @@
const { t } = useI18n()
const { sports, user } = toRefs(props)
let selectedTimeFrame = ref('month')
const selectedTimeFrame = ref('month')
const chartParams: Ref<IStatisticsDateParams> = ref(
getChartParams(selectedTimeFrame.value)
)

View File

@ -0,0 +1,72 @@
<template>
<div id="account-confirmation-email" class="center-card with-margin">
<div class="email-sent" v-if="action === 'email-sent'">
<EmailSent />
<div class="email-sent-message">
{{ $t('user.ACCOUNT_CONFIRMATION_SENT') }}
</div>
</div>
<div v-else>
<Card>
<template #title>{{ $t('user.RESENT_ACCOUNT_CONFIRMATION') }}</template>
<template #content>
<UserAuthForm :action="action" />
</template>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { toRefs } from 'vue'
import EmailSent from '@/components/Common/Images/EmailSent.vue'
import UserAuthForm from '@/components/User/UserAuthForm.vue'
interface Props {
action: string
}
const props = defineProps<Props>()
const { action } = toRefs(props)
</script>
<style scoped lang="scss">
@import '~@/scss/vars.scss';
#account-confirmation-email {
display: flex;
flex-direction: column;
.email-sent {
display: flex;
flex-direction: column;
align-items: center;
svg {
stroke: none;
fill-rule: nonzero;
fill: var(--app-color);
filter: var(--svg-filter);
width: 100px;
}
.email-sent-message {
font-size: 1.1em;
text-align: center;
@media screen and (max-width: $medium-limit) {
font-size: 1em;
}
}
}
::v-deep(.card) {
.card-content {
#user-auth-form {
margin-top: 0;
#user-form {
width: 100%;
}
}
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div id="password-action-done" class="center-card center-card with-margin">
<div id="password-action-done" class="center-card with-margin">
<EmailSent v-if="action === 'request-sent'" />
<Password v-else />
<div class="password-message">

View File

@ -16,10 +16,10 @@
unitFrom="km"
:digits="0"
:displayUnit="false"
:useImperialUnits="user.imperial_units"
:useImperialUnits="authUser.imperial_units"
/>
<span class="stat-label">
{{ user.imperial_units ? 'miles' : 'km' }}
{{ authUser.imperial_units ? 'miles' : 'km' }}
</span>
</div>
<div class="user-stat hide-small">
@ -34,10 +34,12 @@
</template>
<script setup lang="ts">
import { toRefs } from 'vue'
import { computed, ComputedRef, toRefs } from 'vue'
import UserPicture from '@/components/User/UserPicture.vue'
import { IUserProfile } from '@/types/user'
import { AUTH_USER_STORE } from '@/store/constants'
import { IAuthUserProfile, IUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
interface Props {
user: IUserProfile
@ -45,6 +47,12 @@
const props = defineProps<Props>()
const { user } = toRefs(props)
const store = useStore()
const authUser: ComputedRef<IAuthUserProfile> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.AUTH_USER_PROFILE]
)
</script>
<style lang="scss" scoped>

View File

@ -3,52 +3,132 @@
<Modal
v-if="displayModal"
:title="$t('common.CONFIRMATION')"
message="admin.CONFIRM_USER_ACCOUNT_DELETION"
:message="
displayModal === 'delete'
? 'admin.CONFIRM_USER_ACCOUNT_DELETION'
: 'admin.CONFIRM_USER_PASSWORD_RESET'
"
:strongMessage="user.username"
@confirmAction="deleteUserAccount(user.username)"
@cancelAction="updateDisplayModal(false)"
@confirmAction="
displayModal === 'delete'
? deleteUserAccount(user.username)
: resetUserPassword(user.username)
"
@cancelAction="updateDisplayModal('')"
/>
<dl>
<dt>{{ $t('user.PROFILE.REGISTRATION_DATE') }}:</dt>
<dd>{{ registrationDate }}</dd>
<dt>{{ $t('user.PROFILE.FIRST_NAME') }}:</dt>
<dd>{{ user.first_name }}</dd>
<dt>{{ $t('user.PROFILE.LAST_NAME') }}:</dt>
<dd>{{ user.last_name }}</dd>
<dt>{{ $t('user.PROFILE.BIRTH_DATE') }}:</dt>
<dd>{{ birthDate }}</dd>
<dt>{{ $t('user.PROFILE.LOCATION') }}:</dt>
<dd>{{ user.location }}</dd>
<dt>{{ $t('user.PROFILE.BIO') }}:</dt>
<dd class="user-bio">
{{ user.bio }}
</dd>
</dl>
<div class="profile-buttons" v-if="fromAdmin">
<button
class="danger"
v-if="authUser.username !== user.username"
@click.prevent="updateDisplayModal(true)"
>
{{ $t('admin.DELETE_USER') }}
</button>
<button @click="$router.go(-1)">{{ $t('buttons.BACK') }}</button>
<div class="info-box success-message" v-if="isSuccess">
{{
$t(
`admin.${
currentAction === 'password-reset'
? 'PASSWORD_RESET'
: 'USER_EMAIL_UPDATE'
}_SUCCESSFUL`
)
}}
</div>
<div class="profile-buttons" v-else>
<button @click="$router.push('/profile/edit')">
{{ $t('user.PROFILE.EDIT') }}
</button>
<button @click="$router.push('/')">{{ $t('common.HOME') }}</button>
<AlertMessage
message="user.THIS_USER_ACCOUNT_IS_INACTIVE"
v-if="!user.is_active"
/>
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<div class="email-form form-box" v-if="displayUserEmailForm">
<form
:class="{ errors: formErrors }"
@submit.prevent="updateUserEmail(user.username)"
>
<label class="form-items" for="email">
{{ $t('admin.CURRENT_EMAIL') }}
<input id="email" type="email" v-model="user.email" disabled />
</label>
<label class="form-items" for="email">
{{ $t('admin.NEW_EMAIL') }}*
<input id="new-email" type="email" required v-model="newUserEmail" />
</label>
<div class="form-buttons">
<button class="confirm" type="submit">
{{ $t('buttons.SUBMIT') }}
</button>
<button class="cancel" @click.prevent="hideEmailForm">
{{ $t('buttons.CANCEL') }}
</button>
</div>
</form>
</div>
<div v-else>
<dl>
<dt>{{ $t('user.PROFILE.REGISTRATION_DATE') }}:</dt>
<dd>{{ registrationDate }}</dd>
<dt>{{ $t('user.PROFILE.FIRST_NAME') }}:</dt>
<dd>{{ user.first_name }}</dd>
<dt>{{ $t('user.PROFILE.LAST_NAME') }}:</dt>
<dd>{{ user.last_name }}</dd>
<dt>{{ $t('user.PROFILE.BIRTH_DATE') }}:</dt>
<dd>{{ birthDate }}</dd>
<dt>{{ $t('user.PROFILE.LOCATION') }}:</dt>
<dd>{{ user.location }}</dd>
<dt>{{ $t('user.PROFILE.BIO') }}:</dt>
<dd class="user-bio">
{{ user.bio }}
</dd>
</dl>
<div class="profile-buttons" v-if="fromAdmin">
<button
class="danger"
v-if="authUser.username !== user.username"
@click.prevent="updateDisplayModal('delete')"
>
{{ $t('admin.DELETE_USER') }}
</button>
<button
v-if="!user.is_active"
@click.prevent="confirmUserAccount(user.username)"
>
{{ $t('admin.ACTIVATE_USER_ACCOUNT') }}
</button>
<button
v-if="authUser.username !== user.username"
@click.prevent="displayEmailForm"
>
{{ $t('admin.UPDATE_USER_EMAIL') }}
</button>
<button
v-if="
authUser.username !== user.username &&
appConfig.is_email_sending_enabled
"
@click.prevent="updateDisplayModal('reset')"
>
{{ $t('admin.RESET_USER_PASSWORD') }}
</button>
<button @click="$router.go(-1)">{{ $t('buttons.BACK') }}</button>
</div>
<div class="profile-buttons" v-else>
<button @click="$router.push('/profile/edit')">
{{ $t('user.PROFILE.EDIT') }}
</button>
<button @click="$router.push('/')">{{ $t('common.HOME') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { format } from 'date-fns'
import { ComputedRef, Ref, computed, ref, toRefs, withDefaults } from 'vue'
import {
ComputedRef,
Ref,
computed,
ref,
toRefs,
withDefaults,
watch,
onUnmounted,
} from 'vue'
import { AUTH_USER_STORE, USERS_STORE } from '@/store/constants'
import { IUserProfile } from '@/types/user'
import { AUTH_USER_STORE, ROOT_STORE, USERS_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { IAuthUserProfile, IUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
interface Props {
@ -62,7 +142,7 @@
const store = useStore()
const { user, fromAdmin } = toRefs(props)
const authUser: ComputedRef<IUserProfile> = computed(
const authUser: ComputedRef<IAuthUserProfile> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.AUTH_USER_PROFILE]
)
const registrationDate = computed(() =>
@ -75,20 +155,106 @@
? format(new Date(props.user.birth_date), 'dd/MM/yyyy')
: ''
)
let displayModal: Ref<boolean> = ref(false)
const isSuccess = computed(
() => store.getters[USERS_STORE.GETTERS.USERS_IS_SUCCESS]
)
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]
)
const displayModal: Ref<string> = ref('')
const formErrors = ref(false)
const displayUserEmailForm: Ref<boolean> = ref(false)
const newUserEmail: Ref<string> = ref('')
const currentAction: Ref<string> = ref('')
function updateDisplayModal(value: boolean) {
function updateDisplayModal(value: string) {
displayModal.value = value
if (value !== '') {
store.commit(USERS_STORE.MUTATIONS.UPDATE_IS_SUCCESS, false)
}
}
function deleteUserAccount(username: string) {
store.dispatch(USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT, { username })
}
function resetUserPassword(username: string) {
currentAction.value = 'password-reset'
store.dispatch(USERS_STORE.ACTIONS.UPDATE_USER, {
username,
resetPassword: true,
})
}
function confirmUserAccount(username: string) {
store.dispatch(USERS_STORE.ACTIONS.UPDATE_USER, {
username,
activate: true,
})
}
function displayEmailForm() {
resetErrorsAndSuccess()
newUserEmail.value = user.value.email_to_confirm
? user.value.email_to_confirm
: ''
displayUserEmailForm.value = true
currentAction.value = 'email-update'
}
function hideEmailForm() {
newUserEmail.value = ''
displayUserEmailForm.value = false
}
function updateUserEmail(username: string) {
store.dispatch(USERS_STORE.ACTIONS.UPDATE_USER, {
username,
new_email: newUserEmail.value,
})
}
function resetErrorsAndSuccess() {
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
store.commit(USERS_STORE.MUTATIONS.UPDATE_IS_SUCCESS, false)
currentAction.value = ''
}
onUnmounted(() => resetErrorsAndSuccess())
watch(
() => isSuccess.value,
(newIsSuccess) => {
if (newIsSuccess) {
updateDisplayModal('')
hideEmailForm()
}
}
)
</script>
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
#user-infos {
.user-bio {
white-space: pre-wrap;
}
.alert-message {
margin: 0;
}
.profile-buttons {
display: flex;
flex-wrap: wrap;
}
.email-form {
display: flex;
form {
width: 100%;
}
.form-buttons {
display: flex;
gap: $default-padding;
margin-top: $default-margin;
}
}
}
</style>

View File

@ -0,0 +1,206 @@
<template>
<div id="user-infos-edition">
<Modal
v-if="displayModal"
:title="$t('common.CONFIRMATION')"
:message="$t('user.CONFIRM_ACCOUNT_DELETION')"
@confirmAction="deleteAccount(user.username)"
@cancelAction="updateDisplayModal(false)"
/>
<div class="profile-form form-box">
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<div class="info-box success-message" v-if="isSuccess">
{{
$t(
`user.PROFILE.SUCCESSFUL_${
emailUpdate && appConfig.is_email_sending_enabled ? 'EMAIL_' : ''
}UPDATE`
)
}}
</div>
<form :class="{ errors: formErrors }" @submit.prevent="updateProfile">
<label class="form-items" for="email">
{{ $t('user.EMAIL') }}*
<input
id="email"
v-model="userForm.email"
:disabled="loading"
:required="true"
@invalid="invalidateForm"
/>
</label>
<label class="form-items" for="password-field">
{{ $t('user.CURRENT_PASSWORD') }}*
<PasswordInput
id="password-field"
:disabled="loading"
:password="userForm.password"
:required="true"
@updatePassword="updatePassword"
@passwordError="invalidateForm"
/>
</label>
<label class="form-items" for="new-password-field">
{{ $t('user.NEW_PASSWORD') }}
<PasswordInput
id="new-password-field"
:disabled="loading"
:checkStrength="true"
:password="userForm.new_password"
:isSuccess="false"
@updatePassword="updateNewPassword"
@passwordError="invalidateForm"
/>
</label>
<div class="form-buttons">
<button class="confirm" type="submit">
{{ $t('buttons.SUBMIT') }}
</button>
<button class="cancel" @click.prevent="$router.push('/profile')">
{{ $t('buttons.CANCEL') }}
</button>
<button class="danger" @click.prevent="updateDisplayModal(true)">
{{ $t('buttons.DELETE_MY_ACCOUNT') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import {
ComputedRef,
Ref,
computed,
reactive,
ref,
toRefs,
onMounted,
watch,
onUnmounted,
} from 'vue'
import PasswordInput from '@/components/Common/PasswordInput.vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { IUserProfile, IUserAccountPayload } from '@/types/user'
import { useStore } from '@/use/useStore'
interface Props {
user: IUserProfile
}
const props = defineProps<Props>()
const { user } = toRefs(props)
const store = useStore()
const userForm: IUserAccountPayload = reactive({
email: '',
password: '',
new_password: '',
})
const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
)
const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]
)
const isSuccess: ComputedRef<boolean> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.IS_SUCCESS]
)
const emailUpdate = ref(false)
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
const formErrors = ref(false)
const displayModal: Ref<boolean> = ref(false)
onMounted(() => {
if (props.user) {
updateUserForm(props.user)
}
})
function invalidateForm() {
formErrors.value = true
}
function updateUserForm(user: IUserProfile) {
userForm.email = user.email
}
function updatePassword(password: string) {
userForm.password = password
}
function updateNewPassword(new_password: string) {
userForm.new_password = new_password
}
function updateProfile() {
const payload: IUserAccountPayload = {
email: userForm.email,
password: userForm.password,
}
if (userForm.new_password) {
payload.new_password = userForm.new_password
}
emailUpdate.value = userForm.email !== user.value.email
store.dispatch(AUTH_USER_STORE.ACTIONS.UPDATE_USER_ACCOUNT, payload)
}
function updateDisplayModal(value: boolean) {
displayModal.value = value
}
function deleteAccount(username: string) {
store.dispatch(AUTH_USER_STORE.ACTIONS.DELETE_ACCOUNT, { username })
}
onUnmounted(() => {
store.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_IS_SUCCESS, false)
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
})
watch(
() => isSuccess.value,
async (isSuccessValue) => {
if (isSuccessValue) {
updatePassword('')
updateNewPassword('')
updateUserForm(user.value)
formErrors.value = false
}
}
)
watch(
() => user.value.email,
async () => {
updateUserForm(user.value)
}
)
</script>
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
.form-items {
.password-input {
::v-deep(.show-password) {
font-weight: normal;
font-size: 0.8em;
margin-top: -4px;
padding-left: 0;
}
::v-deep(.form-info) {
font-weight: normal;
padding-left: $default-padding;
}
::v-deep(.password-strength-details) {
font-weight: normal;
margin-top: 0;
}
}
}
.form-buttons {
flex-direction: row;
@media screen and (max-width: $x-small-limit) {
flex-direction: column;
}
}
</style>

View File

@ -1,42 +1,12 @@
<template>
<div id="user-infos-edition">
<Modal
v-if="displayModal"
:title="$t('common.CONFIRMATION')"
:message="$t('user.CONFIRM_ACCOUNT_DELETION')"
@confirmAction="deleteAccount(user.username)"
@cancelAction="updateDisplayModal(false)"
/>
<div class="profile-form form-box">
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<form @submit.prevent="updateProfile">
<label class="form-items" for="email">
{{ $t('user.EMAIL') }}
<input id="email" :value="user.email" disabled />
</label>
<label class="form-items" for="registrationDate">
{{ $t('user.PROFILE.REGISTRATION_DATE') }}
<input id="registrationDate" :value="registrationDate" disabled />
</label>
<label class="form-items" for="password">
{{ $t('user.PASSWORD') }}
<input
id="password"
type="password"
v-model="userForm.password"
:disabled="loading"
/>
</label>
<label class="form-items" for="passwordConfirmation">
{{ $t('user.PASSWORD_CONFIRMATION') }}
<input
id="passwordConfirmation"
type="password"
v-model="userForm.password_conf"
:disabled="loading"
/>
</label>
<hr />
<label class="form-items" for="first_name">
{{ $t('user.PROFILE.FIRST_NAME') }}
<input
@ -84,9 +54,6 @@
<button class="cancel" @click.prevent="$router.push('/profile')">
{{ $t('buttons.CANCEL') }}
</button>
<button class="danger" @click.prevent="updateDisplayModal(true)">
{{ $t('buttons.DELETE_MY_ACCOUNT') }}
</button>
</div>
</form>
</div>
@ -95,15 +62,7 @@
<script setup lang="ts">
import { format } from 'date-fns'
import {
ComputedRef,
Ref,
computed,
reactive,
ref,
toRefs,
onMounted,
} from 'vue'
import { ComputedRef, computed, reactive, onMounted, onUnmounted } from 'vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { IUserProfile, IUserPayload } from '@/types/user'
@ -116,10 +75,7 @@
const store = useStore()
const { user } = toRefs(props)
const userForm: IUserPayload = reactive({
password: '',
password_conf: '',
first_name: '',
last_name: '',
birth_date: '',
@ -137,7 +93,6 @@
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
let displayModal: Ref<boolean> = ref(false)
onMounted(() => {
if (props.user) {
@ -160,17 +115,26 @@
function updateProfile() {
store.dispatch(AUTH_USER_STORE.ACTIONS.UPDATE_USER_PROFILE, userForm)
}
function updateDisplayModal(value: boolean) {
displayModal.value = value
}
function deleteAccount(username: string) {
store.dispatch(AUTH_USER_STORE.ACTIONS.DELETE_ACCOUNT, { username })
}
onUnmounted(() => {
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
})
</script>
<style lang="scss">
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
.form-items {
.password-input {
::v-deep(.show-password) {
font-weight: normal;
font-size: 0.8em;
margin-top: -4px;
padding-left: 0;
}
}
}
.form-buttons {
flex-direction: row;
@media screen and (max-width: $x-small-limit) {

View File

@ -33,7 +33,7 @@
</template>
<script setup lang="ts">
import { ComputedRef, Ref, computed, ref, toRefs } from 'vue'
import { ComputedRef, Ref, computed, ref, toRefs, onUnmounted } from 'vue'
import UserPicture from '@/components/User/UserPicture.vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
@ -59,7 +59,7 @@
const fileSizeLimit = appConfig.value.max_single_file_size
? getReadableFileSize(appConfig.value.max_single_file_size)
: ''
let pictureFile: Ref<File | null> = ref(null)
const pictureFile: Ref<File | null> = ref(null)
function deleteUserPicture() {
store.dispatch(AUTH_USER_STORE.ACTIONS.DELETE_PICTURE)
@ -76,6 +76,10 @@
})
}
}
onUnmounted(() => {
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
})
</script>
<style lang="scss" scoped>
@ -85,6 +89,7 @@
.user-picture-form {
display: flex;
flex-direction: column;
margin-top: $default-margin;
form {
display: flex;

View File

@ -68,7 +68,7 @@
</template>
<script setup lang="ts">
import { ComputedRef, computed, reactive, onMounted } from 'vue'
import { ComputedRef, computed, reactive, onMounted, onUnmounted } from 'vue'
import TimezoneDropdown from '@/components/User/ProfileEdition/TimezoneDropdown.vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
@ -134,4 +134,8 @@
function updateTZ(value: string) {
userForm.timezone = value
}
onUnmounted(() => {
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
})
</script>

View File

@ -34,7 +34,7 @@
const store = useStore()
const { user, tab } = toRefs(props)
const tabs = ['PROFILE', 'PICTURE', 'PREFERENCES', 'SPORTS']
const tabs = ['PROFILE', 'ACCOUNT', 'PICTURE', 'PREFERENCES', 'SPORTS']
const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
)

View File

@ -1,5 +1,10 @@
<template>
<div id="user-auth-form">
<div
id="user-auth-form"
:class="`${
['reset', 'reset-request'].includes(action) ? action : 'user-form'
}`"
>
<div id="user-form">
<div
class="form-box"
@ -11,6 +16,26 @@
message="user.REGISTER_DISABLED"
v-if="registration_disabled"
/>
<AlertMessage
message="admin.EMAIL_SENDING_DISABLED"
v-if="sendingEmailDisabled"
/>
<div
class="info-box success-message"
v-if="isSuccess || isRegistrationSuccess"
>
{{
$t(
`user.PROFILE.SUCCESSFUL_${
isRegistrationSuccess
? `REGISTRATION${
appConfig.is_email_sending_enabled ? '_WITH_EMAIL' : ''
}`
: 'UPDATE'
}`
)
}}
</div>
<form
:class="{ errors: formErrors }"
@submit.prevent="onSubmit(action)"
@ -23,57 +48,61 @@
required
pattern="[a-zA-Z0-9_]+"
minlength="3"
maxlength="12"
maxlength="30"
@invalid="invalidateForm"
v-model="formData.username"
:placeholder="$t('user.USERNAME')"
/>
<div v-if="action === 'register'" class="form-info">
<i class="fa fa-info-circle" aria-hidden="true" />
{{ $t('user.USERNAME_INFO') }}
</div>
<input
v-if="action !== 'reset'"
id="email"
:disabled="registration_disabled"
:disabled="registration_disabled || sendingEmailDisabled"
required
@invalid="invalidateForm"
type="email"
v-model="formData.email"
:placeholder="
action === 'reset-request'
? $t('user.ENTER_EMAIL')
: $t('user.EMAIL')
"
:placeholder="$t('user.EMAIL')"
/>
<input
v-if="action !== 'reset-request'"
id="password"
<div
v-if="
[
'reset-request',
'register',
'account-confirmation-resend',
].includes(action)
"
class="form-info"
>
<i class="fa fa-info-circle" aria-hidden="true" />
{{ $t('user.EMAIL_INFO') }}
</div>
<PasswordInput
v-if="
!['account-confirmation-resend', 'reset-request'].includes(
action
)
"
:disabled="registration_disabled"
required
@invalid="invalidateForm"
type="password"
minlength="8"
v-model="formData.password"
:required="true"
:placeholder="
action === 'reset'
? $t('user.ENTER_PASSWORD')
: $t('user.PASSWORD')
"
/>
<input
v-if="['register', 'reset'].includes(action)"
id="confirm-password"
:disabled="registration_disabled"
type="password"
minlength="8"
required
@invalid="invalidateForm"
v-model="formData.password_conf"
:placeholder="
action === 'reset'
? $t('user.ENTER_PASSWORD_CONFIRMATION')
: $t('user.PASSWORD_CONFIRM')
"
:password="formData.password"
:checkStrength="['reset', 'register'].includes(action)"
@updatePassword="updatePassword"
@passwordError="invalidateForm"
/>
</div>
<button type="submit" :disabled="registration_disabled">
<button
type="submit"
:disabled="registration_disabled || sendingEmailDisabled"
>
{{ $t(buttonText) }}
</button>
</form>
@ -81,8 +110,12 @@
<router-link class="links" to="/register">
{{ $t('user.REGISTER') }}
</router-link>
-
<router-link class="links" to="/password-reset/request">
<span v-if="appConfig.is_email_sending_enabled">-</span>
<router-link
v-if="appConfig.is_email_sending_enabled"
class="links"
to="/password-reset/request"
>
{{ $t('user.PASSWORD_FORGOTTEN') }}
</router-link>
</div>
@ -92,6 +125,16 @@
{{ $t('user.LOGIN') }}
</router-link>
</div>
<div
v-if="
['login', 'register'].includes(action) &&
appConfig.is_email_sending_enabled
"
>
<router-link class="links" to="/account-confirmation/resend">
{{ $t('user.ACCOUNT_CONFIRMATION_NOT_RECEIVED') }}
</router-link>
</div>
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
</div>
</div>
@ -110,6 +153,7 @@
} from 'vue'
import { useRoute } from 'vue-router'
import PasswordInput from '@/components/Common/PasswordInput.vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { ILoginRegisterFormData } from '@/types/user'
@ -131,7 +175,6 @@
username: '',
email: '',
password: '',
password_conf: '',
})
const buttonText: ComputedRef<string> = computed(() =>
getButtonText(props.action)
@ -139,13 +182,27 @@
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
const isRegistrationSuccess: ComputedRef<boolean> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.IS_REGISTRATION_SUCCESS]
)
const isSuccess: ComputedRef<boolean> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.IS_SUCCESS]
)
const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]
)
const language: ComputedRef<string> = computed(
() => store.getters[ROOT_STORE.GETTERS.LANGUAGE]
)
const registration_disabled: ComputedRef<boolean> = computed(
() =>
props.action === 'register' && !appConfig.value.is_registration_enabled
)
const sendingEmailDisabled: ComputedRef<boolean> = computed(
() =>
['reset-request', 'account-confirmation-resend'].includes(props.action) &&
!appConfig.value.is_email_sending_enabled
)
const formErrors = ref(false)
function getButtonText(action: string): string {
@ -160,6 +217,9 @@
function invalidateForm() {
formErrors.value = true
}
function updatePassword(password: string) {
formData.password = password
}
function onSubmit(actionType: string) {
switch (actionType) {
case 'reset':
@ -171,7 +231,6 @@
}
return store.dispatch(AUTH_USER_STORE.ACTIONS.RESET_USER_PASSWORD, {
password: formData.password,
password_conf: formData.password_conf,
token: props.token,
})
case 'reset-request':
@ -181,7 +240,15 @@
email: formData.email,
}
)
case 'account-confirmation-resend':
return store.dispatch(
AUTH_USER_STORE.ACTIONS.RESEND_ACCOUNT_CONFIRMATION_EMAIL,
{
email: formData.email,
}
)
default:
formData['language'] = language.value
store.dispatch(AUTH_USER_STORE.ACTIONS.LOGIN_OR_REGISTER, {
actionType,
formData,
@ -193,13 +260,17 @@
formData.username = ''
formData.email = ''
formData.password = ''
formData.password_conf = ''
}
watch(
() => route.path,
async () => {
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
store.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_IS_SUCCESS, false)
store.commit(
AUTH_USER_STORE.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS,
false
)
formErrors.value = false
resetFormData()
}
@ -212,10 +283,6 @@
#user-auth-form {
display: flex;
align-items: center;
margin: $default-margin 0;
height: 100%;
#user-form {
width: 60%;
@ -229,7 +296,6 @@
font-style: italic;
padding: 0 $default-padding;
}
button {
margin: $default-margin;
border: solid 1px var(--app-color);
@ -238,16 +304,23 @@
border-color: var(--disabled-color);
}
}
.success-message {
margin: $default-margin;
}
}
@media screen and (max-width: $medium-limit) {
height: auto;
margin-bottom: 50px;
#user-form {
margin-top: $default-margin;
width: 100%;
}
}
}
.user-form {
margin-top: 200px;
@media screen and (max-width: $small-limit) {
margin-top: $default-margin;
}
}
</style>

View File

@ -25,7 +25,7 @@
const authUserPictureUrl = computed(() =>
props.user.picture
? `${getApiUrl()}users/${props.user.username}/picture`
? `${getApiUrl()}users/${props.user.username}/picture?${Date.now()}`
: ''
)
</script>

View File

@ -35,8 +35,9 @@
function getPath(tab: string) {
switch (tab) {
case 'ACCOUNT':
case 'PICTURE':
return '/profile/edit/picture'
return `/profile/edit/${tab.toLocaleLowerCase()}`
case 'PREFERENCES':
case 'SPORTS':
return `/profile${
@ -52,7 +53,10 @@
<style lang="scss">
@import '~@/scss/vars.scss';
.profile-tabs {
margin: $default-margin 0 $default-margin;
.profile-tabs-checkboxes {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: $default-margin * 0.5;
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<div class="users-filters">
<div class="search-username">
<input
id="username"
name="username"
v-model.trim="username"
@keyup.enter="searchUsers"
:placeholder="$t('user.FILTER_ON_USERNAME')"
/>
<i
v-if="username !== ''"
class="fa fa-times"
aria-hidden="true"
@click="resetFilter"
/>
</div>
<i
class="fa fa-search"
:class="{ 'fa-disabled': username === '' }"
aria-hidden="true"
@click="searchUsers"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const username = ref(route.query.q ? route.query.q : '')
const emit = defineEmits(['filterOnUsername'])
function searchUsers() {
if (username.value !== '') {
emit('filterOnUsername', username)
}
}
function resetFilter() {
username.value = ''
emit('filterOnUsername', username.value)
}
</script>
<style lang="scss" scoped>
@import '~@/scss/vars.scss';
.users-filters {
display: flex;
align-items: center;
padding: $default-padding 0;
gap: $default-padding;
.fa {
font-size: 1.5em;
}
.fa-disabled {
color: var(--disabled-color);
}
.search-username {
display: flex;
align-items: center;
justify-content: space-between;
gap: $default-padding;
border: solid 1px var(--card-border-color);
border-radius: $border-radius;
color: var(--info-color);
width: 45%;
input {
border: none;
height: 12px;
width: 90%;
}
input:focus {
outline: none;
}
.fa-times {
padding-right: 10px;
}
}
}
@media screen and (max-width: $small-limit) {
.users-filters {
.search-username {
width: 400px;
}
}
}
@media screen and (max-width: $x-small-limit) {
.users-filters {
.search-username {
width: 90%;
}
}
}
</style>

View File

@ -58,7 +58,7 @@
import { htmlLegendPlugin } from '@/components/Workout/WorkoutDetail/WorkoutChart/legend'
import { TUnit } from '@/types/units'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import {
IWorkoutChartData,
IWorkoutData,
@ -68,7 +68,7 @@
import { getDatasets } from '@/utils/workouts'
interface Props {
authUser: IUserProfile
authUser: IAuthUserProfile
workoutData: IWorkoutData
}
const props = defineProps<Props>()
@ -77,14 +77,14 @@
const { t } = useI18n()
let displayDistance = ref(true)
let beginElevationAtZero = ref(true)
const displayDistance = ref(true)
const beginElevationAtZero = ref(true)
const datasets: ComputedRef<IWorkoutChartData> = computed(() =>
getDatasets(props.workoutData.chartData, t, props.authUser.imperial_units)
)
const fromKmUnit = getUnitTo('km')
const fromMUnit = getUnitTo('m')
let chartData: ComputedRef<ChartData<'line'>> = computed(() => ({
const chartData: ComputedRef<ChartData<'line'>> = computed(() => ({
labels: displayDistance.value
? datasets.value.distance_labels
: datasets.value.duration_labels,

View File

@ -46,7 +46,7 @@
import WorkoutMap from '@/components/Workout/WorkoutDetail/WorkoutMap/index.vue'
import { WORKOUTS_STORE } from '@/store/constants'
import { ISport } from '@/types/sports'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import {
IWorkout,
IWorkoutData,
@ -58,7 +58,7 @@
import { formatWorkoutDate, getDateWithTZ } from '@/utils/dates'
interface Props {
authUser: IUserProfile
authUser: IAuthUserProfile
displaySegment: boolean
sports: ISport[]
workoutData: IWorkoutData
@ -75,7 +75,7 @@
const workout: ComputedRef<IWorkout> = computed(
() => props.workoutData.workout
)
let segmentId: Ref<number | null> = ref(
const segmentId: Ref<number | null> = ref(
route.params.workoutId ? +route.params.segmentId : null
)
const segment: ComputedRef<IWorkoutSegment | null> = computed(() =>
@ -83,7 +83,7 @@
? workout.value.segments[+segmentId.value - 1]
: null
)
let displayModal: Ref<boolean> = ref(false)
const displayModal: Ref<boolean> = ref(false)
const sport = computed(() =>
props.sports
? props.sports.find(

View File

@ -1,7 +1,7 @@
<template>
<div
id="workout-edition"
class="center-card center-card with-margin"
class="center-card with-margin"
:class="{ 'center-form': workout && workout.with_gpx }"
>
<Card>
@ -137,6 +137,8 @@
class="workout-duration"
type="text"
placeholder="HH"
minlength="1"
maxlength="2"
pattern="^([0-1]?[0-9]|2[0-3])$"
required
@invalid="invalidateForm"
@ -150,6 +152,8 @@
class="workout-duration"
type="text"
pattern="^([0-5][0-9])$"
minlength="2"
maxlength="2"
placeholder="MM"
required
@invalid="invalidateForm"
@ -163,6 +167,8 @@
class="workout-duration"
type="text"
pattern="^([0-5][0-9])$"
minlength="2"
maxlength="2"
placeholder="SS"
required
@invalid="invalidateForm"
@ -237,7 +243,7 @@
import { ROOT_STORE, WORKOUTS_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { ISport } from '@/types/sports'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { IWorkout, IWorkoutForm } from '@/types/workouts'
import { useStore } from '@/use/useStore'
import { formatWorkoutDate, getDateWithTZ } from '@/utils/dates'
@ -246,7 +252,7 @@
import { convertDistance } from '@/utils/units'
interface Props {
authUser: IUserProfile
authUser: IAuthUserProfile
sports: ISport[]
isCreation?: boolean
loading?: boolean
@ -295,7 +301,7 @@
workoutDurationSeconds: '',
workoutDistance: '',
})
let withGpx = ref(
const withGpx = ref(
props.workout.id ? props.workout.with_gpx : props.isCreation
)
let gpxFile: File | null = null
@ -415,12 +421,6 @@
@import '~@/scss/vars.scss';
#workout-edition {
@media screen and (max-width: $small-limit) {
&.center-form {
margin: 50px auto;
}
}
::v-deep(.card) {
.card-title {
text-align: center;
@ -510,5 +510,16 @@
}
}
}
@media screen and (max-width: $small-limit) {
margin-bottom: 0;
&.center-form {
margin: 50px auto;
}
&.with-margin {
margin-top: 0;
}
}
}
</style>

View File

@ -165,12 +165,12 @@
import { LocationQuery, useRoute, useRouter } from 'vue-router'
import { ISport } from '@/types/sports'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { translateSports } from '@/utils/sports'
import { units } from '@/utils/units'
interface Props {
authUser: IUserProfile
authUser: IAuthUserProfile
sports: ISport[]
}
const props = defineProps<Props>()

View File

@ -25,7 +25,7 @@
:query="query"
/>
<table>
<thead>
<thead :class="{ smaller: 'de' === currentLanguage }">
<tr>
<th class="sport-col" />
<th>{{ capitalize($t('workouts.WORKOUT', 1)) }}</th>
@ -71,7 +71,7 @@
class="fa fa-map-o"
aria-hidden="true"
/>
{{ workout.title }}
<span class="title">{{ workout.title }}</span>
</router-link>
<StaticMap
v-if="workout.with_gpx && hoverWorkoutId === workout.id"
@ -79,7 +79,7 @@
:display-hover="true"
/>
</td>
<td>
<td class="workout-date">
<span class="cell-heading">
{{ $t('workouts.DATE') }}
</span>
@ -179,10 +179,10 @@
import Pagination from '@/components/Common/Pagination.vue'
import StaticMap from '@/components/Common/StaticMap.vue'
import NoWorkouts from '@/components/Workouts/NoWorkouts.vue'
import { WORKOUTS_STORE } from '@/store/constants'
import { ROOT_STORE, WORKOUTS_STORE } from '@/store/constants'
import { IPagination } from '@/types/api'
import { ITranslatedSport } from '@/types/sports'
import { IUserProfile } from '@/types/user'
import { IAuthUserProfile } from '@/types/user'
import { IWorkout, TWorkoutsPayload } from '@/types/workouts'
import { useStore } from '@/use/useStore'
import { getQuery, sortList, workoutsPayloadKeys } from '@/utils/api'
@ -192,7 +192,7 @@
import { defaultOrder } from '@/utils/workouts'
interface Props {
user: IUserProfile
user: IAuthUserProfile
sports: ITranslatedSport[]
}
const props = defineProps<Props>()
@ -214,6 +214,9 @@
const pagination: ComputedRef<IPagination> = computed(
() => store.getters[WORKOUTS_STORE.GETTERS.WORKOUTS_PAGINATION]
)
const currentLanguage: ComputedRef<string> = computed(
() => store.getters[ROOT_STORE.GETTERS.LANGUAGE]
)
let query: TWorkoutsPayload = getWorkoutsQuery(route.query)
const hoverWorkoutId: Ref<string | null> = ref(null)
@ -238,19 +241,24 @@
}
function getWorkoutsQuery(newQuery: LocationQuery): TWorkoutsPayload {
query = getQuery(newQuery, orderByList, defaultOrder.order_by, {
defaultSort: defaultOrder.order,
})
const workoutQuery = getQuery(
newQuery,
orderByList,
defaultOrder.order_by,
{
defaultSort: defaultOrder.order,
}
)
Object.keys(newQuery)
.filter((k) => workoutsPayloadKeys.includes(k))
.map((k) => {
if (typeof newQuery[k] === 'string') {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
query[k] = newQuery[k]
workoutQuery[k] = newQuery[k]
}
})
return query
return workoutQuery
}
function getConvertedPayload(payload: TWorkoutsPayload): TWorkoutsPayload {
@ -258,7 +266,7 @@
...payload,
}
Object.entries(convertedPayload).map((entry) => {
if (entry[0].match('speed|distance')) {
if (entry[0].match('speed|distance') && entry[1]) {
convertedPayload[entry[0]] = convertDistance(+entry[1], 'mi', 'km')
}
})
@ -287,7 +295,7 @@
width: 100%;
.box {
padding: $default-padding $default-padding * 2;
padding: $default-padding $default-padding * 1.5;
@media screen and (max-width: $small-limit) {
&.empty-table {
display: none;
@ -318,14 +326,33 @@
}
.workouts-table {
.smaller {
th {
font-size: 0.95em;
padding: $default-padding 0;
max-width: 100px;
}
}
td {
text-align: right;
}
.sport-col {
padding-right: 0;
padding: 0;
}
.workout-title {
max-width: 90px;
text-align: left;
width: 100px;
position: relative;
.fa-map-o {
font-size: 0.75em;
padding-right: $default-padding * 0.5;
}
.nav-item {
white-space: nowrap;
.title {
word-break: break-word;
white-space: normal;
}
}
.static-map {
display: none;
@ -339,14 +366,27 @@
height: 20px;
width: 20px;
}
.workout-date {
max-width: 60px;
text-align: left;
}
@media screen and (max-width: $small-limit) {
td,
.workout-date,
.workout-title {
text-align: center;
}
.sport-col {
display: flex;
justify-content: center;
padding: $default-padding;
}
.workout-date {
max-width: initial;
}
.workout-title {
max-width: initial;
width: 100%;
}
.workout-title:hover .static-map {
display: none;