This segment is the tenth installment of a tutorial series focused on how to create a full-stack application using Flask and Stream. In this article, we are going to start styling our app and adding cool new features like notification counts and link previews. Be sure to check out the Github repo to follow along!
Getting Started
As of our previous tutorial, we have built all of the basic functionality of our site. Our last issue to resolve is that the app lacks any kind of aesthetic appeal. Currently, any links that a user might add do not have any visual flare- the feeds definitely won't be winning any UI design awards. One thing that would drastically improve the look of our site would be to add link previews, similar to Facebook, Twitter, or Pinterest. Also, being able to auto-populate the title and description fields for the user after they enter a link would make for a much better user experience.
Pre-Previews
Luckily, Stream provides a very convenient, asynchronous JavaScript function that takes a URL and returns all of the information that we need using Open Graph. Open Graph protocol returns titles, descriptions, images, and videos from a link. We already have the description and title, so the only thing left to add is images. Feel free (even encouraged!) to add a conditional into your app to allow videos to display instead of images if they are available.
However, before we can start integrating this functionality, we need to make space for it in our web app. The first step is adding images to our database model, as well as providing a way to update our Stream Feeds for new fields (in app/models.py
).
#... Content(db.Model): #... image = db.Column(db.String, default=None) #... def add_to_stream(self): #... client.feed("Collections", str(self.collection_id)).add_activity({'actor': client.users.create_reference(str(self.collection.author_id)), 'verb': 'post', 'object': 'Content:' + str(self.id), 'post': self.title, 'url': self.url, 'image': self.image, 'description': self.description, 'time': self.timestamp, 'collection': { 'id': self.collection_id, 'name': self.collection.name }, 'foreign_id': 'Content:' + str(self.id) }) #... def add_fields_to_stream(self, **kwargs): # Update Stream Feed with new fields try: client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) client.activity_partial_update(foreign_id='Content:' + str(self.id), time=self.timestamp, set=kwargs ) return True except: return False def remove_fields_from_stream(self, **kwargs): # Update Stream with removed fields try: client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) client.activity_partial_update(foreign_id='Content:' + str(self.id), time=self.timestamp, unset=kwargs ) return True except: return False
Hindsight Is 20/20
Before we can start adding new content, any links to existing content do not have images associated with them yet. When we start using images in our feeds, some of them will display with images, and others will have a large, ugly “None” text showing instead. Therefore, we need a solution that allows us to update each image in the database as well as our Stream feeds. Since we are using Open Graph on the front end, the backend script should use the same thing. To do so, we will use the Python-OpenGraph library. To install it, run pip install python-opengraph
. Then (in application.py
) we will create a CLI script that returns all content entries in the database, queries their Open Graph values, returning and updating their image values for the table and Stream.
#... import opengraph #... @app.cli.command() def update_images(): content = Content.query.all() for x in content: og = opengraph.OpenGraph(url=x.url) x.image = og.image x.add_fields_to_stream(image=og.image) db.session.commit()
Again, we have altered our database model, so be sure to run flask db migrate, flask db upgrade, as well as the newly created flask update-images. The command line does not recognize underscores, so replace the underscore(_) with a dash(-) when running the command.
Picture Perfect
Now that everything is up to date, we can start to change our forms, views, and templates for the new field. We begin with the templates (in app/templates/_content.html
).
#... <!-- Content Card --> <div id="content-card"> <!-- Content Image --> <div id="content-img"> <a id="content-image-link" href=""><img id="content-image" src="" alt="None"></a> </div> <!-- Content Title --> #...
Next, we need to adjust our index page for the home timeline to retrieve and render the content through the Stream request (in app/templates/index.html
).
#... <!-- Content Info--> template_clone.querySelector('#content-image').src = data.results[i].activities[j]['image']; template_clone.querySelector('#content-image-link').href = data.results[i].activities[j]['url']; #...
Finally, we do the same for the collection page (in app/templates/collection.html
).
#... <!-- Content Info--> template_clone.querySelector('#content-image').src = data.results[i].activities[j]['image']; template_clone.querySelector('#content-image-link').href = data.results[i].activities[j]['url']; #...
See It To Believe It
We want to give users the chance to see their content before they post it to a collection by creating a link preview in the new content screen. The preview needs to have a few bits of functionality. First, we want it to show and return the image, but we don’t want the image field itself to show. Second, we want the preview to reflect the on-screen input fields for title and description. We start this process off with our forms (in
app/main/forms.py`)
#... from wtforms import StringField, TextAreaField, BooleanField, SelectField,\ SubmitField, HiddenField #... class ContentForm(FlaskForm): collection = SelectField(label='Collection', render_kw={'class': 'input-field'}, coerce=int, validators=[DataRequired()]) image = HiddenField(id='image') title = StringField(label='Title', id='title', validators=[DataRequired()]) description = StringField(label='Description', id='description') url = StringField(label='URL', id='url', validators=[URL(), DataRequired()]) submit = SubmitField('Submit')
I added ID parameters to each of the fields in the form, which help us select them using jQuery in the template. I also created the image field as a hidden field so that it won’t show to the user.
We also need to update our new content view to return a token for the user (for the Stream function) as well as to provide the ability to use the new image field in creating a record from the form (in app/main/views.py
).
#... @main.route('/new-content/', methods=['GET', 'POST']) @login_required def new_content(): form = ContentForm() # Generate Stream token for use in template token = current_user.stream_user_token() form.collection.choices = [(c.id, c.name) for c in current_user.collections] if current_user.can(Permission.WRITE) and form.validate_on_submit(): collection = Collection.query.get_or_404(form.collection.data) if collection.has_content(form.url.data): flash('That link is already in this Collection!') return redirect(url_for('.new_content')) content = Content(title=form.title.data, image=form.image.data, url=form.url.data, description=form.description.data, collection=collection) db.session.add(content) db.session.commit() if content.add_to_stream(): flash("Your Content has been added!") return redirect(url_for('.get_content', id=content.id)) else: db.session.delete(content) db.session.commit() flash("An error occurred, please try again") return redirect(url_for('.new_content')) # Pass through token in return statement return render_template('new_content.html', form=form, token=token)
Since the preview does not need nor have some of the information provided by the full content template, we need to create a new one (in app/templates/_content_preview.html
).
<template id="content-template"> <div class="container content-container"> <div id="content-img"> <a id="content-image-link" href=""><img id="content-image" src="" alt="None"></a> </div> <!-- Content Title --> <h4 id="content-title-header"> <a href="" id="content-title"></a> </h4> <!-- Content Description --> <div id="content-body"> <p id="content-description"></p> <a id="content-read-more" href=""></a> </div> </div> </template>
The Stream Open Graph scraper works in a very similar way to the requests we have been doing for a while, so it is a straightforward crossover for our existing JavaScript code (in app/templates/new_content.html
).
#... <script> document.addEventListener("DOMContentLoaded", function() { const client = stream.connect( '7yuqbuncncwa', '{{ token }}' ); const content_template = document.querySelector('#content-template'); const urlFieldElement = document.querySelector('#url'); urlFieldElement.addEventListener('change', (event) => { var input_string1 = event.target.value.match(/(([a-z]+:\/\/)?(([a-z0-9\-]+\.)+([a-z]{2}|aero|arpa|biz|com|coop|edu|gov|info|int|jobs|mil|museum|name|nato|net|org|pro|travel|local|internal))(:[0-9]{1,5})?(\/[a-z0-9_\-\.~]+)*(\/([a-z0-9_\-\.]*)(\?[a-z0-9+_\-\.%=&]*)?)?(#[a-zA-Z0-9!$&'()*+.=-_~:@/?]*)?)(\s+|$)/gi); var input_string2 = event.target.value.match(/https?:\/\/\S+/gi); if (input_string1 !== null || input_string2 !== null) { if (input_string2 !== null) { var matches = input_string2 } else { var matches = input_string1 } async function ogRequest(url) { let response = await client.og(url); return response } ogRequest(matches[0]) .then((data) => { let template_clone = content_template.content.cloneNode(true); let content_scroller = document.querySelector('#content-scroller'); $("#url").val(matches[0]); $("#image").val(data.images[0]['image']); $("#title").val(data.title); $("#description").val(data.description); <!-- Content Info--> template_clone.querySelector('#content-image').src = data.images[0]['image']; template_clone.querySelector('#content-image-link').href = data.url; template_clone.querySelector('#content-title').innerHTML = data.title; // Update ID value of element to let it be updated on field change template_clone.querySelector('#content-title').id = `content-title-1`; template_clone.querySelector('#content-description').innerHTML = data.description; // Update ID value of element to let it be updated on field change template_clone.querySelector('#content-description').id = `content-description-1`; template_clone.querySelector('#content-read-more').innerHTML = 'Read More'; template_clone.querySelector('#content-read-more').href = data.url; content_scroller.appendChild(template_clone); }) } }); }); // Listen for title and description field changes to edit preview var title = document.getElementById('title'); title.onkeyup = title.onkeypress = function(){ document.getElementById('content-title-1').innerHTML = this.value; }; var description = document.getElementById('description'); description.onkeyup = description.onkeypress = function(){ document.getElementById('content-description-1').innerHTML = this.value; }; </script> {% endblock %}
The input string variables at the top provide regex for two things. The first is for ‘dirty’ URLs, or when a link is surrounded by unrelated text, and the second identifies a ‘clean’ URL, one that does not have any surrounding text.
This page includes the addition of the onkeyup and onkeypress methods at the bottom which trigger an update to the preview at the top when the input fields are changed. Also, we use jQuery to set values for the form when the preview function is run, so import it in app\templates\base.html
.
#... <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script> #...
The last thing we have to do is update the content page itself to include the image (in app/templates/content.html
).
#... <img id="content-image" src={{ content.image }} alt="None"> <h2 id="content-name">{{ content.title }}</h2> #...
Notify Me
Even though we have a notifications feed running, it does not provide the notification-count that you would expect to see from a modern social media platform. Luckily, we can put this together in a snap. Since the notification-count displays in the navbar, we need to create a badge (like our follower-count) that visibly shows the count of unseen notifications (in app/templates/base.html
).
#... <ul class="dropdown-menu"> <li><a href="{{ url_for('main.notifications') }}">Notifications <span id="notification-count" class="badge"></span></a></li> #... {% block scripts %} {{ super() }} {{ moment.include_moment() }} {% if current_user.is_authenticated %} <script> document.addEventListener("DOMContentLoaded", function() { const client = stream.connect( '7yuqbuncncwa', '{{ token }}' ); const notifications = client.feed('Notifications', '{{ current_user.id }}'); function loadNotificationCount() { notifications.get().then((data) => { if (data['unread'] > 0) { $("#notification-count").text(data['unread']); } }) } loadNotificationCount() }); </script> {% endif %} {% endblock %}
Since the navbar returns on every page, we need to provide a current user ID # to the Stream request to get the count. While I am going to pass through the current user token on every rendered template, you could just as easily modify the code to use cookies instead.
#... @main.route('/user/<username>') def user(username): #... token = user.stream_user_token() return render_template('user.html', user=user, token=token) @main.route('/users') def users(): #... token = current_user.stream_user_token() return render_template('users.html', users=users, pagination=pagination, token=token) @main.route('/edit-profile', methods=['GET', 'POST']) @login_required def edit_profile(): #... token = current_user.stream_user_token() return render_template('edit_profile.html', form=form, token=token) @main.route('/edit-profile/<int:id>', methods=['GET', 'POST']) @login_required @admin_required def edit_profile_admin(id): #... token = current_user.stream_user_token() return render_template('edit_profile.html', form=form, user=user, token=token) @main.route('/new-collection/', methods=['GET', 'POST']) @login_required def new_collection(): #... token = current_user.stream_user_token() return render_template('new_collection.html', form=form, token=token) @main.route('/edit-collection/<int:id>', methods=['GET', 'POST']) @login_required def edit_collection(id): #... token = current_user.stream_user_token() return render_template('edit_collection.html', collection=collection, form=form, token=token) @main.route('/content/<int:id>', methods=['GET']) def get_content(id): #... token = current_user.stream_user_token() return render_template('content.html', content=content, author=content.collection.author, token=token) @main.route('/new-content/', methods=['GET', 'POST']) @login_required def new_content(): #... token = current_user.stream_user_token() return render_template('new_content.html', form=form, token=token) @main.route('/edit-content/<int:id>', methods=['GET', 'POST']) @login_required def edit_content(id): #... token = current_user.stream_user_token() return render_template('edit_content.html', content=content, form=form, token=token) @main.route('/followers/<username>') def followers(username): #... token = current_user.stream_user_token() return render_template('followers.html', user=user, title="Followers of", endpoint='.followers', pagination=pagination, follows=follows, token=token) @main.route('/followed_by/<username>') def followed_by(username): #... token = current_user.stream_user_token() return render_template('followers.html', user=user, title="Followed by", endpoint='.followed_by', pagination=pagination, follows=follows, token=token) @main.route('/collection-followers/<int:id>') @login_required def collection_followers(id): #... token = current_user.stream_user_token() return render_template('collection_followers.html', collection=collection, title='Followers', endpoint='.collection_followers', pagination=pagination, follows=follows, token=token)
Finally, we want the count to return to zero once a user opens the notification page, as that would indicate they have, in fact, seen the notifications. This step requires a very simple tweak to accomplish (in app/templates/notifications.html
).
#... // mark notifications as read when notification page is opened if (!last_id) { request = notifications.get({limit: 10, mark_read: true}) } else { request = notifications.get({limit: 10, mark_read: true, id_lt: last_id}) #...
Gotta Have Style
We are getting close to the end now! The last thing that we need to tackle is some simple CSS changes to polish the look of the site. First, let's take a look at changing the font. I personally prefer to stick to one or two fonts at an absolute maximum, as font-weight, style, and decoration can provide all of the variation you actually need. In the case of this demo, I chose Montserrat from Google Fonts, but feel free to play around and find what works best for you. We can import the font in app/templates/base.html
.
#... <link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet"> #...
Additionally, we have text notifying a user that they have reached the end of a feed. It would look cleaner and clearer if we replaced the text to use an image instead. We will have to update this in all of the templates that use feeds, starting with the home page (in app/templates/index.html
).
#... if (data.next === "") { content_sentinel.innerHTML = '<img class="finished" src="{{ url_for("static", filename="tick-inside-a-circle.svg")|safe }}" alt="None"><br>'; content_loading = false; last_page = true; return; } #...
Then notifications (in `app/templates/notifications.html)
#... if (data.next === "") { collection_sentinel.innerHTML = '<img class="finished" src="{{ url_for("static", filename="tick-inside-a-circle.svg")|safe }}" alt="None"><br>'; collection_loading = false; last_page = true; return; } #...
Next, the user page (in app/templates/user.html
)
#... if (data.results.length < 1) { collection_sentinel.innerHTML = '<img class="finished" src="{{ url_for("static", filename="tick-inside-a-circle.svg")|safe }}" alt="None"><br>'; collection_loading = false; return; #...
And finally, the collection page (in app/templates/collection.html
)
#... if (data.results.length < 1 ) { content_sentinel.innerHTML = '<img class="finished" src="{{ url_for("static", filename="tick-inside-a-circle.svg")|safe }}" alt="None"><br>'; content_loading = false; return; #...
Profiling
As you can see, our user page has some slight stylistic issues, particularly with the username and user’s name being displayed right beside each other. This appears redundant, so it would help if only one of these elements were displayed, preferably their actual name. Additionally, we need to add some tags to the page to add styles (in app/templates/user.html
).
#... <div class="page-header"> <img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=256) }}"> <div class="profile-header"> <h1 id="user-title">{% if user.name %}{{ user.name }}{% else %}{{ user.username }}{% endif %}</h1> {% if current_user.is_administrator() %} <p><a href="mailto:{{ user.email }}">{{ user.email }}</a></p> {% endif %} {% if user.about_me %}<p id="user-description">{{ user.about_me }}</p>{% endif %} <p id="user-meta">Member since {{ moment(user.member_since).format('L') }}. Last seen {{ moment(user.last_seen).fromNow() }}.</p> <p> #...
Final Touches
Now, all we have to do is put together our CSS for the page, and we are done!
We’start off by creating the CSS document and defining our element level references (in app/static/styles.css
).
/* Tags */ a { color: #1f1f1f; } a:hover { color: #1f1f1f; } h1, h2, h3, h4, h5, h6, p, a, textarea, input, label, select, th, span { font-family: 'Montserrat', sans-serif; }
Next, we set a maximum width on our content container to limit the size of the pictures and nicely center the content (in app/static/styles.css
).
#... /* Content */ .content-container { max-width: 400pt; }
After that step, we give some visual flair to the content elements, specifically the image. Creating an animated shadow for the image will add a nice-looking effect. Varying the font sizes and weights (but don’t go too crazy!) also helps to break up the text and allows the user to focus on different sections independently (in app/static/styles.css
).
#... /* Content Header */ #user-profile{ margin-bottom: 5pt; } #user-profile-link{ font-size: 10pt; margin-left: 5pt; } #user-profile-img{ border-radius: 50%; } /* Content Body */ #content-card{ text-align: center; } #content-image { max-width: 500px; box-shadow: rgba(47, 125, 235, 0.16) 0px 2px 7px, rgba(47, 125, 235, 0.16) 0px 2px 7px, rgba(47, 125, 235, 0.11) 0px 1px 1px; border-radius: 8px; transition: all 0.32s cubic-bezier(0.4, 0, 0.2, 1) 0s; transition-property: all; transition-duration: 0.32s; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-delay: 0s; cursor: pointer; margin-top: 2pt; margin-bottom: 5pt; } #content-image:hover{ box-shadow: rgba(47, 125, 235, 0.1) 0px 1px 6px, rgba(47, 125, 235, 0.1) 0px 0px 2px, rgba(47, 125, 235, 0.11) 0px 1px 1px; } #content-title{ font-size: 16pt; font-weight: 200; } #content-body { margin-bottom: 3pt } #content-description { font-size: 12pt; margin-bottom: 2pt; color: #333333ba } #content-read-more { font-weight: 500 } /* Content Meta */ #content-meta { font-size: 8pt; font-style: italic; }
In continuation, we focus on the page headers, our profile, and content pages, centering the text and putting emphasis on the user’s name (in app/static/styles.css
).
#... /* Page Headers */ .page-header { text-align: center; } /* Profile Page */ .profile-thumbnail { border-radius: 50%; } #user-title{ font-weight: 900; font-size: 20pt; } #user-description { font-size: 10pt; margin-bottom: 2pt; color: #333333ba } #user-meta { font-size: 8pt; font-style: italic; } /* Content Page */ #content-author { margin-left: 5pt }
The notifications-count has a rather bland gray background, which undercuts its importance. A sleek-looking gradient effect can make it appear a lot more interesting (in app/static/styles.css
).
#... /* Notifications */ #notification-count { background: linear-gradient(90deg, #ff8a00, #e52e71); margin-left: 5pt; margin-bottom: 1.5pt; }
Last but not least, we need to center and size the “finished” image we integrated this week (in app/static/styles.css
).
#... /* Content End*/ .finished { max-width: 6%; position: absolute; left: 47%; }
Final Thoughts
Congratulations!
You have officially created an entire web application with Flask and Stream, complete with styling and some rather slick functionality. The response times for our content is seamless thanks to lightning-quick data retrieval from Stream, with a modern, feed-based view to check out all the latest and greatest activities from your friends. We are getting very close to the end of this section of the tutorial, with our last step being deployment and configuration. In the next article, I am going to take you through deploying the entire app as a serverless function using AWS Lambda, RDS, and Amazon Mail!
As always, thanks for reading and happy coding!
Note: The next post in this series can be found here