This is part 3 in a three-part series. Read part 1 here and part 2 here.
With a working API layer under our belt, it's time to put in the final piece of the puzzle: the presentation layer. This layer consists of static resources (HTML, CSS, JavaScript) and API calls to display and add confessions.
✏️ As this tutorial is not frontend focused, I wanted to keep things simple and use vanilla JavaScript, HTML and CSS. I will also not go into too much detail with why or how these are implemented, as we are not really here for do frontend stuff are we? 😎
When we serve index.html back to the user, we want it to have a confession already so the user won’t load the page and then wait for the first API call to return the confession. That basically means that our application needs to parse the index.html static file, find the right place to inject a confession, and then inject the content in the correct place after fetching a random confession from the database. Now that might work if you have one item to inject, but what if you have two or ten?
That's where templates come into play. Templates allow us to have a static HTML that contains placeholders (aka variables) that the templating engine will replace with the content of our choice. In addition, templates allow us to use tags to control the template logic if
statements, for example). However, we will not be dealing with those in this tutorial.
For Late Night Confessions, I chose Askama as the templating engine because of its easy integration and simple syntax. Let’s create a template for our index.html file (trust me, it's easier than you think):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Late Night Confessions</title>
<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,300;1,400&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cirrus-ui@0.6.0/dist/cirrus.min.css" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="./static/confessions.js"></script>
</head>
<body>
<div class="stars"></div>
<div class="twinkling"></div>
<div class="clouds"></div>
<div class="main">
<div class="avatar avatar--xlarge">
<img src="https://images.unsplash.com/photo-1483706600674-e0c87d3fe85b?dpr=1&auto=format&fit=crop&w=256&h=256&q=60&cs=tinysrgb&crop=fill&bg=fff" />
</div>
<div class="title">
<h1>Late Night Confessions</h1>
</div>
<div class="confessionView">
<div class="card">
<div class="card-container">
<div class="card-image" id="confessionCardImage"></div>
</div>
<div class="content">
<p></p>
</div>
</div>
</div>
<div class="timeBar">
<div class="progress">
<div class="progress-value"></div>
</div>
</div>
<div class="addView">
<a href="#confession-modal"><button class="outline btn-primary">Add Yours</button></a>
</div>
<footer>
<p><span>♥️</span> confessions on record!</p>
</footer>
</div>
<div class="modal modal-animated--zoom-in" id="confession-modal">
<div class="modal-overlay"></div>
<div class="modal-content confessionAdd" role="document">
<div class="modal-header">
<a href="#main" class="u-pull-right" aria-label="Close"><span class="xBtn">X</span></a>
<div class="modal-title">Confess your <span class="heart">♥️</span> out</p></div>
</div>
<div class="modal-body">
<textarea id="confessionText" placeholder="Enter your confession"></textarea>
</div>
<div class="modal-footer">
<div class="form-section u-text-right">
<a href="#main">
<button class="btn btn-small u-inline-block">Cancel</button>
</a>
<button class="btn-primary btn btn-small" id="btnSubmit">Post</button>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
The vast majority of this file is plain old HTML syntax, take a glance at lines 32 and 47— These are Askama variables:
confession
(line 32) — This variable will be replaced by an actual confession from the database.total_confessions
(line 47) — This variable will be replaced by the total number of confessions in our database.And that's basically that for making our HTML an Askama template. Let’s quickly add the styles.css and confessions.js files to the site/static folder and get back to Rust to integrate Askama:
styles.css:
* {
margin: 0;
padding: 0;
}
body {
background-color: #000;
}
.main {
z-index: 5;
position: relative;
padding-top: 20px;
max-width: 600px;
margin: 0 auto;
height: 100%;
}
.title h1 {
font-size: 36px;
font-family: 'Lato', sans-serif;
margin-top: 0.5em;
margin-left: auto;
margin-right: auto;
text-align: center;
color: #fff;
background: -webkit-linear-gradient(#eee, #333);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 6px 6px 0px rgba(0, 0, 0, 0.2);
display: block;
position: relative;
z-index: 3;
}
@keyframes move-clouds-back {
from {
background-position: 0 0;
}
to {
background-position: 10000px 0;
}
}
@keyframes move-twinkle-back {
from {
background-position: 0 0;
}
to {
background-position: -10000px 5000px;
}
}
.stars,
.twinkling,
.clouds {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: block;
}
.stars {
background: #000 url(/assets/stars.png) repeat top center;
z-index: 0;
}
.twinkling {
background: transparent url(/assets/twinkling.png) repeat top center;
z-index: 1;
animation: move-twinkle-back 200s linear infinite;
}
.clouds {
background: transparent url(/assets/clouds.png) repeat top center;
z-index: 2;
opacity: 0.4;
animation: move-clouds-back 200s linear infinite;
}
.avatar {
border: 2px solid #333;
}
.progress {
background: rgba(255, 255, 255, 0.1);
justify-content: flex-start;
border-radius: 100px;
align-items: center;
position: relative;
padding: 0 5px;
display: flex;
height: 20px;
width: 100%;
max-width: 600px;
}
.progress-value {
animation: load 30s normal forwards;
box-shadow: 0 10px 40px -10px #fff;
border-radius: 100px;
background: #c21b2b;
height: 10px;
width: 0;
}
@keyframes load {
0% {
width: 100%;
}
100% {
width: 0%;
}
}
.confessionAdd {
max-width: 450px !important;
width: 100%;
}
.heart {
color: #c21b2b;
}
.addView {
text-align: center;
padding-top: 20px;
padding-bottom: 20px;
}
.modal.shown .modal-overlay,
.modal:target .modal-overlay {
background-color: rgba(54, 54, 54, 0.8);
}
.modal-header span.xBtn {
font-size: 1.4rem;
color: #374054;
}
.emptyArea {
border: 2px solid #c21b2b;
}
footer {
width: 100%;
padding-bottom: 5px;
}
footer span {
color: #f03d4d;
}
confessions.js:
const BASE_URL = 'http://localhost:8000';
async function refreshCard(cardContent, cardImage, progressBar) {
try {
const resp = await fetch(`${BASE_URL}/api/confession`, {
headers: {
'Content-Type': 'application/json',
},
});
const respJson = await resp.json();
cardImage.style.backgroundImage = `url('https://source.unsplash.com/featured/600x400/?city,night&seed=${Date.now()}')`;
cardContent.innerText = respJson.confession;
progressBar.classList.remove('progress-value');
void progressBar.offsetWidth;
progressBar.classList.add('progress-value');
} catch (e) {
console.error(e);
}
}
window.addEventListener('load', (event) => {
const progressBar = document.querySelector('.progress-value');
const cardImage = document.getElementById('confessionCardImage');
const cardContent = document.querySelector('.content p');
const btnSubmit = document.getElementById('btnSubmit');
const footerNote = document.querySelector('footer span').nextSibling;
cardImage.style.backgroundImage = `url('https://source.unsplash.com/featured/600x400/?city,night&seed=${Date.now()}')`;
btnSubmit.addEventListener('click', async () => {
const confessionArea = document.getElementById('confessionText');
confessionArea.classList.remove('emptyArea');
if (confessionArea.value.length <= 5) {
confessionArea.classList.add('emptyArea');
} else {
btnSubmit.setAttribute('disabled', 'disabled');
const resp = await fetch(`${BASE_URL}/api/confession`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: confessionArea.value }),
});
if (resp.status === 201) {
alert('Your confession is safe with us!');
const currentNumber = footerNote.textContent.match(/.+\d/)[0];
footerNote.textContent = ` ${parseInt(currentNumber) + 1} confessions on record!`;
} else {
alert('Error saving your confession. Try again later.');
}
window.location.href = '/#main';
}
});
progressBar.addEventListener('animationend', async () => {
await refreshCard(cardContent, cardImage, progressBar);
});
});
A few points of interest in our JavaScript:
refreshCard
function makes a call to our GET API to get a new random confession and update the UI.animationend
event to update the displayed confession once the timer runs off (lines 53–55).1. Back in our Rust project, let’s add Askama to the Cargo.toml file:
[dependencies]
...
askama = "0.10.5"
2. As mentioned earlier, Askama works with templates, so the first thing we need to do is tell Askama where our template is and which variables it's going to have:
use askama::Template;
#[derive(Template)]
#[template(path = "index.html")]
struct HomepageTemplate {
confession: String,
total_confessions: i64,
}
🔬 So what do we have here?
3. We now have two functions that need to get a random confession from the database: get_confession
(the handler for the GET API) and root
(the handler that renders index.html). To avoid code duplication, let’s extract get_confession
’s confession fetching logic to a new function, get_random_confession
:
fn get_random_confession(conn: &PgConnection) -> Result<Confession, diesel::result::Error> {
schema::confessions::table
.order(RANDOM)
.limit(1)
.first::<Confession>(conn)
}
Notice that our new function takes a conn
variable of type &PgConnection
as argument and return a Result
of either a Confession
(if successful) or diesel’s error type (if there was a database-related error).
4. With the get_random_confession
function in place, let’s update the get_confession
handler to use the new function as its closure:
#[get("/confession", format = "json")]
async fn get_confession(conn: DBPool) -> Result<Json<Confession>, CustomError> {
let confession: Confession = conn.run(|c| get_random_confession(c)).await?;
Ok(Json(confession))
}
5. Lastly, let’s update the root
handler to render the template using Askama and set it as the response:
#[get("/")]
async fn root(conn: DBPool) -> Result<content::Html<String>, CustomError> {
let confession_from_db: Confession = conn.run(|c| get_random_confession(c)).await?;
let confession_count:i64 = conn.run(|c| {
schema::confessions::table.count().get_result(c)
}).await?;
let template = HomepageTemplate {
confession: confession_from_db.confession,
total_confessions: confession_count,
};
let response = content::Html(template.to_string());
Ok(response)
}
🔬 So what do we have here?
Html<String>
for success and our CustomError
in case of an error.get_random_confession
function, just like we are doing for the API call to /api/confession.HomepageTemplate
struct (a.k.a our template definition) with the data from Postgres.And that's basically it! Go ahead and run cargo run
and browse to localhost:8000. You should get something similar to the following:
Our site creation journey has come to its end, but take a look back at everything you achieved! You now have a web app that is able to handle static files and API requests, uses a Postgres instance for persisting data and takes advantage of templates for quicker and easier HTML manipulation.
I would love to read your comments on this mini-post series over on Twitter, and please join me on my next Rust post series: “Let’s Build a Fitness Tracking Webapp With Rust and Yew”.