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:
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:
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 is10
by default but can be made configurable. Every 10 seconds, the list will change.Middle column: A list of questions will be housed here.
Right column: We will have some charts for plotting the
prices
,market caps
, andtotal 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>
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>
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}¤cy=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,
};
},
};
The endpoint used here, ${BASE_API_URI}/crypto/coin_prices?tags=${coinIDs}¤cy=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))
}
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:
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}
It has two columns:
- The first shows the question and all the answers to that question.
- 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(),
};
},
};
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:
As for login and signup pages, we have these:
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:
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)