Updated front-end to follow updated APIs.

This commit is contained in:
Andrew Lalis 2023-02-03 22:12:26 +01:00
parent 67504d0883
commit 1134eef3ad
23 changed files with 134 additions and 186 deletions

View File

@ -44,9 +44,9 @@ public class GymService {
query.orderBy(criteriaBuilder.desc(root.get("createdAt"))); query.orderBy(criteriaBuilder.desc(root.get("createdAt")));
query.distinct(true); query.distinct(true);
// TODO: Filter to only verified submissions.
return PredicateBuilder.and(criteriaBuilder) return PredicateBuilder.and(criteriaBuilder)
.with(criteriaBuilder.equal(root.get("gym"), gym)) .with(criteriaBuilder.equal(root.get("gym"), gym))
.with(criteriaBuilder.isTrue(root.get("complete")))
.build(); .build();
}, PageRequest.of(0, 10)) }, PageRequest.of(0, 10))
.map(ExerciseSubmissionResponse::new) .map(ExerciseSubmissionResponse::new)

View File

@ -111,9 +111,7 @@ module.exports = configure(function (ctx) {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: [ plugins: ['Notify'],
'Notify'
],
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations

View File

@ -41,30 +41,49 @@ class AuthModule {
clearTimeout(this.tokenRefreshTimer); clearTimeout(this.tokenRefreshTimer);
} }
public async register(payload: UserCreationPayload) { public async register(payload: UserCreationPayload): Promise<User> {
const response = await api.post('/auth/register', payload); const response = await api.post('/auth/register', payload);
console.log(response);
}
public async activateUser(code: string): Promise<User> {
const response = await api.post('/auth/activate', {code: code});
return response.data; return response.data;
} }
private async fetchNewToken(credentials: TokenCredentials): Promise<string> { public async activateUser(code: string): Promise<User> {
const response = await api.post('/auth/activate', { code: code });
return response.data;
}
public async fetchNewToken(credentials: TokenCredentials): Promise<string> {
const response = await api.post('/auth/token', credentials); const response = await api.post('/auth/token', credentials);
return response.data.token; return response.data.token;
} }
private async refreshToken(authStore: AuthStoreType) { public async refreshToken(authStore: AuthStoreType) {
const response = await api.get('/auth/token', authStore.axiosConfig); const response = await api.get('/auth/token', authStore.axiosConfig);
authStore.token = response.data.token; authStore.token = response.data.token;
} }
private async fetchMyUser(authStore: AuthStoreType): Promise<User> { public async fetchMyUser(authStore: AuthStoreType): Promise<User> {
const response = await api.get('/auth/me', authStore.axiosConfig); const response = await api.get('/auth/me', authStore.axiosConfig);
return response.data; return response.data;
} }
public async updatePassword(newPassword: string, authStore: AuthStoreType) {
await api.post(
'/auth/me/password',
{ newPassword: newPassword },
authStore.axiosConfig
);
}
public async generatePasswordResetCode(email: string) {
await api.get('/auth/reset-password', { params: { email: email } });
}
public async resetPassword(resetCode: string, newPassword: string) {
await api.post('/auth/reset-password', {
code: resetCode,
newPassword: newPassword,
});
}
} }
export default AuthModule; export default AuthModule;

View File

