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:
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.
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:
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:
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:
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:
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:
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.jsimport{BASE_API_URI}from"$lib/utils/constants";/** @type {import('@sveltejs/kit').Handle} */exportasyncfunctionhandle({event,resolve}){if (event.locals.user){// if there is already a user in session load page as normalreturnawaitresolve(event);}// get cookies from browserconstsession=event.cookies.get("cryptoflow-sessionid");if (!session){// if there is no session load page as normalreturnawaitresolve(event);}// find the user based on the sessionconstres=awaitevent.fetch(`${BASE_API_URI}/users/current`,{credentials:"include",headers:{Cookie:`sessionid=${session}`,},});if (!res.ok){// if there is no session load page as normalreturnawaitresolve(event);}// if `user` exists set `events.local`constresponse=awaitres.json();event.locals.user=response;// load page as normalreturnawaitresolve(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:
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.
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-nocheckimport{quintOut}from"svelte/easing";import{crossfade}from"svelte/transition";exportconst[send,receive]=crossfade({duration:(d)=>Math.sqrt(d*200),// eslint-disable-next-line no-unused-varsfallback(node,params){conststyle=getComputedStyle(node);consttransform=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
*/exportconstisValidEmail=(email)=>{constEMAIL_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])?/;returnEMAIL_REGEX.test(email.trim());};/**
* Validates a strong password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/exportconstisValidPasswordStrong=(password)=>{conststrongRegex=newRegExp("^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})");returnstrongRegex.test(password.trim());};/**
* Validates a medium password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/exportconstisValidPasswordMedium=(password)=>{constmediumRegex=newRegExp("^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})");returnmediumRegex.test(password.trim());};/**
* Test whether or not an object is empty.
* @param {Record<string, string>} obj - The object to test
* @returns `true` or `false`
*/exportfunctionisEmpty(obj){for (const_iinobj){returnfalse;}returntrue;}/**
* 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'.
*/exportconstgetRequests=async (sveltekitFetch,targetUrl,credentials="omit",requestMethod="GET",mode="cors")=>{constheaders={"Content-Type":"application/json"};constrequestInitOptions={method:requestMethod,mode:mode,credentials:credentials,headers:headers,};constres=awaitsveltekitFetch(targetUrl,requestInitOptions);returnres.ok&&(awaitres.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.
*/exportconstgetCoinsPricesServer=async (sveltekitFetch,tags,currency)=>{constres=awaitgetRequests(sveltekitFetch,`/api/crypto/prices?tags=${tags}¤cy=${currency}`);returnres;};/**
* Format price to be more readable.
* @file lib/utils/helpers.js
* @param {number} price - The price to format.
*/exportfunctionformatPrice(price){returnprice.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2,});}constcoinSymbols={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.
*/exportfunctionformatCoinName(coinName){// Format the name by capitalizing the first letter of each wordconstformattedName=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"})`;}exportfunctiontimeAgo(dateString){constdate=newDate(dateString);constnow=newDate();constsecondsAgo=Math.round((now-date)/1000);constminutesAgo=Math.round(secondsAgo/60);consthoursAgo=Math.round(minutesAgo/60);constdaysAgo=Math.round(hoursAgo/24);constrtf=newIntl.RelativeTimeFormat("en",{numeric:"auto"});if (secondsAgo<60){returnrtf.format(-secondsAgo,"second");}elseif (minutesAgo<60){returnrtf.format(-minutesAgo,"minute");}elseif (hoursAgo<24){returnrtf.format(-hoursAgo,"hour");}elseif (daysAgo<30){returnrtf.format(-daysAgo,"day");}else{// Fallback to a more standard date formatreturndate.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} */letinputFromOutside;/**
* Set the input element.
* @file $lib/utils/select.custom.ts
* @param {HTMLInputElement} inputElement - The input element
*/exportfunctionsetInputElement(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
*/exportfunctionfilterTags(tagInput,allTags){inputFromOutside=tagInput;constinput=tagInput.value.toLowerCase();if (input.trim()===""){clearSuggestions();return;}let$selectedTags=get(selectedTags);constsuggestions=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
*/functionselectTag(tagId){if (!get(selectedTags).includes(tagId)){// Add tag to selected tags storeselectedTags.set([...get(selectedTags),tagId]);displaySelectedTags();inputFromOutside.value="";updateInputPlaceholder();clearSuggestions();}else{// Optional: Provide feedback to the user that the tag is already selectedconsole.log("Tag already selected");}}/**
* Clear suggestions.
* @file $lib/utils/select.custom.ts
*/functionclearSuggestions(){constcontainer=document.getElementById("suggestions");// @ts-ignorecontainer.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
*/functionremoveTag(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.
*/functionupdateInputPlaceholder(){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
*/functiondisplaySuggestions(tags){/** @type {HTMLElement} */// @ts-ignoreconstcontainer=document.getElementById("suggestions");container.innerHTML="";// Clear existing suggestionstags.forEach((tag)=>{constdiv=document.createElement("div");div.textContent=tag.name;div.className="cursor-pointer p-2 hover:bg-[#145369]";div.addEventListener("click",()=>selectTag(tag.id));// Attach event listenercontainer.appendChild(div);});}/**
* Display selected tags to the user.
* @file $lib/utils/select.custom.ts
*/exportfunctiondisplaySelectedTags(){constcontainer=document.getElementById("selected-tags");// @ts-ignorecontainer.innerHTML="";// Clear existing tagslet$selectedTags=get(selectedTags);$selectedTags.forEach((tag)=>{constspan=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;constremoveSpan=document.createElement("span");removeSpan.className="cursor-pointer text-red-500 hover:text-red-600";removeSpan.textContent=" x";removeSpan.onclick=()=>removeTag(tag);// Attach event listenerspan.appendChild(removeSpan);// @ts-ignorecontainer.appendChild(span);});}
The appended comments say exactly what the file and functions do. There is a custom store introduced there:
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} */exportletwidth;/** @type {string | null} */exportletmessage;</script><divclass="loading"><pclass="simple-loader"style={width?`width:${width}px`:''}/> {#if
message}
<p>{message}</p>
{/if}
</div><style>.loading{display:flex;align-items:center;/* justify-content: center; */}.loadingp{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(#00000deg,#0001degcalc(360deg/var(--n)-var(--g)-1deg),#0000calc(360deg/var(--n)-var(--g))calc(360deg/var(--n))),radial-gradient(farthest-side,#0000calc(98%-var(--b)),#000calc(100%-var(--b)));-webkit-mask:var(--_m);mask:var(--_m);-webkit-mask-composite:destination-in;mask-composite:intersect;animation:load1sinfinitesteps(var(--n));}@keyframesload{to{transform:rotate(1turn);}}</style>
Then comes the composable and flexible animated modal:
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!