Writing and styling the frontend code

Create and style the webpage

Under the "static" directory, create an index.htm file. In a larger app we might break things up a bit, but for this demo we'll use a single file for all of the HTML.

We'll start by bringing in Vue as our JS framework (plus axios for convenient REST calls), and Bootstrap4 for stying. For simplicity, we will just use CDN-hosted sources:

<!-- development version of VueJS (use https://cdn.jsdelivr.net/npm/vue@2.6.10 in production) -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
<!-- axios for REST calls -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<!-- basic Bootstrap4 CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<!-- development version of VueJS (use https://cdn.jsdelivr.net/npm/vue@2.6.10 in production) -->


Now we create a pretty simple webpage, with a wrapper DIV that contains two major sections: one for showing the user's profile data (avatar, email, name, and bio), and another for registering or logging in. With only a few exceptions, all styling is done with basic Bootstrap4 classes and laid out using Bootstrap's grid system:

<div class="container text-center">

<h2 class="text-center text-secondary p-3">Welcome to EmptyApp!</h2>

<!-- profile pane for logged-in users -->
<div class="row justify-content-md-center border border-info p-2" style="min-width: 700px">

<div class="col-4">
<div class="mt-1">
<img class="img-fluid" src="/default_avatar.png" alt="avatar" style="max-width: 200px">
</div>
<form action="account" method="POST" enctype="multipart/form-data">
<input type="file" id="avatar" ref="avatar" accept="image/gif, image/jpg, image/jpeg, image/png"/>
</form>
</div>

<div class="col-8">
<div class="row mt-3">
<div class="col-12 text-center text-danger" style="min-height: 1.5rem">Errors go here</div>
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Email:</h5><p class="pl-1">User ID</p>
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Name:</h5><input class="col-9" name="bio" />
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Bio:</h5><input class="col-9" name="bio" />
</div>
<div class="row mt-4">
<div class="col-6 text-right"><div class="btn btn-outline-dark">Save</div></div>
<div class="col-6 text-left"><div class="btn btn-outline-dark">Log Out</div></div>
</div>
</div>

</div>

<!-- login/register pane -->
<div class="row justify-content-md-center border border-info p-2" style="min-width: 700px">

<div class="col-12">
<div class="row mt-3">
<div class="col-12 text-center text-danger" style="min-height: 1.5rem">Errors go here</div>
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Email:</h5><input class="col-9" name="email" autocomplete="username" />
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Password:</h5><input type="password" class="col-9" name="password" autocomplete="current-password" />
</div>
<div class="row mt-4">
<div class="col-6 text-right"><div class="btn btn-outline-dark">Log In</div></div>
<div class="col-6 text-left"><div class="btn btn-outline-dark">Register</div></div>
</div>
</div>

</div>

</div>

Add Vue directives

That'll do for a basic, styled skeleton. Now let's update that code to add in some Vue directives; these make our page dynamic, allowing us to selectively display only portions of the page, fill in values from the User's profile, and react to changes by calling JS methods. Our updated main DIV now looks like (changes highlighted):

<div class="container text-center">

<h2 class="text-center text-secondary p-3">Welcome to EmptyApp!</h2>

<!-- profile pane for logged-in users -->
  <div v-if="user" class="row justify-content-md-center border border-info p-2" style="min-width: 700px">

<div class="col-4">
<div class="mt-1">
<img v-if="avatar_loading" class="img-fluid" src="/loading.gif" alt="avatar" style="max-width: 200px">
<img v-if class="img-fluid" :src="user.avatar+'?'+update_time" alt="avatar" style="max-width: 200px">
      </div>
<!-- button triggers form, since cannot style file inputs directly -->
<form action="account" class="d-none" method="POST" enctype="multipart/form-data" @submit.prevent>
<input type="file" id="avatar" ref="avatar" accept="image/gif, image/jpg, image/jpeg, image/png" @change="updateAvatar"/>
</form>
<button class="btn btn-outline-dark mt-1" @click="triggerAvatar" :disabled="avatar_loading">Change image</button>
</div>

<div class="col-8">
<div class="row mt-3">
<div class="col-12 text-center text-danger" style="min-height: 1.5rem">{{error}}</div>
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Email:</h5><p class="pl-1">{{user.id}}</p>
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Name:</h5><input class="col-9" name="bio" v-model="user.name" />
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Bio:</h5><input class="col-9" name="bio" v-model="user.bio" />
</div>
<div class="row mt-4">
<div class="col-6 text-right"><div class="btn btn-outline-dark" @click="updateUser">Save</div></div>
<div class="col-6 text-left"><div class="btn btn-outline-dark" @click="logOut">Log Out</div></div>
</div>
</div>

</div>

<!-- login/register pane -->
<div v-else class="row justify-content-md-center border border-info p-2" style="min-width: 700px">

<div class="col-12">
<div class="row mt-3">
<div class="col-12 text-center text-danger" style="min-height: 1.5rem">{{error}}</div>
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Email:</h5><input class="col-9" name="email" autocomplete="username" v-model="login.email" />
</div>
<div class="row mt-3">
<h5 class="col-2 text-right">Password:</h5><input type="password" class="col-9" name="password" autocomplete="current-password" v-model="login.password" />
      </div>
<div class="row mt-4">
<div class="col-6 text-right"><div class="btn btn-outline-dark" @click="logIn">Log In</div></div>
<div class="col-6 text-left"><div class="btn btn-outline-dark" @click="register">Register</div></div>
</div>
</div>

</div>

</div>

<script src="/app.js"></script>


If you're not familiar with Vue, here's a peek at the specific code added, and what it does:

id="app" -- each Vue app works within a specified element, so we need to have an ID to locate it. Any ID will do... here we're using "app" just for simplicity.

v-if="user" -- if there's a non-null property "user" present in our Vue app, this DIV vill be displayed. If not, it is hidden.

<img v-if="avatar_loading" class="img-fluid" src="/loading.gif" alt="avatar" style="max-width: 200px"> -- by adding a property "avatar_loading" to our Vue app, we can make this img appear, to display an animated "loading" gif. We'll turn this on and off when the user is updating their profile image, to reassure them that something is happening.

<img v-else class="img-fluid" :src="user.avatar+'?'+update_time" alt="avatar" style="max-width: 200px"> -- the v-else directive here says that this element should only be shown if the prior v-if evaluated to True, so the actual avatar will only be shown when the loading gif is not. The :src property tells Vue it should evaluate the contents of the property, then set the src property of that element to the result. "user.avatar+'?'+update_time" grabs the user's avatar property from the Vue app and appends "?" and the property "update_time". We'll use this for cache-busting the images, so that the rendered src URL for the image will look something like "/avatars/filename.jpg?1565038570243", with the number changing each time the image is updated.

<form action="account" class="d-none" method="POST" enctype="multipart/form-data" @submit.prevent> -- the default file-upload box is ugly, but we can't style it directly, so we'll hide it with bootstrap's d-none class and add our own button later. @submit.prevent is Vue's way of blocking the default form action, so it doesn't navigate away from the page when the form is submitted.

<input type="file" id="avatar" ref="avatar" accept="image/gif, image/jpg, image/jpeg, image/png" @change="updateAvatar"/> -- when the form's file input changes, we'll run a function called "updateAvatar()" in the Vue controller.

<button class="btn btn-outline-dark mt-1" @click="triggerAvatar" :disabled="avatar_loading">Change image</button> -- this button visually replaces the file-upload form. When it is clicked, a Vue function "triggerAvatar" will be run (as we'll see soon, this function's purpose is to trigger the form element itself).

{{error}} -- this looks for a property called "error" in the Vue app, and places its contents here. If the property is null, an empty string is displayed.

{{user.id}} -- if there is a user with an "id" property in Vue, it shows the value here.

v-model="user.name" -- displays the "name" property of the user from the Vue app, and also allows the user to edit that value: any changes to the text in the input field will cause the user.id to immediately change.

v-model="user.bio" -- as above, but for the user's bio.

@click="updateUser" -- when this button is clicked, a function "updateUser()" will be called in the Vue controller.

@click="logOut" -- as above, but the "logOut()" function

v-else -- in the DOM (webpage's structure), the login/register DIV immediately follows the profile div, so this v-else occurs when <div v-if="user"... evaluates to false. In other words, the profile div is shown when a non-null "user" property exists in Vue, and the login/register div is shown when there is no user.

{{error}} -- displays the property "error" from the Vue app, if any.

v-model="login.email" -- displays an editable "email" property from an item called "login" in the Vue app.

v-model="login.password" -- as above, but the "password" property.

@click="logIn" -- when clicked, the "login()" function is called in Vue.

@click="register" -- when clicked, calls "register()"

<script src="/app.js"></script> -- as the last line of our HTML, we include app.js, which will contain the frontend logic which glues together the HTML view and the backend Python we wrote earlier. This is added last so that the HTML exists before the script is run, but could be brought to the top if an onload callback was used to delay its execution.

Our HTML is now complete! You can check it against the completed example.


Write the Vue controller

With the directives in place, our HTML is ready to hold the values we need the user to see, and accept input from the user. But we need something to connect that display to our backend Python/Flask. This is the role of the Vue app: it acts as a frontend controller, managing the state of the display and handling the logic which connects the user's view to our backend REST endpoints.

Create a file app.js inside the "static" directory.  We'll begin by creating a global variable to hold the URL where our Flask server is running; all our REST calls will go here, so it makes sense to have a single variable (in ES6, we'd use const).  Then, we create the initial skeleton for our Vue app. The el parameter tells it which element to attach to in the DOM (id="app" in our HTML above):

var API_URL='http://127.0.0.1:5000';

var app = new Vue({
el: '#app',
data: {},
methods: {},
mounted: function() {}
});

 

Let's fill out each of our Vue app's sections one-by-one, starting with the Data section. This will hold all the properties we need to reference over the lifetime of the app. We've seen most of these in our HTML already, and they are mostly empty/false to begin with. Error will hold any error messages to display, avatar_loading is true only when the avatar image is being updated, user will hold the current User's profile data, update_time will change whenever the user's data changes, login will hold the username/password entered in the login/registration form, and token (never shown in the HTML) holds the JWT authorization passed back from Flask when the user logs in:

  data: {
error: null,
avatar_loading: false,
user: null,
update_time: null,
login: {email: null, password: null},
token: null
},


The Methods section should contain the functions that can be triggered within the Vue app. Let's go through them individually, noting that they all clear the error property of the app when they start, and update the error property as needed to alert the user of any messages/errors.

register -- POST a new username/password to the "/register" URL; if it succeeds, save the authentication token and persist it in localStorage (in case the user closes/re-opens the page). Update the user property and the update_time.

logIn -- similar to register, but for existing users to log in.

logOut -- clear all data and erase the token from localStorage.

updateUser -- POST the user's (updated) profile data to "/account", then refresh the local copy of the user with the response (in case the server altered the data after it was received). Note the addition of the Authorization header which contains the current token.

triggerAvatar -- web browsers only allow file-upload forms to be triggered by an user click. This function is triggered by a button in the HTML, and serves merely to cause a simulated click() action to be performed on the hidden file-upload form input.

updateAvatar -- once the user has selected a new profile image file to upload, POST it to the server as a multipart form, including the Authorization token in the header. Upon success, refresh the local user data and update_time. Set avatar_loading to true during the process, so they know something is happening.

  methods: {
register: function() {
app.error = null;
axios.post(API_URL+'/register', {email: app.login.email, password: app.login.password})
.then(function(response) {
console.log(response);
app.token = response.data.token;
localStorage.token = app.token;
app.user = response.data.user;
app.update_time = Date.now();
},
function(err) {
app.error = err.response.data.message;
});
},
logIn: function() {
app.error = null;
axios.post(API_URL+'/login', {email: app.login.email, password: app.login.password})
.then(function(response) {
app.token = response.data.token;
localStorage.token = app.token;
app.user = response.data.user;
app.update_time = Date.now();
},
function(err) {
app.error = err.response.data.message;
});
},
logOut: function() {
app.error = null;
app.user = null;
app.login = {email: null, password: null};
app.token = null;
localStorage.token = null;
},
updateUser: function() {
app.error = null;
axios.post(API_URL+'/account', app.user, {headers: {Authorization: 'Bearer: '+app.token}})
.then(function(response) {
app.error = "Your changes have been saved";
app.user = response.data.user;
app.update_time = Date.now();
},
function(err) {
if(err.response.status==401) {
return app.logOut();
}
app.error = err.response.data.message;
});
},
triggerAvatar: function() {
this.$refs.avatar.click()
},
updateAvatar: function() {
var formData=new FormData();
fileList=this.$refs.avatar.files;
if(!fileList.length) return;
formData.append('avatar',fileList[0],fileList[0].name);
var url=API_URL+'/avatar';
app.error="Processing your image...";
app.avatar_loading = true;
axios.post(url,formData, {headers: {Authorization: 'Bearer: '+app.token, 'Content-Type': 'multipart/form-data'}} )
.then(function(response) {
app.error = null;
app.avatar_loading = false;
app.user = response.data.user;
app.update_time = Date.now();
},
function(err) {
if(err.response.status==401) {
return app.logOut();
}
app.error=err.response.data.message;
app.avatar_loading=false;
});
}
}


Lastly, when the page first loads, we want to check to see if they are already logged in, so we add a function to the Mounted section which checks for a token in localStorage, then validates it against the server:

mounted: function() {
//on login, load token from local storage; if it hasn't expired, refresh it and log user in
var token = localStorage.token;
if (token && token.split('.').length >= 3) {
var data=JSON.parse(atob(token.split('.')[1]));
var exp=new Date(data.exp*1000);
if(new Date()<exp) {
axios.get(API_URL+'/account',{headers:{Authorization:'Bearer: '+token}})
.then(function(response) {
app.token=response.data.token;
localStorage.token=app.token;
app.user=response.data.user;
app.update_time=Date.now();
});
}
}
}

 

There are a lot of other things we could add, but we have a basic functioning app now, so let's test it out! Compare your completed code to the example. If everything looks good, we're ready to try running the application.


NEXT: start the server