@ -13,7 +13,7 @@ export interface ExerciseSubmissionPayload {
weight: number; weight: number;
weightUnit: string; weightUnit: string;
reps: number; reps: number;
videoId: number; videoFileId: string;
} }
export interface ExerciseSubmission { export interface ExerciseSubmission {
@ -21,7 +21,7 @@ export interface ExerciseSubmission {
createdAt: string; createdAt: string;
gym: SimpleGym; gym: SimpleGym;
exercise: Exercise; exercise: Exercise;
status: ExerciseSubmissionStatus; videoFileId: string;
submitterName: string; submitterName: string;
rawWeight: number; rawWeight: number;
weightUnit: string; weightUnit: string;
@ -29,14 +29,6 @@ export interface ExerciseSubmission {
reps: number; reps: number;
} }
export enum ExerciseSubmissionStatus {
WAITING = 'WAITING',
PROCESSING = 'PROCESSING',
FAILED = 'FAILED',
COMPLETED = 'COMPLETED',
VERIFIED = 'VERIFIED',
}
class SubmissionsModule { class SubmissionsModule {
public async getSubmission( public async getSubmission(
submissionId: string submissionId: string
@ -45,16 +37,6 @@ class SubmissionsModule {
return response.data; return response.data;
} }
public getSubmissionVideoUrl(submission: ExerciseSubmission): string | null {
if (
submission.status !== ExerciseSubmissionStatus.COMPLETED &&
submission.status !== ExerciseSubmissionStatus.VERIFIED
) {
return null;
}
return BASE_URL + `/submissions/${submission.id}/video`;
}
public async createSubmission( public async createSubmission(
gym: GymRoutable, gym: GymRoutable,
payload: ExerciseSubmissionPayload payload: ExerciseSubmissionPayload
@ -63,49 +45,6 @@ class SubmissionsModule {
const response = await api.post(`/gyms/${gymId}/submissions`, payload); const response = await api.post(`/gyms/${gymId}/submissions`, payload);
return response.data; return response.data;
} }
public async uploadVideoFile(gym: GymRoutable, file: File): Promise<number> {
const formData = new FormData();
formData.append('file', file);
const gymId = getGymCompoundId(gym);
const response = await api.post(
`/gyms/${gymId}/submissions/upload`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
}
);
return response.data.id as number;
}
/**
* Asynchronous method that waits until a submission is done processing.
* @param submissionId The submission's id.
*/
public async waitUntilSubmissionProcessed(
submissionId: string
): Promise<ExerciseSubmission> {
let failureCount = 0;
let attemptCount = 0;
while (failureCount < 5 && attemptCount < 60) {
await sleep(1000);
attemptCount++;
try {
const response = await this.getSubmission(submissionId);
failureCount = 0;
if (
response.status !== ExerciseSubmissionStatus.WAITING &&
response.status !== ExerciseSubmissionStatus.PROCESSING
) {
return response;
}
} catch (error) {
console.log(error);
failureCount++;
}
}
throw new Error('Failed to wait for submission to complete.');
}
} }
export default SubmissionsModule; export default SubmissionsModule;

View File

@ -1,5 +1,5 @@
import {boot} from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import {createI18n} from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import messages from 'src/i18n'; import messages from 'src/i18n';

View File

@ -29,7 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from 'stores/auth-store'; import { useAuthStore } from 'stores/auth-store';
import api from 'src/api/main'; import api from 'src/api/main';
import {useRoute, useRouter} from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
const authStore = useAuthStore(); const authStore = useAuthStore();
const route = useRoute(); const route = useRoute();
@ -39,8 +39,8 @@ async function goToLoginPage() {
await router.push({ await router.push({
path: '/login', path: '/login',
query: { query: {
next: encodeURIComponent(route.path) next: encodeURIComponent(route.path),
} },
}); });
} }
</script> </script>

View File

@ -15,7 +15,7 @@
<q-card> <q-card>
<q-card-section class="text-center"> <q-card-section class="text-center">
<video <video
:src="api.gyms.submissions.getSubmissionVideoUrl(submission)" :src="getFileUrl(submission.videoFileId)"
width="600" width="600"
loop loop
controls controls
@ -30,6 +30,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ExerciseSubmission } from 'src/api/main/submission'; import { ExerciseSubmission } from 'src/api/main/submission';
import api from 'src/api/main'; import api from 'src/api/main';
import { getFileUrl } from 'src/api/cdn';
interface Props { interface Props {
submission: ExerciseSubmission; submission: ExerciseSubmission;

View File

@ -25,7 +25,7 @@ const i18n = useI18n({ useScope: 'global' });
const localeOptions = [ const localeOptions = [
{ value: 'en-US', label: 'English' }, { value: 'en-US', label: 'English' },
{ value: 'nl-NL', label: 'Nederlands' }, { value: 'nl-NL', label: 'Nederlands' },
{ value: 'de', label: 'Deutsch' } { value: 'de', label: 'Deutsch' },
]; ];
</script> </script>

View File

@ -12,7 +12,7 @@ export default {
leaderboard: 'Bestenliste', leaderboard: 'Bestenliste',
homePage: { homePage: {
overview: 'Überblick über dieses Fitnessstudio:', overview: 'Überblick über dieses Fitnessstudio:',
recentLifts: 'Letzten Aufzüge' recentLifts: 'Letzten Aufzüge',
}, },
submitPage: { submitPage: {
name: 'Dein Name', name: 'Dein Name',
@ -24,5 +24,4 @@ export default {
submit: 'Einreichen', submit: 'Einreichen',
}, },
}, },
}; };

View File

@ -9,14 +9,14 @@ export default {
email: 'Email', email: 'Email',
password: 'Password', password: 'Password',
register: 'Register', register: 'Register',
error: 'An error occurred.' error: 'An error occurred.',
}, },
loginPage: { loginPage: {
title: 'Login to Gymboard', title: 'Login to Gymboard',
email: 'Email', email: 'Email',
password: 'Password', password: 'Password',
logIn: 'Log in', logIn: 'Log in',
createAccount: 'Create an account' createAccount: 'Create an account',
}, },
indexPage: { indexPage: {
searchHint: 'Search for a Gym', searchHint: 'Search for a Gym',
@ -41,6 +41,6 @@ export default {
}, },
accountMenuItem: { accountMenuItem: {
logIn: 'Login', logIn: 'Login',
logOut: 'Log out' logOut: 'Log out',
} },
}; };

