Introduction
From part 0 to part 4, we built out CryptoFlow
's backend service. Though we can quickly use Postman, VS Code's ThunderClient or automated tests to see the endpoints working easily, this isn't all we want. We want to actively interact with the backend service via some intuitive user interface. Also, a layman wouldn't be able to "consume" the service we've built in the last parts. This article introduces building out the user interface of the system. We will be using SvelteKit, a framework that streamlines web development, and TailwindCSS, the utility-first CSS framework. Let's dig in!
Source code
The source code for this series is hosted on GitHub via:
Sirneij / cryptoflow
A Q&A web application to demostrate how to build a secured and scalable client-server application with axum and sveltekit
CryptoFlow
CryptoFlow
is a full-stack web application built with Axum and SvelteKit. It's a Q&A system tailored towards the world of cryptocurrency!
I also have the application live. You can interact with it here. Please note that the backend was deployed on Render which:
Spins down a Free web service that goes 15 minutes without receiving inbound traffic. Render spins the service back up whenever it next receives a request to process. Spinning up a service takes up to a minute, which causes a noticeable delay for incoming requests until the service is back up and running. For example, a browser page load will hang temporarily.
Its building process is explained in this series of articles.
Implementation
Step 1: Set up the frontend app
To ease the entire process, kindly head on to creating a project. I named this frontend
. After that, proceed to install tailwind CSS with sveltekit. Kindly do this before moving to the next step.
Since we want the frontend application to be served on port 3000
, head on to frontend/package.json
and update the dev
script to:
...
"dev": "vite dev --port 3000",
...
We also need to get the backend URL for server communication. Depending on your backend domain or where you host the backend code, this can change. We will use a .env
file at the root of the frontend
app:
VITE_BASE_API_URI_DEV=http://127.0.0.1:8008/api
VITE_BASE_API_URI_PROD=
Normally, variables in .env
files served by vite should have the VITE_
prefix. You can change the value to the URL of your local web server (axum backend built in the last few articles). The .env
file has two variables that store the backend APIs depending on whether it's in a development or production environment.
Retrieving and using this environment variable can be made seamless by exporting it to a .js
file. In SvelteKit, I like to do this in frontend/src/lib/utils/constants.js
:
export const BASE_API_URI = import.meta.env.DEV
? import.meta.env.VITE_BASE_API_URI_DEV
: import.meta.env.VITE_BASE_API_URI_PROD;
That automatically updates BASE_API_URI
from the .env
file.
Step 2: Getting the app layout
As earlier stated, we'll use a lot of TailwindCSS to style the web interface. I have already done that and won't be going into the nitty-gritty of the interface or how to use TailwindCSS. That said, let's build out how the general outlook of the app will be. Open up frontend/src/routes/+layout.svelte
:
<script>
import "../app.css";
import Header from "$lib/components/header/Header.svelte";
import Footer from "$lib/components/footer/Footer.svelte";
import Transition from "$lib/components/Transition.svelte";
export let data;
</script>
<Transition key="{data.url}" duration="{600}">
<header class="sticky top-0 z-50 bg-[#0a0a0a]">
<header />
</header>
<slot />
<footer />
</Transition>
The first import, import '../app.css'
, was gotten from setting up tailwind CSS with sveltekit. Others were components written. I won't show codes for all the components, however, the Transition
component looks like this:
<script>
import { slide } from 'svelte/transition';
/** @type {string} */
export let key;
/** @type {number} */
export let duration = 300;
</script>
{#key key}
<div in:slide={{ duration, delay: duration }} out:slide={{ duration }}>
<slot />
</div>
{/key}
The component simply allows smooth page transactions. Just to have nice effects while navigating pages. The transition requires a key which should be distinct for each page. A simple and intuitive thing that comes to mind is the page's URL which is generally unique. To make available the page's URL, we need to expose it in +layout.js
:
// frontend/src/routes/+layout.js
/** @type {import('./$types').LayoutLoad} */
export async function load({ fetch, url, data }) {
const { user } = data;
return { fetch, url: url.pathname, user };
}
We didn't only expose the URL, fetch
and user
were also exposed. fetch
will be used to make some requests to the server later on. I always prefer using the fetch
API provided by SvelteKit which extends the normal version of the API. user
makes available the data of the currently logged-in user. How do we get it? We'll use the power of the handle method of SvelteKit's server-side hook:
// frontend/src/hooks.server.js
import { BASE_API_URI } from "$lib/utils/constants";
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
if (event.locals.user) {
// if there is already a user in session load page as normal
return await resolve(event);
}
// get cookies from browser
const session = event.cookies.get("cryptoflow-sessionid");
if (!session) {
// if there is no session load page as normal
return await resolve(event);
}
// find the user based on the session
const res = await event.fetch(`${BASE_API_URI}/users/current`, {
credentials: "include",
headers: {
Cookie: `sessionid=${session}`,
},
});
if (!res.ok) {
// if there is no session load page as normal
return await resolve(event);
}
// if `user` exists set `events.local`
const response = await res.json();
event.locals.user = response;
// load page as normal
return await resolve(event);
}
The server-side handle hook "runs every time the SvelteKit server receives a request and determines the response". Normally, the function takes the event
and resolve
arguments which represent the incoming request and renders the routes alongside its response respectively. The event
object has locals
as one of its properties. We can use the Locals
TypeScript's interface
to hold some data. The data it holds can be accessed and subsequently exposed in the load
functions of +page/layout.server.js/ts
. The comments in frontend/src/hooks.server.js
do enough justice to what it does. To satisfy JsDoc
or TypeScript
requirements, we need to add the user
property to the Locals
interface:
// frontend/src/app.d.ts
interface User {
email: string;
first_name: string;
last_name: string;
id: string;
is_staff: boolean;
is_active: boolean;
thumbnail: string;
is_superuser: boolean;
date_joined: string;
}
...
declare global {
namespace App {
...
interface Locals {
user: User;
}
...
}
}
...
Making the user
available to all routes is our aim. A good place to ensure this is the +layout.server.js
file which can help propagate the user's data.
// frontend/src/routes/+layout.server.js
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ locals }) {
return {
user: locals.user,
};
}
The exposed user
object was what we retrieved from the data
argument in frontend/src/routes/+layout.js
. With that, we can now access the user's data on any page via the data
property of the page
store.
The other components imported in frontend/src/routes/+layout.svelte
are just some simple Tailwind CSS-styled HTML documents.
Step 3: Utility components and functions
In the spirit of keeping most of the tiny details out of the way, we will go through some simple components and functions that will be used in subsequent articles. The first we will see is frontend/src/lib/utils/helpers.js
:
// frontend/src/lib/utils/helpers.js
// @ts-nocheck
import { quintOut } from "svelte/easing";
import { crossfade } from "svelte/transition";
export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
// eslint-disable-next-line no-unused-vars
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: ${transform} scale(${t});
opacity: ${t}
`,
};
},
});
/**
* Validates an email field
* @file lib/utils/helpers/input.validation.ts
* @param {string} email - The email to validate
*/
export const isValidEmail = (email) => {
const EMAIL_REGEX =
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
return EMAIL_REGEX.test(email.trim());
};
/**
* Validates a strong password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordStrong = (password) => {
const strongRegex = new RegExp(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
);
return strongRegex.test(password.trim());
};
/**
* Validates a medium password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordMedium = (password) => {
const mediumRegex = new RegExp(
"^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})"
);
return mediumRegex.test(password.trim());
};
/**
* Test whether or not an object is empty.
* @param {Record<string, string>} obj - The object to test
* @returns `true` or `false`
*/
export function isEmpty(obj) {
for (const _i in obj) {
return false;
}
return true;
}
/**
* Handle all GET requests.
* @file lib/utils/helpers.js
* @param {typeof fetch} sveltekitFetch - Fetch object from sveltekit
* @param {string} targetUrl - The URL whose resource will be fetched.
* @param {RequestCredentials} [credentials='omit'] - Request credential. Defaults to 'omit'.
* @param {'GET' | 'POST'} [requestMethod='GET'] - Request method. Defaults to 'GET'.
* * @param {RequestMode | undefined} [mode='cors'] - Request mode. Defaults to 'GET'.
*/
export const getRequests = async (
sveltekitFetch,
targetUrl,
credentials = "omit",
requestMethod = "GET",
mode = "cors"
) => {
const headers = { "Content-Type": "application/json" };
const requestInitOptions = {
method: requestMethod,
mode: mode,
credentials: credentials,
headers: headers,
};
const res = await sveltekitFetch(targetUrl, requestInitOptions);
return res.ok && (await res.json());
};
/**
* Get coin prices.
* @file lib/utils/helpers.js
* @param {typeof fetch} sveltekitFetch - Fetch object from sveltekit
* @param {string} tags - The tags of the coins to fetch prices for.
* @param {string} currency - The currency to fetch prices in.
*/
export const getCoinsPricesServer = async (sveltekitFetch, tags, currency) => {
const res = await getRequests(
sveltekitFetch,
`/api/crypto/prices?tags=${tags}¤cy=${currency}`
);
return res;
};
/**
* Format price to be more readable.
* @file lib/utils/helpers.js
* @param {number} price - The price to format.
*/
export function formatPrice(price) {
return price.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
const coinSymbols = {
Bitcoin: "BTC",
Ethereum: "ETH",
BNB: "BNB",
Litecoin: "LTC",
Dogecoin: "DOGE",
// Add other coins and their symbols here
};
/**
* Format the coin name to be more readable.
* @file lib/utils/helpers.js
* @param {string} coinName - The coin name to format.
*/
export function formatCoinName(coinName) {
// Format the name by capitalizing the first letter of each word
const formattedName = coinName
.toLowerCase()
.replace(/(?:^|\s)\S/g, (a) => a.toUpperCase());
// Return the formatted name with the coin's symbol (if available)
return `${formattedName} (${coinSymbols[formattedName] || "N/A"})`;
}
export function timeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const secondsAgo = Math.round((now - date) / 1000);
const minutesAgo = Math.round(secondsAgo / 60);
const hoursAgo = Math.round(minutesAgo / 60);
const daysAgo = Math.round(hoursAgo / 24);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (secondsAgo < 60) {
return rtf.format(-secondsAgo, "second");
} else if (minutesAgo < 60) {
return rtf.format(-minutesAgo, "minute");
} else if (hoursAgo < 24) {
return rtf.format(-hoursAgo, "hour");
} else if (daysAgo < 30) {
return rtf.format(-daysAgo, "day");
} else {
// Fallback to a more standard date format
return date.toLocaleDateString();
}
}
Just some random functions for fading animations; email, password and object validations; sending GET
requests; and price (may be removed) and age formatting.
Aside from those functions, there are some other ones in frontend/src/lib/utils/select.custom.js
:
// frontend/src/lib/utils/select.custom.js
/**
* Tag Selection and Suggestion Management.
* Handles the logic for filtering tags based on user input, selecting tags, and displaying selected tags and suggestions.
*/
import { selectedTags } from "$lib/stores/tags.stores";
import { get } from "svelte/store";
/** @type {HTMLInputElement} */
let inputFromOutside;
/**
* Set the input element.
* @file $lib/utils/select.custom.ts
* @param {HTMLInputElement} inputElement - The input element
*/
export function setInputElement(inputElement) {
inputFromOutside = inputElement;
}
// Create a Tag type that has id, name, and symbol properties all of type string in jsdoc
/**
* @typedef {Object} Tag
* @property {string} id
* @property {string} name
* @property {string} symbol
*/
/**
* Filter tags based on user input and display suggestions.
* @file $lib/utils/select.custom.ts
* @param {HTMLInputElement} tagInput - The input element
* @param {Array<Tag>} allTags - All the tags
*/
export function filterTags(tagInput, allTags) {
inputFromOutside = tagInput;
const input = tagInput.value.toLowerCase();
if (input.trim() === "") {
clearSuggestions();
return;
}
let $selectedTags = get(selectedTags);
const suggestions = allTags.filter(
(tag) =>
(tag.id.toLowerCase().includes(input) ||
tag.name.toLowerCase().includes(input)) &&
!$selectedTags.includes(tag.id)
);
displaySuggestions(suggestions);
}
/**
* Select a tag and display it.
* @file $lib/utils/select.custom.ts
* @param {string} tagId - The tag to select
*/
function selectTag(tagId) {
if (!get(selectedTags).includes(tagId)) {
// Add tag to selected tags store
selectedTags.set([...get(selectedTags), tagId]);
displaySelectedTags();
inputFromOutside.value = "";
updateInputPlaceholder();
clearSuggestions();
} else {
// Optional: Provide feedback to the user that the tag is already selected
console.log("Tag already selected");
}
}
/**
* Clear suggestions.
* @file $lib/utils/select.custom.ts
*/
function clearSuggestions() {
const container = document.getElementById("suggestions");
// @ts-ignore
container.innerHTML = ""; // Clear suggestions
}
/**
* Remove a tag from the selected tags.
* @file $lib/utils/select.custom.ts
* @param {string} tagId - The ID of the tag to remove
*/
function removeTag(tagId) {
let $selectedTags = get(selectedTags);
$selectedTags = $selectedTags.filter((t) => t !== tagId);
selectedTags.set($selectedTags);
displaySelectedTags();
updateInputPlaceholder();
}
/**
* Update the input placeholder text based on the number of selected tags.
*/
function updateInputPlaceholder() {
let $selectedTags = get(selectedTags);
if ($selectedTags.length === 4) {
inputFromOutside.disabled = true;
inputFromOutside.placeholder = "Max tags reached";
} else {
inputFromOutside.disabled = false;
inputFromOutside.placeholder = `Add up to ${
4 - $selectedTags.length
} more tags`;
}
}
/**
* Display suggestions to the user.
* @file $lib/utils/select.custom.ts
* @param {Array<Tag>} tags - The tags to display
*/
function displaySuggestions(tags) {
/** @type {HTMLElement} */
// @ts-ignore
const container = document.getElementById("suggestions");
container.innerHTML = ""; // Clear existing suggestions
tags.forEach((tag) => {
const div = document.createElement("div");
div.textContent = tag.name;
div.className = "cursor-pointer p-2 hover:bg-[#145369]";
div.addEventListener("click", () => selectTag(tag.id)); // Attach event listener
container.appendChild(div);
});
}
/**
* Display selected tags to the user.
* @file $lib/utils/select.custom.ts
*/
export function displaySelectedTags() {
const container = document.getElementById("selected-tags");
// @ts-ignore
container.innerHTML = ""; // Clear existing tags
let $selectedTags = get(selectedTags);
$selectedTags.forEach((tag) => {
const span = document.createElement("span");
span.className =
"inline-block bg-[#145369] rounded-full px-3 py-1 text-sm font-semibold text-white mr-2 mb-2";
span.textContent = tag;
const removeSpan = document.createElement("span");
removeSpan.className = "cursor-pointer text-red-500 hover:text-red-600";
removeSpan.textContent = " x";
removeSpan.onclick = () => removeTag(tag); // Attach event listener
span.appendChild(removeSpan);
// @ts-ignore
container.appendChild(span);
});
}
The appended comments say exactly what the file and functions do. There is a custom store introduced there:
// frontend/src/lib/stores/tags.stores.js
import { writable } from "svelte/store";
/** @type {Array<string>} */
let tags = [];
export const selectedTags = writable(tags);
The next things are the simple components. We start with a responsive but simple loader:
<!-- frontend/src/lib/components/Loader.svelte -->
<script>
/** @type {number | null} */
export let width;
/** @type {string | null} */
export let message;
</script>
<div class="loading">
<p class="simple-loader" style={width ? `width: ${width}px` : ''} /> {#if
message}
<p>{message}</p>
{/if}
</div>
<style>
.loading {
display: flex;
align-items: center;
/* justify-content: center; */
}
.loading p {
margin-left: 0.5rem;
}
.simple-loader {
--b: 20px; /* border thickness */
--n: 15; /* number of dashes*/
--g: 7deg; /* gap between dashes*/
--c: #2596be; /* the color */
width: 40px; /* size */
aspect-ratio: 1;
border-radius: 50%;
padding: 1px; /* get rid of bad outlines */
background: conic-gradient(#0000, var(--c)) content-box;
--_m: /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
#0000 0deg,
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
), radial-gradient(farthest-side, #0000 calc(98% - var(--b)), #000 calc(100% -
var(--b)));
-webkit-mask: var(--_m);
mask: var(--_m);
-webkit-mask-composite: destination-in;
mask-composite: intersect;
animation: load 1s infinite steps(var(--n));
}
@keyframes load {
to {
transform: rotate(1turn);
}
}
</style>
Then comes the composable and flexible animated modal:
<!-- frontend/src/lib/components/Modal.svelte -->
<script>
import { quintOut } from 'svelte/easing';
import { createEventDispatcher } from 'svelte';
const modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
const transform = getComputedStyle(node).transform;
return {
duration,
easing: quintOut,
css: (/** @type {any} */ t, /** @type {number} */ u) => {
return `transform:
${transform}
scale(${t})
translateY(${u * -100}%)
`;
}
};
};
const dispatch = createEventDispatcher();
function closeModal() {
dispatch('close', {});
}
</script>
<div class="modal-background">
<div transition:modal={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
<a title="Close" class="modal-close" on:click={closeModal} role="dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 384 512">
<path
d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
/>
</svg>
</a>
<div class="container">
<slot />
</div>
</div>
</div>
<style>
.modal-background {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
}
.modal {
position: absolute;
left: 50%;
top: 50%;
width: 40%;
box-shadow: 0 0 10px hsl(0 0% 0% / 10%);
transform: translate(-50%, -50%);
}
@media (max-width: 990px) {
.modal {
width: 90%;
}
}
.modal-close {
border: none;
}
.modal-close svg {
display: block;
margin-left: auto;
margin-right: auto;
fill: rgb(14 165 233 /1);
transition: all 0.5s;
}
.modal-close:hover svg {
fill: rgb(225 29 72);
transform: scale(1.5);
}
.modal .container {
max-height: 90vh;
overflow-y: auto;
}
@media (min-width: 680px) {
.modal .container {
flex-direction: column;
left: 0;
width: 100%;
}
}
</style>
Last on the list is the error-showing component:
<!-- frontend/src/lib/components/ShowError.svelte -->
<script>
import { receive, send } from '$lib/utils/helpers';
/** @type {any}*/
export let form;
</script>
{#if form?.errors}
<!-- Error Message Display -->
{#each form?.errors as error (error.id)}
<p
class="text-red-500 p-3 text-center mb-4 italic"
in:receive={{ key: error.id }}
out:send={{ key: error.id }}
>
{error.message}
</p>
{/each}
{/if}
With that, we come to the end of the first start of building our application's front end!
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (0)