DEV Community

Cover image for CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 6
John Owolabi Idogun
John Owolabi Idogun

Posted on

CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 6

Introduction

It's been a while since I last updated this series of articles. I have been away, and I sincerely apologize for the abandonment. I will be completing the series by going through the frontend code and other updates I made at the backend. Let's get into it!

Source code

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

GitHub logo 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.






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.

Implementation

Step 1: Landing page

Our application will have a landing page where questions are listed. The page will be split into three resizable columns:

  1. Left column: This will contain developer information and some coins with their rankings. The number of coins at a time will be specified by the NUM_OF_COINS_TO_SHOW constant which is 10 by default but can be made configurable. Every 10 seconds, the list will change.

  2. Middle column: A list of questions will be housed here.

  3. Right column: We will have some charts for plotting the prices, market caps, and total volumes of any selected coin. Making a total of three multi-line charts. Each line corresponds to each of the coins. We will provide users with two inputs where they can select up to 4 coins at a time, and the number of days they want their histories to be shown.

These entire requirements are implemented in frontend/src/routes/+page.svelte:

<script>
  import Charts from "$lib/components/Charts.svelte";
  import { NUM_OF_COINS_TO_SHOW } from "$lib/utils/constants.js";
  import { onDestroy, onMount } from "svelte";

  export let data,
    /** @type {import('./$types').ActionData} */
    form;

  /**
   * @typedef {Object} Coin
   * @property {string} id - The id of the coin.
   * @property {string} name - The name of the coin.
   * @property {string} symbol - The symbol of the coin.
   * @property {string} image - The image of the coin.
   * @property {number} market_cap_rank - The market cap rank of the coin.
   */

  /**
   * @type {Coin[]}
   */
  let selectedCoins = [],
    /** @type {Number} */
    intervalId;

  $: ({ questions, coins } = data);

  const selectCoins = () => {
    const selectedCoinsSet = new Set();
    while (selectedCoinsSet.size < NUM_OF_COINS_TO_SHOW) {
      const randomIndex = Math.floor(Math.random() * coins.length);
      selectedCoinsSet.add(coins[randomIndex]);
    }
    selectedCoins = Array.from(selectedCoinsSet);
  };

  onMount(() => {
    selectCoins(); // Select coins immediately on mount
    intervalId = setInterval(selectCoins, 10000); // Select coins every 10 seconds
  });

  onDestroy(() => {
    clearInterval(intervalId); // Clear the interval when the component is destroyed
  });
</script>

