Added env files, completed user settings page.

This commit is contained in:
Andrew Lalis 2023-02-16 12:05:18 +01:00
parent 88089f2e11
commit 0663296052
13 changed files with 235 additions and 21 deletions

View File

@ -0,0 +1,3 @@
API_URL=http://localhost:8080
CDN_URL=http://localhost:8082
SEARCH_URL=http://localhost:8081

View File

@ -0,0 +1,3 @@
API_URL=https://api.gymboard.com
CDN_URL=https://cdn.gymboard.com
SEARCH_URL=https://search.gymboard.com

View File

@ -11,6 +11,7 @@
"@quasar/cli": "^2.0.0", "@quasar/cli": "^2.0.0",
"@quasar/extras": "^1.0.0", "@quasar/extras": "^1.0.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"dotenv": "^16.0.3",
"luxon": "^3.2.1", "luxon": "^3.2.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"pinia": "^2.0.11", "pinia": "^2.0.11",
@ -2166,6 +2167,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/dotenv": {
"version": "16.0.3",
"resolved": "http://192.168.88.248:8081/repository/npm-public/dotenv/-/dotenv-16.0.3.tgz",
"integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -7937,6 +7947,11 @@
"is-obj": "^2.0.0" "is-obj": "^2.0.0"
} }
}, },
"dotenv": {
"version": "16.0.3",
"resolved": "http://192.168.88.248:8081/repository/npm-public/dotenv/-/dotenv-16.0.3.tgz",
"integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="
},
"eastasianwidth": { "eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",

View File

@ -14,6 +14,7 @@
"@quasar/cli": "^2.0.0", "@quasar/cli": "^2.0.0",
"@quasar/extras": "^1.0.0", "@quasar/extras": "^1.0.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"dotenv": "^16.0.3",
"luxon": "^3.2.1", "luxon": "^3.2.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"pinia": "^2.0.11", "pinia": "^2.0.11",

View File

@ -12,6 +12,8 @@ const { configure } = require('quasar/wrappers');
const path = require('path'); const path = require('path');
const { withCtx } = require('vue'); const { withCtx } = require('vue');
require('dotenv').config();
module.exports = configure(function (ctx) { module.exports = configure(function (ctx) {
return { return {
eslint: { eslint: {
@ -65,7 +67,9 @@ module.exports = configure(function (ctx) {
// publicPath: '/', // publicPath: '/',
// analyze: true, // analyze: true,
env: { env: {
API: ctx.dev ? 'http://localhost:8080' : 'https://api.gymboard.com', API_URL: process.env.API_URL,
CDN_URL: process.env.CDN_URL,
SEARCH_URL: process.env.SEARCH_URL,
}, },
// rawDefine: {} // rawDefine: {}
// ignorePublicFolder: true, // ignorePublicFolder: true,

View File

@ -1,6 +1,7 @@
import {api} from 'src/api/main/index'; import {api} from 'src/api/main/index';
import {AuthStoreType} from 'stores/auth-store'; import {AuthStoreType} from 'stores/auth-store';
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
import {WeightUnit} from 'src/api/main/submission';
export interface User { export interface User {
id: string; id: string;
@ -21,7 +22,7 @@ export interface UserPersonalDetails {
userId: string; userId: string;
birthDate?: string; birthDate?: string;
currentWeight?: number; currentWeight?: number;
currentWeightUnit?: number; currentWeightUnit?: WeightUnit;
currentMetricWeight?: number; currentMetricWeight?: number;
sex: PersonSex; sex: PersonSex;
} }

View File

@ -3,12 +3,9 @@ import GymsModule from 'src/api/main/gyms';
import ExercisesModule from 'src/api/main/exercises'; import ExercisesModule from 'src/api/main/exercises';
import LeaderboardsModule from 'src/api/main/leaderboards'; import LeaderboardsModule from 'src/api/main/leaderboards';
import AuthModule from 'src/api/main/auth'; import AuthModule from 'src/api/main/auth';
console.log(process.env);
export const BASE_URL = 'http://localhost:8080';
// TODO: Figure out how to get the base URL from environment.
export const api = axios.create({ export const api = axios.create({
baseURL: BASE_URL, baseURL: process.env.API_URL,
}); });
class GymboardApi { class GymboardApi {

View File

@ -37,7 +37,7 @@ export default boot(({ app }) => {
} }
// Temporary override if you want to test a particular locale. // Temporary override if you want to test a particular locale.
i18n.global.locale.value = 'nl-NL'; // i18n.global.locale.value = 'nl-NL';
// Set i18n instance on app // Set i18n instance on app
app.use(i18n); app.use(i18n);

View File

@ -2,11 +2,12 @@
<div class="row justify-between"> <div class="row justify-between">
<span class="property-label">{{ label }}</span> <span class="property-label">{{ label }}</span>
<div v-if="typeof modelValue === 'string'"> <div v-if="(typeof modelValue === 'string' || typeof modelValue === 'number') && inputType !== 'select'">
<q-input <q-input
:model-value="modelValue" :model-value="modelValue"
@update:modelValue="onValueUpdated" @update:modelValue="onValueUpdated"
:type="inputType" :type="inputType"
:step="numberInputStep"
dense dense
/> />
</div> </div>
@ -14,6 +15,16 @@
<div v-if="typeof modelValue === 'boolean'"> <div v-if="typeof modelValue === 'boolean'">
<q-toggle :model-value="modelValue" @update:modelValue="onValueUpdated"/> <q-toggle :model-value="modelValue" @update:modelValue="onValueUpdated"/>
</div> </div>
<div v-if="inputType === 'select'">
<q-select
:model-value="modelValue"
:options="selectOptions"
emit-value
map-options
@update:modelValue="onValueUpdated"
/>
</div>
</div> </div>
</template> </template>
@ -24,6 +35,8 @@ interface Props {
label: string; label: string;
inputType?: any; inputType?: any;
modelValue: string | number | boolean | DateTime; modelValue: string | number | boolean | DateTime;
selectOptions?: Array<object>;
numberInputStep?: number;
} }
defineProps<Props>(); defineProps<Props>();
const emits = defineEmits(['update:modelValue']); const emits = defineEmits(['update:modelValue']);

View File

@ -48,11 +48,25 @@ export default {
}, },
userSettingsPage: { userSettingsPage: {
title: 'Account Settings', title: 'Account Settings',
password: 'Password',
passwordHint: 'Set a new password for your account.',
updatePassword: 'Update Password',
passwordUpdated: 'Password updated.',
passwordInvalid: 'Invalid password.',
personalDetails: { personalDetails: {
birthDate: 'Date of Birth' title: 'Personal Details',
birthDate: 'Date of Birth',
sex: 'Sex',
sexMale: 'Male',
sexFemale: 'Female',
sexUnknown: 'Prefer not to say',
currentWeight: 'Current Weight',
currentWeightUnit: 'Current Weight Unit'
}, },
preferences: { preferences: {
accountPrivate: 'Private Account' title: 'Preferences',
accountPrivate: 'Private',
language: 'Language'
}, },
save: 'Save', save: 'Save',
undo: 'Undo' undo: 'Undo'
@ -65,5 +79,9 @@ export default {
}, },
generalErrors: { generalErrors: {
apiError: 'An API error occurred. Please try again later.' apiError: 'An API error occurred. Please try again later.'
},
weightUnit: {
kilograms: 'Kilograms',
pounds: 'Pounds'
} }
}; };

View File

@ -17,6 +17,7 @@ export default {
password: 'Wachtwoord', password: 'Wachtwoord',
logIn: 'Inloggen', logIn: 'Inloggen',
createAccount: 'Account aanmaken', createAccount: 'Account aanmaken',
authFailed: 'Ongeldige inloggegevens.',
}, },
indexPage: { indexPage: {
searchHint: 'Zoek een sportschool', searchHint: 'Zoek een sportschool',
@ -39,8 +40,48 @@ export default {
submit: 'Sturen', submit: 'Sturen',
}, },
}, },
userPage: {
notFound: {
title: 'Gebruiker niet gevonden',
description: 'Wij konden de gebruiker voor wie jij zoekt niet vinden, helaas.'
}
},
userSettingsPage: {
title: 'Account instellingen',
password: 'Wachtwoord',
passwordHint: 'Stel een nieuw wachtwoord voor je account in.',
updatePassword: 'Wachtwoord bijwerken',
passwordUpdated: 'Wachtwoord succesvol bijgewerkt.',
passwordInvalid: 'Ongeldig wachtwoord.',
personalDetails: {
title: 'Persoonlijke gegevens',
birthDate: 'Geboortedatum',
sex: 'Geslacht',
sexMale: 'Mannelijk',
sexFemale: 'Vrouwelijk',
sexUnknown: 'Liever niet zeggen',
currentWeight: 'Huidige gewicht',
currentWeightUnit: 'Eenheden van huidige gewicht'
},
preferences: {
title: 'Voorkeuren',
accountPrivate: 'Privaat',
language: 'Taal'
},
save: 'Opslaan',
undo: 'Terugzetten'
},
accountMenuItem: { accountMenuItem: {
logIn: 'Inloggen', logIn: 'Inloggen',
profile: 'Profile',
settings: 'Instellingen',
logOut: 'Uitloggen', logOut: 'Uitloggen',
}, },
generalErrors: {
apiError: 'Er is een API fout opgetreden. Probeer het nogmals later.'
},
weightUnit: {
kilograms: 'Kilogram',
pounds: 'Ponden'
}
}; };

View File

@ -40,7 +40,7 @@
<router-link <router-link
:to="{ :to="{
path: '/register', path: '/register',
query: route.query.next ? { next: route.query.next } : {}, query: route.query.next ? { next: route.query.next } : {}
}" }"
class="q-mt-md text-primary text-center col-12" class="q-mt-md text-primary text-center col-12"
> >
@ -78,7 +78,7 @@ const passwordVisible = ref(false);
/** /**
* The main login function. It attempts to log in the user, and gracefully * The main login function. It attempts to log in the user, and gracefully
* handles failures. * handles failures.
* *
* Upon successful login, we set the app's locale to the user's preferred * Upon successful login, we set the app's locale to the user's preferred
* locale, and then redirect to the pre-configured next URL (if there is one). * locale, and then redirect to the pre-configured next URL (if there is one).
*/ */
@ -87,8 +87,8 @@ async function tryLogin() {
await api.auth.login(authStore, loginModel.value); await api.auth.login(authStore, loginModel.value);
// Set the locale to the user's preferred locale. // Set the locale to the user's preferred locale.
i18n.locale.value = resolveLocale(authStore.user?.preferences?.locale); i18n.locale.value = resolveLocale(authStore.user?.preferences?.locale).value;
// Redirect back to whatever was set as the next URL. // Redirect back to whatever was set as the next URL.
const dest = route.query.next const dest = route.query.next
? decodeURIComponent(route.query.next as string) ? decodeURIComponent(route.query.next as string)

View File

@ -1,17 +1,58 @@
<!--
The page where users can edit their personal information and preferences.
-->
<template> <template>
<q-page> <q-page>
<StandardCenteredPage> <StandardCenteredPage>
<h3>{{ $t('userSettingsPage.title') }}</h3> <h3>{{ $t('userSettingsPage.title') }}</h3>
<hr> <hr>
<div class="row justify-between">
<span class="property-label">{{ $t('userSettingsPage.password') }}</span>
<q-input
type="password"
v-model="newPassword"
:hint="$t('userSettingsPage.passwordHint')"
/>
</div>
<div v-if="canUpdatePassword">
<q-btn :label="$t('userSettingsPage.updatePassword')" color="positive" @click="updatePassword"/>
</div>
<div v-if="personalDetails"> <div v-if="personalDetails">
<h4>Personal Information</h4> <h4>{{ $t('userSettingsPage.personalDetails.title') }}</h4>
<EditablePropertyRow <EditablePropertyRow
v-model="personalDetails.birthDate" v-model="personalDetails.birthDate"
:label="$t('userSettingsPage.personalDetails.birthDate')" :label="$t('userSettingsPage.personalDetails.birthDate')"
input-type="date" input-type="date"
/> />
<EditablePropertyRow
v-model="personalDetails.sex"
:label="$t('userSettingsPage.personalDetails.sex')"
input-type="select"
:select-options="[
{ label: $t('userSettingsPage.personalDetails.sexMale'), value: 'MALE' },
{ label: $t('userSettingsPage.personalDetails.sexFemale'), value: 'FEMALE' },
{ label: $t('userSettingsPage.personalDetails.sexUnknown'), value: 'UNKNOWN' },
]"
/>
<EditablePropertyRow
v-model="personalDetails.currentWeight"
:label="$t('userSettingsPage.personalDetails.currentWeight')"
input-type="number"
:number-input-step="0.1"
/>
<EditablePropertyRow
v-model="personalDetails.currentWeightUnit"
:label="$t('userSettingsPage.personalDetails.currentWeightUnit')"
input-type="select"
:select-options="[
{ label: $t('weightUnit.kilograms'), value: WeightUnit.KILOGRAMS },
{ label: $t('weightUnit.pounds'), value: WeightUnit.POUNDS },
]"
/>
<div v-if="personalDetailsChanged"> <div v-if="personalDetailsChanged" class="save-button-row">
<q-btn :label="$t('userSettingsPage.save')" color="positive" @click="savePersonalDetails"/> <q-btn :label="$t('userSettingsPage.save')" color="positive" @click="savePersonalDetails"/>
<q-btn :label="$t('userSettingsPage.undo')" color="secondary" @click="undoPersonalDetailsChanges"/> <q-btn :label="$t('userSettingsPage.undo')" color="secondary" @click="undoPersonalDetailsChanges"/>
</div> </div>
@ -19,14 +60,21 @@
</div> </div>
<div v-if="preferences"> <div v-if="preferences">
<h4>Preferences</h4> <h4>{{ $t('userSettingsPage.preferences.title') }}</h4>
<EditablePropertyRow <EditablePropertyRow
v-model="preferences.accountPrivate" v-model="preferences.accountPrivate"
:label="$t('userSettingsPage.preferences.accountPrivate')" :label="$t('userSettingsPage.preferences.accountPrivate')"
/> />
<EditablePropertyRow
v-model="preferences.locale"
:label="$t('userSettingsPage.preferences.language')"
input-type="select"
:select-options="supportedLocales"
@update:modelValue="updateLocale"
/>
<div v-if="preferencesChanged"> <div v-if="preferencesChanged" class="save-button-row">
<q-btn :label="$t('userSettingsPage.save')" color="positive" @click="savePreferences"/> <q-btn :label="$t('userSettingsPage.save')" color="positive" @click="savePreferences"/>
<q-btn :label="$t('userSettingsPage.undo')" color="secondary" @click="undoPreferencesChanges"/> <q-btn :label="$t('userSettingsPage.undo')" color="secondary" @click="undoPreferencesChanges"/>
</div> </div>
@ -44,10 +92,16 @@ import {computed, onMounted, ref, Ref, toRaw} from 'vue';
import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth'; import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth';
import api from 'src/api/main'; import api from 'src/api/main';
import EditablePropertyRow from 'components/EditablePropertyRow.vue'; import EditablePropertyRow from 'components/EditablePropertyRow.vue';
import {WeightUnit} from 'src/api/main/submission';
import {resolveLocale, supportedLocales} from 'src/i18n';
import {useI18n} from 'vue-i18n';
import {useQuasar} from 'quasar';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const quasar = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const i18n = useI18n({useScope: 'global'});
const personalDetails: Ref<UserPersonalDetails | undefined> = ref(); const personalDetails: Ref<UserPersonalDetails | undefined> = ref();
const preferences: Ref<UserPreferences | undefined> = ref(); const preferences: Ref<UserPreferences | undefined> = ref();
@ -55,6 +109,8 @@ const preferences: Ref<UserPreferences | undefined> = ref();
let initialPersonalDetails: UserPersonalDetails | null = null; let initialPersonalDetails: UserPersonalDetails | null = null;
let initialPreferences: UserPreferences | null = null; let initialPreferences: UserPreferences | null = null;
const newPassword = ref('');
onMounted(async () => { onMounted(async () => {
// Redirect away from the page if the user isn't viewing their own settings. // Redirect away from the page if the user isn't viewing their own settings.
const userId = route.params.userId as string; const userId = route.params.userId as string;
@ -67,6 +123,8 @@ onMounted(async () => {
preferences.value = await api.auth.getMyPreferences(authStore); preferences.value = await api.auth.getMyPreferences(authStore);
initialPreferences = structuredClone(toRaw(preferences.value)); initialPreferences = structuredClone(toRaw(preferences.value));
newPassword.value = '';
}); });
const personalDetailsChanged = computed(() => { const personalDetailsChanged = computed(() => {
@ -79,10 +137,23 @@ const preferencesChanged = computed(() => {
JSON.stringify(initialPreferences) !== JSON.stringify(preferences.value); JSON.stringify(initialPreferences) !== JSON.stringify(preferences.value);
}); });
const canUpdatePassword = computed(() => {
const p = newPassword.value;
return p.length >= 8;
});
async function savePersonalDetails() { async function savePersonalDetails() {
if (personalDetails.value) { if (personalDetails.value) {
personalDetails.value = await api.auth.updateMyPersonalDetails(authStore, personalDetails.value); try {
initialPersonalDetails = structuredClone(toRaw(personalDetails.value)); personalDetails.value = await api.auth.updateMyPersonalDetails(authStore, personalDetails.value);
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
} catch (error: any) {
if (error.response && error.response.status === 400) {
console.warn('bad request');
} else {
console.error(error);
}
}
} }
} }
@ -94,14 +165,61 @@ async function savePreferences() {
if (preferences.value) { if (preferences.value) {
preferences.value = await api.auth.updateMyPreferences(authStore, preferences.value); preferences.value = await api.auth.updateMyPreferences(authStore, preferences.value);
initialPreferences = structuredClone(toRaw(preferences.value)); initialPreferences = structuredClone(toRaw(preferences.value));
updateLocale();
} }
} }
function updateLocale() {
const chosenLocale = resolveLocale(preferences.value?.locale);
i18n.locale.value = chosenLocale.value;
}
function undoPreferencesChanges() { function undoPreferencesChanges() {
preferences.value = structuredClone(initialPreferences); preferences.value = structuredClone(initialPreferences);
updateLocale();
}
async function updatePassword() {
try {
await api.auth.updatePassword(newPassword.value, authStore);
newPassword.value = '';
quasar.notify({
message: i18n.t('userSettingsPage.passwordUpdated'),
type: 'positive',
position: 'top'
});
} catch (error: any) {
if (error.response && error.response.status === 400) {
newPassword.value = '';
quasar.notify({
message: i18n.t('userSettingsPage.passwordInvalid'),
type: 'warning',
position: 'top'
});
} else {
quasar.notify({
message: i18n.t('generalErrors.apiError'),
type: 'danger',
position: 'top'
});
}
}
} }
</script> </script>
<style scoped> <style scoped>
.property-label {
font-weight: bold;
margin-top: auto;
margin-bottom: auto;
}
.save-button-row {
display: flex;
flex-direction: row;
column-gap: 10px;
justify-content: flex-end;
margin-top: 10px;
margin-bottom: 10px;
}
</style> </style>