View File

@ -5,5 +5,5 @@ import de from './de';
export default { export default {
'en-US': enUS, 'en-US': enUS,
'nl-NL': nlNL, 'nl-NL': nlNL,
'de': de, de: de,
}; };

View File

@ -9,14 +9,14 @@ export default {
email: 'E-mail', email: 'E-mail',
password: 'Wachtwoord', password: 'Wachtwoord',
register: 'Registreren', register: 'Registreren',
error: 'Er is een fout opgetreden.' error: 'Er is een fout opgetreden.',
}, },
loginPage: { loginPage: {
title: 'Inloggen bij Gymboard', title: 'Inloggen bij Gymboard',
email: 'E-mail', email: 'E-mail',
password: 'Wachtwoord', password: 'Wachtwoord',
logIn: 'Inloggen', logIn: 'Inloggen',
createAccount: 'Account aanmaken' createAccount: 'Account aanmaken',
}, },
indexPage: { indexPage: {
searchHint: 'Zoek een sportschool', searchHint: 'Zoek een sportschool',
@ -41,6 +41,6 @@ export default {
}, },
accountMenuItem: { accountMenuItem: {
logIn: 'Inloggen', logIn: 'Inloggen',
logOut: 'Uitloggen' logOut: 'Uitloggen',
} },
}; };

View File

@ -2,7 +2,10 @@
<StandardCenteredPage> <StandardCenteredPage>
<h3 class="text-center">About Gymboard</h3> <h3 class="text-center">About Gymboard</h3>
<p> <p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Error fugit quia laboriosam eaque? Deserunt, accusantium dicta assumenda debitis incidunt eius provident magnam, est quasi officia voluptas, nam neque omnis reiciendis. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Error fugit quia
laboriosam eaque? Deserunt, accusantium dicta assumenda debitis incidunt
eius provident magnam, est quasi officia voluptas, nam neque omnis
reiciendis.
</p> </p>
</StandardCenteredPage> </StandardCenteredPage>
</template> </template>

View File

@ -1,19 +1,11 @@
<template> <template>
<div <div class="fullscreen text-center q-pa-md flex flex-center">
class="fullscreen text-center q-pa-md flex flex-center"
>
<div> <div>
<div style="font-size: 30vh">404</div> <div style="font-size: 30vh">404</div>
<div class="text-h2" style="opacity: 0.4">Page not found.</div> <div class="text-h2" style="opacity: 0.4">Page not found.</div>
<q-btn <q-btn class="q-mt-xl" unelevated to="/" label="Go Home" no-caps />
class="q-mt-xl"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -3,8 +3,8 @@
<StandardCenteredPage> <StandardCenteredPage>
<h3>Testing Page</h3> <h3>Testing Page</h3>
<p> <p>
Use this page to test new functionality, before adding it to the main app. Use this page to test new functionality, before adding it to the main
This page should be hidden on production. app. This page should be hidden on production.
</p> </p>
<div style="border: 3px solid red"> <div style="border: 3px solid red">
<h4>Auth Test</h4> <h4>Auth Test</h4>