<div class="flex flex-col md:flex-row text-[#efefef]">
  <!-- Left Column for Tags -->
  <div class="hidden md:block md:w-1/4 p-4 resize overflow-auto">
    <!-- Developer Profile Card -->
    <div
      class="bg-[#041014] hover:bg-black border border-black hover:border-[#145369] rounded-lg shadow p-4 mb-1"
    >
      <img
        src="https://media.licdn.com/dms/image/D4D03AQElygM4We8kqA/profile-displayphoto-shrink_800_800/0/1681662853733?e=1721865600&v=beta&t=idb1YHHzZbXHJ1MxC4Ol2ZnnbyCHq6GDtjzTzGkziLQ"
        alt="Developer"
        class="rounded-full w-24 h-24 mx-auto mb-3"
      />
      <h3 class="text-center text-xl font-bold mb-2">John O. Idogun</h3>
      <a
        href="https://github.com/sirneij"
        class="text-center text-blue-500 block mb-2"
      >
        @SirNeij
      </a>
      <p class="text-center">Developer & Creator of CryptoFlow</p>
    </div>
    <div
      class="bg-[#041014] p-6 rounded-lg shadow mb-6 hover:bg-black border border-black hover:border-[#145369]"
    >
      <h2 class="text-xl font-semibold mb-4">Coin ranks</h2>
      {#each selectedCoins as coin (coin.id)}
      <div
        class="flex items-center justify-between mb-2 border-b border-[#0a0a0a] hover:bg-[#041014] px-3 py-1"
      >
        <div class="flex items-center">
          <img
            class="w-8 h-8 rounded-full mr-2 transition-transform duration-500 ease-in-out transform hover:rotate-180"
            src="{coin.image}"
            alt="{coin.name}"
          />
          <span class="mr-2">{coin.name}</span>
        </div>
        <span
          class="inline-block bg-blue-500 text-white text-xs px-2 rounded-full uppercase font-semibold tracking-wide"
        >
          #{coin.market_cap_rank}
        </span>
      </div>
      {/each}
    </div>
  </div>

  <div class="md:w-5/12 py-4 px-2 resize overflow-auto">
    {#if questions} {#each questions as question (question.id)}
    <div
      class="
                bg-[#041014] mb-1 rounded-lg shadow hover:bg-black border border-black hover:border-[#145369]"
    >
      <div class="p-4">
        <a
          href="/questions/{question.id}"
          class="text-xl font-semibold hover:text-[#2596be]"
        >
          {question.title}
        </a>
        <!-- <p class="mt-2">{article.description}</p> -->
        <div class="mt-3 flex flex-wrap">
          {#each question.tags as tag}
          <span
            class="mr-2 mb-2 px-3 py-1 text-sm bg-[#041014] border border-[#145369] hover:border-[#2596be] rounded"
          >
            {tag.name}
          </span>
          {/each}
        </div>
      </div>
    </div>
    {/each} {/if}
  </div>

  <!-- Right Column for Charts -->
  <div class="hidden md:block md:w-1/3 px-2 py-4 resize overflow-auto">
    <div
      class="bg-[#041014] rounded-lg shadow p-4 hover:bg-black border border-black hover:border-[#145369]"
    >
      <h2 class="text-xl font-semibold mb-4">Charts</h2>
      <Charts {coins} {form} />
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

To select 10 unique coins every 10 seconds, we randomly get them from the coins data and use Set to ensure no duplication is permitted. This is what selectCoins is about. As the DOM gets loaded, we call this function and then use setInterval for the periodic and automatic selection. We also ensure the interval is destroyed when we navigate out of the page for memory safety reasons.

For the charts, there is a component, Charts, that handles the logic:

<!-- frontend/src/lib/components/Charts.svelte -->
<script>
    import { applyAction, enhance } from '$app/forms';
    import { notification } from '$lib/stores/notification.store';
    import ShowError from './ShowError.svelte';
    import Loader from './Loader.svelte';
    import { fly } from 'svelte/transition';
    import { onMount } from 'svelte';
    import Chart from 'chart.js/auto';
    import 'chartjs-adapter-moment';
    import { chartConfig, handleZoom } from '$lib/utils/helpers';
    import TagCoin from './inputs/TagCoin.svelte';

    export let coins,
        /** @type {import('../../routes/$types').ActionData} */
        form;

    /** @type {HTMLInputElement} */
    let tagInput,
        /** @type {HTMLCanvasElement} */
        priceChartContainer,
        /** @type {HTMLCanvasElement} */
        marketCapChartContainer,
        /** @type {HTMLCanvasElement} */
        totalVolumeChartContainer,
        fetching = false,
        rendered = false,
        /**
         * @typedef {Object} CryptoData
         * @property {Array<Number>} prices - The price data
         * @property {Array<Number>} market_caps - The market cap data
         * @property {Array<Number>} total_volumes - The total volume data
         */

        /**
         * @typedef {Object.<String, CryptoData>} CryptoDataSet
         */

        /** @type {CryptoDataSet} */
        plotData = {},
        /** @type {CanvasRenderingContext2D | null} */

        context,
        /** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */
        priceChart,
        /** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */
        marketCapChart,
        /** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */
        totalVolumeChart,
        /** @type {CanvasRenderingContext2D|null} */
        priceContext,
        /** @type {CanvasRenderingContext2D|null} */
        marketCapContext,
        /** @type {CanvasRenderingContext2D|null} */
        totalVolumeContext;

    /** @type {import('../../routes/$types').SubmitFunction}*/
    const handleCoinDataFetch = async () => {
        fetching = true;
        return async ({ result }) => {
            fetching = false;
            if (result.type === 'success') {
                $notification = { message: 'Coin data fetched successfully', colorName: 'blue' };

                if (result.data) {
                    plotData = result.data.marketData;
                    await applyAction(result);
                }
            }
        };
    };

    onMount(() => {
        priceContext = priceChartContainer.getContext('2d');
        marketCapContext = marketCapChartContainer.getContext('2d');
        totalVolumeContext = totalVolumeChartContainer.getContext('2d');

        if (priceContext === null || marketCapContext === null || totalVolumeContext === null) {
            throw new Error('Could not get the context of the canvas element');
        }

        // Create a new configuration object for each chart
        const priceChartConfig = { ...chartConfig };
        priceChartConfig.data = { datasets: [] };
        priceChart = new Chart(priceContext, priceChartConfig);

        const marketCapChartConfig = { ...chartConfig };
        marketCapChartConfig.data = { datasets: [] };
        marketCapChart = new Chart(marketCapContext, marketCapChartConfig);

        const totalVolumeChartConfig = { ...chartConfig };
        totalVolumeChartConfig.data = { datasets: [] };
        totalVolumeChart = new Chart(totalVolumeContext, totalVolumeChartConfig);

        rendered = true;

        // Add event listeners for zooming
        priceChartContainer.addEventListener('wheel', (event) => handleZoom(event, priceChart));
        marketCapChartContainer.addEventListener('wheel', (event) => handleZoom(event, marketCapChart));
        totalVolumeChartContainer.addEventListener('wheel', (event) =>
            handleZoom(event, totalVolumeChart)
        );
    });

    /**
     * Update the chart with new data
     * @param {Chart<"line", { x: Date; y: number; }[], unknown>} chart - The chart to update
     * @param {Array<Array<number>>} data - The new data to update the chart with
     * @param {string} label - The label to use for the dataset
     * @param {string} cryptoName - The name of the cryptocurrency
     */
    const updateChart = (chart, data, label, cryptoName) => {
        const dataset = {
            label: `${cryptoName} ${label}`,
            data: data.map(
                /** @param {Array<number>} item */
                (item) => {
                    return {
                        x: new Date(item[0]),
                        y: item[1]
                    };
                }
            ),
            fill: false,
            borderColor: '#' + Math.floor(Math.random() * 16777215).toString(16),
            tension: 0.1
        };

        chart.data.datasets.push(dataset);
        chart.update();
    };

    $: if (rendered) {
        // Clear the datasets for each chart
        priceChart.data.datasets = [];
        marketCapChart.data.datasets = [];
        totalVolumeChart.data.datasets = [];

        Object.keys(plotData).forEach(
            /** @param {string} cryptoName */
            (cryptoName) => {
                // Update each chart with the new data
                updateChart(priceChart, plotData[cryptoName].prices, 'Price', cryptoName);
                updateChart(marketCapChart, plotData[cryptoName].market_caps, 'Market Cap', cryptoName);
                updateChart(
                    totalVolumeChart,
                    plotData[cryptoName].total_volumes,
                    'Total Volume',
                    cryptoName
                );
            }
        );
    }
</script>

<form action="?/getCoinData" method="POST" use:enhance={handleCoinDataFetch}>
    <ShowError {form} />
    <div style="display: flex; justify-content: space-between;">
        <div style="flex: 2; margin-right: 10px;">
            <TagCoin
                label="Cryptocurrencies"
                id="tag-input"
                name="tags"
                value=""
                {coins}
                placeholder="Select cryptocurrencies..."
            />
        </div>
        <div style="flex: 1; margin-left: 10px;">
            <label for="days" class="block text-[#efefef] text-sm font-bold mb-2">Days</label>
            <input
                type="number"
                id="days"
                name="days"
                value="7"
                required
                class="w-full p-4 bg-[#0a0a0a] text-[#efefef] border border-[#145369] rounded focus:outline-none focus:border-[#2596be] text-gray-500"
                placeholder="Enter days"
            />
        </div>
    </div>
    {#if fetching}
        <Loader width={20} message="Fetching data..." />
    {:else}
        <button
            class="px-6 py-2 bg-[#041014] border border-[#145369] hover:border-[#2596be] text-[#efefef] hover:text-white rounded"
        >
            Fetch Coin Data
        </button>
    {/if}
</form>

<div in:fly={{ x: 100, duration: 1000, delay: 1000 }} out:fly={{ duration: 1000 }}>
    <canvas bind:this={priceChartContainer} />
    <canvas bind:this={marketCapChartContainer} />
    <canvas bind:this={totalVolumeChartContainer} />
</div>
Enter fullscreen mode Exit fullscreen mode

We employed Charts.js as the charting library. It's largely simple to use. Though the component looks big, it's very straightforward. We used JSDocs instead of TypeScript for annotations. At first, when the DOM was mounted, we created charts with empty datasets. We then expect users to select their preferred coins and number of days. Clicking the Fetch Coin Data button will send the inputted data to the backend using SvelteKit's form actions. The data returned by this API call will be used to populate the plots using Svelte's reactive block dynamically. The code for the form action and the preliminary data retrieval from the backend is in frontend/src/routes/+page.server.js:

import { BASE_API_URI } from "$lib/utils/constants";
import { fail } from "@sveltejs/kit";

/** @type {import('./$types').PageServerLoad} */
export async function load({ fetch }) {
  const fetchQuestions = async () => {
    const res = await fetch(`${BASE_API_URI}/qa/questions`);
    return res.ok && (await res.json());
  };

  const fetchCoins = async () => {
    const res = await fetch(`${BASE_API_URI}/crypto/coins`);
    return res.ok && (await res.json());
  };

  const questions = await fetchQuestions();
  const coins = await fetchCoins();

  return {
    questions,
    coins,
  };
}

// Get coin data form action

/** @type {import('./$types').Actions} */
export const actions = {
  /**
   * Get coin market history data from the API
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  getCoinData: async ({ request, fetch }) => {
    const data = await request.formData();
    const coinIDs = String(data.get("tags"));
    const days = Number(data.get("days"));
    const res = await fetch(
      `${BASE_API_URI}/crypto/coin_prices?tags=${coinIDs}&currency=USD&days=${days}`
    );
    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    const response = await res.json();

    return {
      status: 200,
      marketData: response,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

The endpoint used here, ${BASE_API_URI}/crypto/coin_prices?tags=${coinIDs}&currency=USD&days=${days}, was just created and the code is:

// backend/src/routes/crypto/prices.rs

use crate::{
    settings,
    utils::{CustomAppError, CustomAppJson},
};
use axum::extract::Query;
use std::collections::HashMap;

#[derive(serde::Deserialize, Debug)]
pub struct CoinMarketDataRequest {
    tags: String,
    currency: String,
    days: i32,
}

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct CoinMarketData {
    prices: Vec<Vec<f64>>,
    market_caps: Vec<Vec<f64>>,
    total_volumes: Vec<Vec<f64>>,
}

#[axum::debug_handler]
#[tracing::instrument(name = "get_coin_market_data")]
pub async fn get_coin_market_data(
    Query(coin_req): Query<CoinMarketDataRequest>,
) -> Result<CustomAppJson<HashMap<String, CoinMarketData>>, CustomAppError> {
    let tag_ids: Vec<String> = coin_req.tags.split(',').map(|s| s.to_string()).collect();
    let mut responses = HashMap::new();
    let settings = settings::get_settings().expect("Failed to get settings");
    for tag_id in tag_ids {
        let url = format!(
            "{}/coins/{}/market_chart?vs_currency={}&days={}",
            settings.coingecko.api_url, &tag_id, coin_req.currency, coin_req.days
        );
        match reqwest::get(&url).await {
            Ok(response) => match response.json::<CoinMarketData>().await {
                Ok(data) => {
                    responses.insert(tag_id, data);
                }
                Err(e) => {
                    tracing::error!("Failed to parse market data from response: {}", e);
                }
            },
            Err(e) => {
                tracing::error!("Failed to fetch market data from CoinGecko: {}", e);
            }
        }
    }

    Ok(CustomAppJson(responses))
}
Enter fullscreen mode Exit fullscreen mode

It simply uses CoinGecko's API to retrieve the history data of the coins since days ago. Back to the frontend code, SvelteKit version 2 made some changes that mandate explicitly awaiting asynchronous functions in load. This and other changes will be pointed out as the series progresses. Our load fetches both the questions and coins from the backend. No pagination is implemented here but it's easy to implement with sqlx. Pagination can also be done easily with sveltekit. You can take that up as a challenge.

The Charts.svelte components used some custom input components. This is simply for modularity's sake and is just simple HTML elements with tailwind CSS. Also, it used chartConfig and handleZoom. The former is just a simple configuration for the entire charts while the latter just allows simple zoom in and out of the plots. For better zooming and panning features, it's recommended to use the chartjs-plugin-zoom.

With all these in place, the landing page should look like this:

Application's home page

Step 2: Question Detail page

The middle column on the landing page shows all the questions in the database. We need a page that zooms in on each question so that other users can provide answers. We have such a page in frontend/src/routes/questions/[id]/+page.svelte:

<script>
    import { applyAction, enhance } from '$app/forms';
    import { page } from '$app/stores';
    import Logo from '$lib/assets/logo.png';
    import {
        formatCoinName,
        formatPrice,
        getCoinsPricesServer,
        highlightCodeBlocks,
        timeAgo
    } from '$lib/utils/helpers.js';
    import { afterUpdate, onMount } from 'svelte';
    import Loader from '$lib/components/Loader.svelte';
    import { scale } from 'svelte/transition';
    import { flip } from 'svelte/animate';
    import Modal from '$lib/components/Modal.svelte';
    import hljs from 'highlight.js';
    import ShowError from '$lib/components/ShowError.svelte';
    import { notification } from '$lib/stores/notification.store.js';
    import 'highlight.js/styles/night-owl.css';
    import TextArea from '$lib/components/inputs/TextArea.svelte';

    export let data;

    /** @type {import('./$types').ActionData} */
    export let form;
    /** @type {Array<{"name": String, "price": number}>} */
    let coinPrices = [],
        processing = false,
        showDeleteModal = false,
        showEditModal = false,
        answerID = '',
        answerContent = '';

    $: ({ question, answers } = data);

    const openModal = (isDelete = true) => {
        if (isDelete) {
            showDeleteModal = true;
        } else {
            showEditModal = true;
        }
    };

    const closeModal = () => {
        showDeleteModal = false;
        showEditModal = false;
    };

    /** @param {String} id */
    const setAnswerID = (id) => (answerID = id);
    /** @param {String} content */
    const setAnswerContent = (content) => (answerContent = content);

    onMount(async () => {
        highlightCodeBlocks(hljs);
        if (question) {
            const tagsString = question.tags
                .map(
                    /** @param {{"id": String}} tag */
                    (tag) => tag.id
                )
                .join(',');
            coinPrices = await getCoinsPricesServer($page.data.fetch, tagsString, 'usd');
        }
    });

    afterUpdate(() => {
        highlightCodeBlocks(hljs);
    });

    /** @type {import('./$types').SubmitFunction} */
    const handleAnswerQuestion = async () => {
        processing = true;
        return async ({ result }) => {
            processing = false;
            if (result.type === 'success') {
                if (result.data && 'answer' in result.data) {
                    answers = [result.data.answer, ...answers];
                    answerContent = '';
                    notification.set({ message: 'Answer posted successfully', colorName: 'blue' });
                }
            }
            await applyAction(result);
        };
    };

    /** @type {import('./$types').SubmitFunction} */
    const handleDeleteAnswer = async () => {
        return async ({ result }) => {
            closeModal();
            if (result.type === 'success') {
                answers = answers.filter(
                    /** @param {{"id": String}} answer */
                    (answer) => answer.id !== answerID
                );
                notification.set({ message: 'Answer deleted successfully', colorName: 'blue' });
            }
            await applyAction(result);
        };
    };

    /** @type {import('./$types').SubmitFunction} */
    const handleUpdateAnswer = async () => {
        return async ({ result }) => {
            closeModal();
            if (result.type === 'success') {
                answers = answers.map(
                    /** @param {{"id": String}} answer */
                    (answer) => {
                        if (result.data && 'answer' in result.data) {
                            return answer.id === answerID ? result.data.answer : answer;
                        }
                        return answer;
                    }
                );
                answerContent = '';
                notification.set({ message: 'Answer updated successfully', colorName: 'blue' });
            }
            await applyAction(result);
        };
    };
</script>

<div class="max-w-5xl mx-auto p-4">
    <!-- Stats Section -->
    <div class="bg-[#0a0a0a] p-6 rounded-lg shadow mb-6 flex justify-between items-center">
        <p>Asked: {timeAgo(question.created_at)}</p>
        <p>Modified: {timeAgo(question.updated_at)}</p>
    </div>
    <div class="grid grid-cols-1 md:grid-cols-12 gap-4">
        <!-- Main Content -->
        <div class="md:col-span-9">
            <!-- Question Section -->
            <div class="bg-[#041014] p-6 rounded-lg shadow mb-6 border border-black">
                <h1 class="text-2xl font-bold mb-4">{question.title}</h1>
                <p>{@html question.content}</p>
                <div class="flex mt-4 flex-wrap">
                    {#each question.tags as tag}
                        <span
                            class="mr-2 mb-2 px-3 py-1 text-sm bg-[#041014] border border-[#145369] hover:border-[#2596be] rounded"
                        >
                            {tag.name.toLowerCase()}
                        </span>
                    {/each}
                </div>
                <div class="flex justify-end mt-4">
                    {#if $page.data.user && question.author.id === $page.data.user.id}
                        <a
                            class="mr-2 text-blue-500 hover:text-blue-600"
                            href="/questions/{question.id}/update"
                        >
                            Edit
                        </a>
                        <a class="mr-2 text-red-500 hover:text-red-600" href="/questions/{question.id}/delete">
                            Delete
                        </a>
                    {/if}
                </div>
                <hr class="my-4" />
                <div class="flex justify-end items-center">
                    <span class="mr-3">
                        {question.author.first_name + ' ' + question.author.last_name}
                    </span>
                    <img
                        src={question.author.thumbnail ? question.author.thumbnail : Logo}
                        alt={question.author.first_name + ' ' + question.author.last_name}
                        class="h-10 w-10 rounded-full"
                    />
                </div>
            </div>

            <!-- Answers Section -->
            <h2 class="text-xl font-bold mb-4">Answers</h2>
            {#each answers as answer (answer.id)}
                <div
                    class="bg-[#041014] p-6 rounded-lg shadow mb-4"
                    transition:scale|local={{ start: 0.4 }}
                    animate:flip={{ duration: 200 }}
                >
                    <p>{@html answer.content}</p>

                    <div class="flex justify-end mt-4">
                        {#if $page.data.user && answer.author.id === $page.data.user.id}
                            <button
                                class="mr-2 text-blue-500 hover:text-blue-600"
                                on:click={() => {
                                    openModal(false);
                                    setAnswerID(answer.id);
                                    setAnswerContent(answer.raw_content);
                                }}
                            >
                                Edit
                            </button>
                            <button
                                class="mr-2 text-red-500 hover:text-red-600"
                                on:click={() => {
                                    openModal();
                                    setAnswerID(answer.id);
                                }}
                            >
                                Delete
                            </button>
                        {/if}
                    </div>
                    <hr class="my-4" />
                    <div class="flex justify-end items-center">
                        <span class="mr-3">{answer.author.first_name + ' ' + answer.author.last_name}</span>
                        <img
                            src={answer.author.thumbnail ? answer.author.thumbnail : Logo}
                            alt={answer.author.first_name + ' ' + answer.author.last_name}
                            class="h-10 w-10 rounded-full"
                        />
                    </div>
                </div>
            {:else}
                <div class="bg-[#041014] p-6 rounded-lg shadow mb-4">
                    <p>No answers yet.</p>
                </div>
            {/each}

            <!-- Post Answer Section -->
            <form
                class="bg-[#041014] p-6 rounded-lg shadow"
                method="POST"
                action="?/answer"
                use:enhance={handleAnswerQuestion}
            >
                <h2 class="text-xl font-bold mb-4">Your Answer</h2>
                <ShowError {form} />

                <TextArea
                    label=""
                    id="answer"
                    name="content"
                    placeholder="Write your answer here (markdown supported)..."
                    bind:value={answerContent}
                />

                {#if processing}
                    <Loader width={20} message="Posting your answer..." />
                {:else}
                    <button
                        class="mt-4 px-6 py-2 bg-[#041014] border border-[#145369] hover:border-[#2596be] text-white rounded"
                    >
                        {#if $page.data.user && $page.data.user.id === question.author.id}
                            Answer your question
                        {:else}
                            Post Your Answer
                        {/if}
                    </button>
                {/if}
            </form>
        </div>

        <!-- Right Sidebar -->
        <div class="md:col-span-3">
            <h2 class="text-xl font-semibold mb-4">Current prices</h2>
            <div
                class="bg-[#041014] rounded-lg shadow p-4 hover:bg-black border border-black hover:border-[#145369]"
            >
                <div class="space-y-4">
                    {#each coinPrices as coin (coin.name)}
                        <div
                            class="bg-[#145369] p-3 rounded-lg text-center"
                            transition:scale|local={{ start: 0.4 }}
                            animate:flip={{ duration: 200 }}
                        >
                            <p class="text-3xl font-bold">
                                <span class="text-base">$</span>{formatPrice(coin.price)}
                            </p>
                            {#if question.tags.find(/** @param {{"id": String}} tag */ (tag) => tag.id === coin.name)}
                                <div class="flex items-center text-lg">
                                    <img
                                        class="w-8 h-8 rounded-full mr-2 transition-transform duration-500 ease-in-out transform hover:rotate-180"
                                        src={question.tags.find(
                                            /** @param {{"id": String}} tag */
                                            (tag) => tag.id === coin.name
                                        ).image}
                                        alt={coin.name}
                                    />
                                    <span class="mr-2">
                                        {formatCoinName(
                                            coin.name,
                                            question.tags.find(
                                                /** @param {{"id": String}} tag */
                                                (tag) => tag.id === coin.name
                                            ).symbol
                                        )}
                                    </span>
                                </div>
                            {/if}
                        </div>
                    {/each}
                </div>
            </div>
        </div>
    </div>
</div>

{#if showDeleteModal}
    <Modal on:close={closeModal}>
        <form
            class="bg-[#041014] p-6 rounded-lg shadow text-center"
            method="POST"
            action="?/deleteAnswer"
            use:enhance={handleDeleteAnswer}
        >
            <ShowError {form} />
            <p class="text-red-500 p-3 mb-4 italic">
                Are you sure you want to delete this answer (id={answerID})
            </p>
            <input type="hidden" name="answerID" value={answerID} />
            <button
                class="mt-4 px-6 py-2 bg-[#041014] border border-red-400 hover:border-red-700 text-red-600 rounded"
            >
                Delete Answer
            </button>
        </form>
    </Modal>
{/if}

{#if showEditModal}
    <Modal on:close={closeModal}>
        <form
            class="bg-[#041014] p-6 rounded-lg shadow text-center"
            method="POST"
            action="?/updateAnswer"
            use:enhance={handleUpdateAnswer}
        >
            <ShowError {form} />
            <input type="hidden" name="answerID" value={answerID} />
            <textarea
                class="w-full p-4 bg-[#0a0a0a] text-[#efefef] border border-[#145369] rounded focus:border-[#2596be] focus:outline-none"
                rows="6"
                bind:value={answerContent}
                name="content"
                placeholder="Write your answer here (markdown supported)..."
            />
            <button
                class="mt-4 px-6 py-2 bg-[#041014] border border-[#145369] hover:border-[#2596be] text-white rounded"
            >
                Update Answer
            </button>
        </form>
    </Modal>
{/if}
Enter fullscreen mode Exit fullscreen mode

It has two columns:

  1. The first shows the question and all the answers to that question.
  2. The second shows the current price of the coin tagged in the question. The prices do not get updated live or in real time, you need to refresh the page for updated prices but this can be improved using web sockets.

This page has an accompanying +page.server.js that fetches the data the page uses and handles other subsequent interactions such as posting, updating, and deleting answers:

import { BASE_API_URI } from "$lib/utils/constants";
import { fail } from "@sveltejs/kit";

/** @type {import('./$types').PageServerLoad} */
export async function load({ fetch, params }) {
  const fetchQuestion = async () => {
    const res = await fetch(`${BASE_API_URI}/qa/questions/${params.id}`);
    return res.ok && (await res.json());
  };

  const fetchAnswers = async () => {
    const res = await fetch(
      `${BASE_API_URI}/qa/questions/${params.id}/answers`
    );
    return res.ok && (await res.json());
  };

  return {
    question: await fetchQuestion(),
    answers: await fetchAnswers(),
  };
}

/** @type {import('./$types').Actions} */
export const actions = {
  /**
   *
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  answer: async ({ request, fetch, params, cookies }) => {
    const data = await request.formData();
    const content = String(data.get("content"));

    /** @type {RequestInit} */
    const requestInitOptions = {
      method: "POST",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        Cookie: `sessionid=${cookies.get("cryptoflow-sessionid")}`,
      },
      body: JSON.stringify({
        content: content,
      }),
    };

    const res = await fetch(
      `${BASE_API_URI}/qa/answer/${params.id}`,
      requestInitOptions
    );

    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    const response = await res.json();

    return {
      status: 200,
      answer: response,
    };
  },
  /**
   *
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  deleteAnswer: async ({ request, fetch, cookies }) => {
    const data = await request.formData();
    const answerID = String(data.get("answerID"));

    /** @type {RequestInit} */
    const requestInitOptions = {
      method: "DELETE",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        Cookie: `sessionid=${cookies.get("cryptoflow-sessionid")}`,
      },
    };

    const res = await fetch(
      `${BASE_API_URI}/qa/answers/${answerID}`,
      requestInitOptions
    );

    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    return {
      status: res.status,
    };
  },
  /**
   *
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  updateAnswer: async ({ request, fetch, cookies }) => {
    const data = await request.formData();
    const answerID = String(data.get("answerID"));
    const content = String(data.get("content"));

    /** @type {RequestInit} */
    const requestInitOptions = {
      method: "PATCH",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        Cookie: `sessionid=${cookies.get("cryptoflow-sessionid")}`,
      },
      body: JSON.stringify({
        content: content,
      }),
    };

    const res = await fetch(
      `${BASE_API_URI}/qa/answers/${answerID}`,
      requestInitOptions
    );

    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    return {
      status: res.status,
      answer: await res.json(),
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

It's just the familiar structure with a load function and a bunch of other form actions. Since all the other pages have this structure, I will skip explaining them but will include their screenshots

You can follow along by reading through the code on GitHub. They are very easy to follow.

The question detail page looks like this:

Question detailed page

As for login and signup pages, we have these:

Login page

Signup page

When one registers, a one-time token is sent to the user's email. There's a page to input this token and get the account attached to it activated. The page looks like this:

Activate account page

With that, we end this series. Kindly check the series' GitHub repository for the updated and complete code. They are intuitive.

I apologize once again for the abandonment.

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)