CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 5
Tags: webdev, javascript, svelte, sveltekit
Published: 2024-01-22 19:15:45

## Introduction

From [part 0][0] to [part 4][1], 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][2], a framework that streamlines web development, and [TailwindCSS][3], the utility-first CSS framework. Let's dig in!

## Source code

The source code for this series is hosted on GitHub via:

{% github Sirneij/cryptoflow %}

## Implementation

### Step 1: Set up the frontend app

To ease the entire process, kindly head on to [creating a project][4]. I named this `frontend`. After that, proceed to [install tailwind CSS with sveltekit][5]. 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:

```json
...
"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`:

```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][3] 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][3]. That said, let's build out how the general outlook of the app will be. Open up `frontend/src/routes/+layout.svelte`:

```html
<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][5]. Others were components written. I won't show codes for all the components, however, the `Transition` component looks like this:

```html
<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`:

```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][6] method of SvelteKit's server-side hook:

```js
// 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][6] 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:

```ts
// 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.

```js
// 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`][7] 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`:

```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}&currency=${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`:

```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:

```js
// 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:

```html
<!-- 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:

```html
<!-- 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:

```html
<!-- 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](https://www.linkedin.com/in/idogun-john-nelson/) and Twitter: [Twitter](https://twitter.com/Sirneij).

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

[0]: https://dev.to/sirneij/cryptoflow-building-a-secure-and-scalable-system-with-axum-and-sveltekit-part-0-mn5 "CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 0"
[1]: https://dev.to/sirneij/cryptoflow-building-a-secure-and-scalable-system-with-axum-and-sveltekit-part-1-2mnn "CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 1"
[2]: https://kit.svelte.dev/ "web development, streamlined"
[3]: https://tailwindcss.com/ "A utility-first CSS framework packed with classes"
[4]: https://kit.svelte.dev/docs/creating-a-project "Creating a project"
[5]: https://tailwindcss.com/docs/guides/sveltekit "Install Tailwind CSS with SvelteKit"
[6]: https://kit.svelte.dev/docs/hooks#server-hooks-handle "Hooks"
[7]: https://kit.svelte.dev/docs/modules#$app-stores-page "Page store"