View File

@ -6,11 +6,11 @@
<script setup lang="ts"> <script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {onMounted, ref} from 'vue'; import { onMounted, ref } from 'vue';
import {useRoute, useRouter} from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import api from 'src/api/main'; import api from 'src/api/main';
import {sleep} from 'src/utils'; import { sleep } from 'src/utils';
import {useI18n} from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@ -35,6 +35,4 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -28,11 +28,20 @@
</q-input> </q-input>
</div> </div>
<div class="row"> <div class="row">
<q-btn type="submit" :label="$t('loginPage.logIn')" color="primary" class="q-mt-md col-12" no-caps/> <q-btn
type="submit"
:label="$t('loginPage.logIn')"
color="primary"
class="q-mt-md col-12"
no-caps
/>
</div> </div>
<div class="row"> <div class="row">
<router-link <router-link
:to="{ path: '/register', query: route.query.next ? { next: route.query.next } : {} }" :to="{
path: '/register',
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"
> >
{{ $t('loginPage.createAccount') }} {{ $t('loginPage.createAccount') }}
@ -46,10 +55,10 @@
<script setup lang="ts"> <script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import SlimForm from 'components/SlimForm.vue'; import SlimForm from 'components/SlimForm.vue';
import {ref} from 'vue'; import { ref } from 'vue';
import api from 'src/api/main'; import api from 'src/api/main';
import {useAuthStore} from 'stores/auth-store'; import { useAuthStore } from 'stores/auth-store';
import {useRoute, useRouter} from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
@ -57,14 +66,16 @@ const route = useRoute();
const loginModel = ref({ const loginModel = ref({
email: '', email: '',
password: '' password: '',
}); });
const passwordVisible = ref(false); const passwordVisible = ref(false);
async function tryLogin() { async function tryLogin() {
try { try {
await api.auth.login(authStore, loginModel.value); await api.auth.login(authStore, loginModel.value);
const dest = route.query.next ? decodeURIComponent(route.query.next as string) : '/'; const dest = route.query.next
? decodeURIComponent(route.query.next as string)
: '/';
await router.push(dest); await router.push(dest);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -77,6 +88,4 @@ function resetLogin() {
} }
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -53,17 +53,17 @@
import SlimForm from 'components/SlimForm.vue'; import SlimForm from 'components/SlimForm.vue';
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import api from 'src/api/main'; import api from 'src/api/main';
import {useRouter} from 'vue-router'; import { useRouter } from 'vue-router';
import {ref} from 'vue'; import { ref } from 'vue';
import {useQuasar} from 'quasar'; import { useQuasar } from 'quasar';
import {useI18n} from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const router = useRouter(); const router = useRouter();
const registerModel = ref({ const registerModel = ref({
name: '', name: '',
email: '', email: '',
password: '' password: '',
}); });
const passwordVisible = ref(false); const passwordVisible = ref(false);
@ -77,7 +77,7 @@ async function tryRegister() {
} catch (error) { } catch (error) {
quasar.notify({ quasar.notify({
message: t('registerPage.error'), message: t('registerPage.error'),
type: 'negative' type: 'negative',
}); });
} }
} }
@ -89,6 +89,4 @@ function resetForm() {
} }
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -10,6 +10,4 @@
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -69,7 +69,6 @@ onMounted(async () => {
function initMap() { function initMap() {
if (!gym.value) return; if (!gym.value) return;
const g: Gym = gym.value; const g: Gym = gym.value;
console.log(mapContainer);
const tiles = new TileLayer(TILE_URL, { const tiles = new TileLayer(TILE_URL, {
attribution: ATTRIBUTION, attribution: ATTRIBUTION,

View File

@ -100,6 +100,7 @@ import { Gym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises'; import { Exercise } from 'src/api/main/exercises';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { sleep } from 'src/utils'; import { sleep } from 'src/utils';
import { uploadVideoToCDN, VideoProcessingStatus, waitUntilVideoProcessingComplete } from 'src/api/cdn';
interface Option { interface Option {
value: string; value: string;
@ -117,7 +118,7 @@ let submissionModel = ref({
weight: 100, weight: 100,
weightUnit: 'Kg', weightUnit: 'Kg',
reps: 1, reps: 1,
videoId: -1, videoFileId: '',
videoFile: null, videoFile: null,
date: new Date().toLocaleDateString('en-CA'), date: new Date().toLocaleDateString('en-CA'),
}); });
@ -159,10 +160,7 @@ async function onSubmitted() {
try { try {
infoMessage.value = 'Uploading video...'; infoMessage.value = 'Uploading video...';
await sleep(1000); await sleep(1000);
submissionModel.value.videoId = await api.gyms.submissions.uploadVideoFile( submissionModel.value.videoFileId = await uploadVideoToCDN(selectedVideoFile.value);
gym.value,
selectedVideoFile.value
);
infoMessage.value = 'Creating submission...'; infoMessage.value = 'Creating submission...';
await sleep(1000); await sleep(1000);
const submission = await api.gyms.submissions.createSubmission( const submission = await api.gyms.submissions.createSubmission(
@ -170,11 +168,14 @@ async function onSubmitted() {
submissionModel.value submissionModel.value
); );
infoMessage.value = 'Submission processing...'; infoMessage.value = 'Submission processing...';
const completedSubmission = const finalStatus = await waitUntilVideoProcessingComplete(submission.videoFileId);
await api.gyms.submissions.waitUntilSubmissionProcessed(submission.id); if (finalStatus === VideoProcessingStatus.COMPLETED) {
console.log(completedSubmission);
infoMessage.value = 'Submission complete!'; infoMessage.value = 'Submission complete!';
await sleep(1000);
await router.push(getGymRoute(gym.value)); await router.push(getGymRoute(gym.value));
} else {
infoMessage.value = 'Submission processing failed. Please try again later.';
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }

View File

@ -8,9 +8,9 @@ import GymHomePage from 'pages/gym/GymHomePage.vue';
import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue'; import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue';
import TestingPage from 'pages/TestingPage.vue'; import TestingPage from 'pages/TestingPage.vue';
import LoginPage from 'pages/auth/LoginPage.vue'; import LoginPage from 'pages/auth/LoginPage.vue';
import RegisterPage from "pages/auth/RegisterPage.vue"; import RegisterPage from 'pages/auth/RegisterPage.vue';
import RegistrationSuccessPage from "pages/auth/RegistrationSuccessPage.vue"; import RegistrationSuccessPage from 'pages/auth/RegistrationSuccessPage.vue';
import ActivationPage from "pages/auth/ActivationPage.vue"; import ActivationPage from 'pages/auth/ActivationPage.vue';
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
// Auth-related pages, which live outside the main layout. // Auth-related pages, which live outside the main layout.
@ -35,7 +35,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'leaderboard', component: GymLeaderboardsPage }, { path: 'leaderboard', component: GymLeaderboardsPage },
], ],
}, },
{ path: 'about', component: AboutPage } { path: 'about', component: AboutPage },
], ],
}, },

View File

@ -4,11 +4,12 @@ import nl.andrewlalis.gymboardcdn.util.ULID;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
@Configuration @Configuration
@EnableScheduling @EnableScheduling
@ -18,24 +19,17 @@ public class Config {
@Value("${app.api-origin}") @Value("${app.api-origin}")
private String apiOrigin; private String apiOrigin;
/**
* Defines the CORS configuration for this API, which is to say that we
* allow cross-origin requests ONLY from the web app for the vast majority
* of endpoints.
* @return The CORS configuration source.
*/
@Bean @Bean
@Order(1) public CorsFilter corsFilter() {
public CorsConfigurationSource corsConfigurationSource() {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
final CorsConfiguration config = new CorsConfiguration(); final CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); config.setAllowCredentials(true);
config.addAllowedOriginPattern(webOrigin); config.addAllowedOriginPattern(webOrigin);
config.addAllowedOriginPattern(apiOrigin); config.addAllowedOriginPattern(apiOrigin);
config.addAllowedHeader("*"); config.setAllowedHeaders(Arrays.asList("Origin", "Content-Type", "Accept"));
config.addAllowedMethod("*"); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "OPTIONS", "DELETE", "PATCH"));
source.registerCorsConfiguration("/**", config); source.registerCorsConfiguration("/**", config);
return source; return new CorsFilter(source);
} }
@Bean @Bean