Probuild Ex Part three

cover for article Probuild Ex Part three

In Part two we build the data collection pipelines. In this third part, we are going to create:

  • A new context App to will hold the queries for the application
  • A styled liveview with tailwind to display our controls and data nicely
  • A liveview hook to use timeago.js
  • A helper module to convert champion_id and summoner_id to league of legends images

Part three assumes that you have already gone through Part two and have the code at a point where we can jump right in. If you want to checkout the companion code and fast forward to this point, do the following:

git clone https://github.com/mrdotb/probuild_ex.git cd probuild_ex git checkout 9f3016f5992de0ad3bd8d90636f1d8ff25fd8508

Table of contents

Have a peak ๐Ÿ‘€ at the end of series application

https://probuild.mrdotb.com/

Install tailwindcss - commit

Edit mix.exs

def deps do [ ... {:tailwind, "~> 0.1.6", runtime: Mix.env() == :dev} ] end defp aliases do [ ... "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] ] end

Edit config/config.exs

config :tailwind, version: "3.1.8", default: [ args: ~w( --config=tailwind.config.js --input=css/app.css --output=../priv/static/assets/app.css ), cd: Path.expand("../assets", __DIR__) ]

Edit config/dev.exs

config :probuild_ex, ProbuildExWeb.Endpoint, ... watchers: [ # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} # Add tailwind watcher tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} ]

Run

mix tailwind.install

It will edit assets/css/app.css and assets/js/app.js and create assets/tailwind.config.js.

At this point we should be good to go let's try some tailwind classes.

edit page/index.html.heex` and replace the content

<h1 class="text-3xl font-bold text-red-500 underline">Welcome to Phoenix!</h1>

Visit http://localhost:4000 we should see our Welcome to Phoenix! styled

Edit root layout add tailwindui nav - commit

I took one of the nice navigation and layout from tailwindui

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="csrf-token" content={csrf_token_value()}> <%= live_title_tag assigns[:page_title] || "ProbuildEx", suffix: " ยท Phoenix Framework" %> <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/> <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script> </head> <body class="min-h-screen flex flex-col"> <div class="flex-1 flex flex-col"> <nav class="bg-indigo-600"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex items-center justify-between h-16"> <div class="flex items-center"> <div class="flex-shrink-0"> <span class="text-white font-bold"> Probuild </span> </div> <div class="hidden md:block"> <div class="ml-10 flex items-baseline space-x-4"> <!-- Current: "bg-indigo-700 text-white", Default: "text-white hover:bg-indigo-500 hover:bg-opacity-75" --> <%= if function_exported?(Routes, :live_dashboard_path, 2) do %> <%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home), class: "text-white hover:bg-indigo-500 hover:bg-opacity-75 px-3 py-2 rounded-md text-sm font-medium" %> <% end %> </div> </div> </div> <div class="-mr-2 flex md:hidden"> <!-- Mobile menu button --> <button id="toggle-menu" type="button" class="bg-indigo-600 inline-flex items-center justify-center p-2 rounded-md text-indigo-200 hover:text-white hover:bg-indigo-500 hover:bg-opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white" aria-controls="mobile-menu" aria-expanded="false"> <span class="sr-only">Open main menu</span> <!-- Heroicon name: outline/bars-3 Menu open: "hidden", Menu closed: "block" --> <svg id="burger" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> </svg> <!-- Heroicon name: outline/x-mark Menu open: "block", Menu closed: "hidden" --> <svg id="x-mark" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> </div> </div> <!-- Mobile menu, show/hide based on menu state. --> <div class="hidden md:hidden" id="mobile-menu"> <div class="px-2 pt-2 pb-3 space-y-1 sm:px-3"> <!-- Current: "bg-indigo-700 text-white", Default: "text-white hover:bg-indigo-500 hover:bg-opacity-75" --> <%= if function_exported?(Routes, :live_dashboard_path, 2) do %> <%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home), class: "text-white hover:bg-indigo-500 hover:bg-opacity-75 block px-3 py-2 rounded-md text-base font-medium" %> <% end %> </div> </div> </nav> <main class="flex-1 bg-gray-100"> <div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <!-- Replace with your content --> <%= @inner_content %> <!-- /End replace --> </div> </main> </div> </body> </html>

We need a bit of js to make the mobile navigation work.

Edit assets/js/app.js add

// tailwind ui mobile nav const $toggleMenu = document.getElementById('toggle-menu') const $burger = document.getElementById('burger') const $xMark = document.getElementById('x-mark') const $mobileMenu = document.getElementById('mobile-menu') $toggleMenu.addEventListener('click', (event) => { event.preventDefault() ;['hidden', 'block'].forEach((className) => { $burger.classList.toggle(className) $xMark.classList.toggle(className) }) $mobileMenu.classList.toggle('hidden') })

Nothing complicate just some css class toggle when the $toggleMenu is clicked

Visit http://localhost:4000 we should see

Create App context - commit

We will create another context that will hold the queries for the application.

Create lib/probuild_ex/app.ex

defmodule ProbuildEx.App do @moduledoc """ The context module who hold the queries. """ import Ecto.Query alias ProbuildEx.Repo alias ProbuildEx.Games.Participant def list_pro_participant_summoner(_opts) do query = from participant in Participant, left_join: game in assoc(participant, :game), left_join: summoner in assoc(participant, :summoner), left_join: opponent_participant in assoc(participant, :opponent_participant), inner_join: pro in assoc(summoner, :pro), preload: [ game: game, opponent_participant: opponent_participant, summoner: {summoner, pro: pro} ], order_by: [desc: game.creation], limit: 20 Repo.all(query) end end

This query will get the participants that are linked to a pro player with all the relations we need preloaded. Later we will use the _opts to filter the query according to what params the user provided on the liveview.

Create live_view template and style - commit

We will setup the liveview.

In lib/probuild_ex_web/router.ex replace the get "/", PageController, :index

defmodule ProbuildExWeb.Router do ... scope "/", ProbuildExWeb do pipe_through :browser live "/", GameLive.Index, :index end ... end

Here we setup the live route to our GameLive view.

Create the folder game_live

mkdir -p lib/probuild_ex_web/live/game_live/

Create game_live/index.ex

defmodule ProbuildExWeb.GameLive.Index do use ProbuildExWeb, :live_view alias ProbuildEx.App @impl true def mount(_params, _session, socket) do socket = assign(socket, participants: App.list_pro_participant_summoner([])) {:ok, socket} end @impl true def handle_params(params, _url, socket) do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end defp apply_action(socket, :index, _params) do socket end end

We use the query we did earlier and assigns the result to be used in the liveview. We will use the apply_action pattern for our liveview.

Create game_live/index.html.heex

<div class="flex flex-col"> <div class="flex justify-center"> <div class="md:max-w-3xl w-full"> <div class="px-2 md:px-0"> <div class="w-full mt-1 relative rounded-full shadow-sm"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <!-- Heroicon name: magnifying-glass --> <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> </svg> </div> <input type="search" name="search" id="search" class="py-4 px-5 focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-full" placeholder="Seach for a Champion or Pro Player"> </div> </div> </div> </div> <div class="mt-3 flex flex-wrap justify-center"> <span class="relative z-0 inline-flex shadow-sm rounded-md"> <button type="button" class="relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 rounded-l-md border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">All Roles</button> <button type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Top</button> <button type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Jungle</button> <button type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Middle</button> <button type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Utility</button> <button type="button" class="-ml-px relative inline-flex items-center px-3 py-1 md:px-4 md:py-2 rounded-r-md border border-gray-300 bg-white text-xs md:text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">Bottom</button> </span> <div> <select id="platform_id" name="platform_id" class="mt-1 md:mt-0 ml-2 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-xs md:text-sm rounded-md"> <option selected>All regions</option> <option>EUW</option> </select> </div> </div> <div class="mt-4 flex flex-col items-center space-y-1"> <div class="w-full max-w-3xl grid-participants-header px-1 py-2 text-xs"> <div></div> <div>Pro player</div> <div class="flex justify-center">Matchup</div> <div class="flex justify-center">KDA</div> <div class="flex justify-center">Summoners</div> <div class="flex justify-center">Build</div> </div> <%= for participant <- @participants do %> <div id={"participant-#{participant.id}"} class={[if(participant.win, do: "border-blue-500", else: "border-red-500"), "hover:bg-gray-100 hover:cursor-pointer border-l-8 w-full max-w-3xl grid-participants px-1 py-2 bg-white rounded-lg overflow-hidden shadow"]}> <div class="grid-area-creation flex md:justify-center items-center"> <%# TODO time ago %> </div> <div class="grid-area-player flex items-center"> <!-- Heroicon name: user-circle --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8"> <path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" /> </svg> <span class="flex-1 ml-1 text-ellipsis overflow-hidden"> <%= participant.summoner.pro.name %> </span> </div> <div class="grid-area-versus flex justify-center items-center space-x-1"> <img class="w-8 h-8 rounded-full" src="https://ddragon.leagueoflegends.com/cdn/12.16.1/img/champion/Gragas.png" alt=""> <span>vs</span> <img class="w-8 h-8 rounded-full" src="https://ddragon.leagueoflegends.com/cdn/12.16.1/img/champion/Gragas.png" alt=""> </div> <div class="grid-area-kda flex justify-center items-center"> <span class="font-medium"> <%= participant.kills %> </span> / <span class="font-medium text-red-500"> <%= participant.deaths %> </span> / <span class="font-medium"> <%= participant.assists %> </span> </div> <div class="grid-area-summoners flex justify-center items-center space-x-1"> <img class="w-8 h-8 border-2 border-black" src="https://ddragon.leagueoflegends.com/cdn/12.16.1/img/spell/SummonerFlash.png" alt=""> <img class="w-8 h-8 border-2 border-black" src="https://ddragon.leagueoflegends.com/cdn/12.16.1/img/spell/SummonerDot.png" alt=""> </div> <div class="grid-area-build flex justify-center items-center space-x-1"> <%= for _ <- 1..6 do %> <img class="w-8 h-8" src="https://ddragon.leagueoflegends.com/cdn/12.16.1/img/item/1001.png" alt=""> <% end %> </div> <div class="grid-area-ellipsis hidden md:flex flex-1 justify-center items-center"> <!-- Heroicon name: ellipsis-vertical --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6"> <path fill-rule="evenodd" d="M4.5 12a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zm6 0a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zm6 0a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z" clip-rule="evenodd" /> </svg> </div> </div> <% end %> </div> </div>

I took some components from tailwindui to do the big search and buttons. The rows layout is a mix of flexbox and grid. I found grid-area to simplify the responsive version a lot but it's not supported by tailwind so we need to add some extra css.

Edit assets/css/app.css

... /* Custom grid for participants */ .grid-participants-header { display: none; } @media (min-width: theme('screens.md')) { .grid-participants-header { display: grid; grid-template-columns: 11% 17% 12% 10% 10% 35% 4%; } } .grid-participants { display: grid; grid-gap: 12px 0px; grid-template-rows: auto auto; grid-template-columns: 20% 20% 60%; grid-template-areas: 'player kda versus' 'creation summoners build'; } @media (min-width: theme('screens.md')) { .grid-participants { grid-gap: 0px; grid-template-columns: 11% 17% 12% 10% 10% 35% 4%; grid-template-areas: 'creation player versus kda summoners build ellipsis'; } } .grid-area-creation { grid-area: creation; } .grid-area-player { grid-area: player; } .grid-area-versus { grid-area: versus; } .grid-area-kda { grid-area: kda; } .grid-area-summoners { grid-area: summoners; } .grid-area-build { grid-area: build; } .grid-area-ellipsis { grid-area: ellipsis; }

Visit http://localhost:4000 we should see

Create time ago hook - commit

We want to display when the game was played. ex: (1h ago, 1m ago ...) I found the nice timeago.js library.

Liveview come with javascript hook and we will create one for timeago.js.

Let's add the timeago.js library to our vendors. Create file assets/vendor/timeago.js Copy the content of this gist inside.

import { render, cancel } from '../vendor/timeago.js' let Hooks = {} Hooks.TimeAgo = { mounted() { render(this.el, 'en_short') }, updated() { render(this.el, 'en_short') }, destroyed() { cancel(this.el) }, } let csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute('content') let liveSocket = new LiveSocket('/live', Socket, { hooks: Hooks, params: { _csrf_token: csrfToken }, })
  • We import render and cancel function from timeago.js
  • Create a Hooks object
  • Add TimeAgo object use three of the callbacks provided by the liveview hook:
    • mounted trigger timeago render when the element has been added to the DOM
    • updated trigger timeago render when the element has been updated in the DOM
    • updated trigger timeago cancel when the element has been removed from the DOM
  • Add the Hooks object to the LiveSocket config

Now let's use the hook in our liveview template.

Edit game_live/index.html.heex replace <%# TODO time ago %>

<time id={"time-ago-#{participant.id}"} phx-hook="TimeAgo" datetime={participant.game.creation}></time>
  • We use the <time> html element.
  • Hook require a unique id we created one using participant_id
  • we set datetime attribute to game.creation timestamp it's the value timeago.js will read

We should get the time ago displayed nicely

Create Ddragon to get assets pictures - commit

Notice for know we put a placeholder for the champions summoners and items. In order to display the real one we need to convert champion_id, summoner_id and item_id to pictures. We will create a small api client to ddragon (the league of legend cdn) and a cache it using ETS.

Create the ddragon folder

mkdir lib/probuild_ex/ddragon

Create lib/probuild_ex/ddragon/api.ex

defmodule ProbuildEx.Ddragon.Api do @moduledoc """ A thin wrapper around the ddragon api for the endpoint we are interested in. """ use Tesla, only: [:get] @local "en_US" plug Tesla.Middleware.BaseUrl, "https://ddragon.leagueoflegends.com" plug Tesla.Middleware.JSON plug Tesla.Middleware.Logger def fetch_champions(patch) do get("/cdn/#{patch}/data/#{@local}/champion.json") end def fetch_items(patch) do get("/cdn/#{patch}/data/#{@local}/item.json") end def fetch_summoners(patch) do get("/cdn/#{patch}/data/#{@local}/summoner.json") end def fetch_versions do get("/api/versions.json") end end

We created a HTTP client to the ddragon cdn using Tesla

Now we will create a GenServer that use ETS as cache mechanism. Create lib/probuild_ex/ddragon/cache.ex

defmodule ProbuildEx.Ddragon.Cache do @moduledoc """ Cache the call of the ddragon api in :ets and provide singular ressource fetch. """ use GenServer, restart: :transient alias ProbuildEx.Ddragon.Api ## Client def start_link(_args) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def fetch_champion_img(key) do GenServer.call(__MODULE__, {:fetch_champion_img, key}) end def fetch_summoner_img(key) do GenServer.call(__MODULE__, {:fetch_summoner_img, key}) end ## Server def init(_) do opts = [:set, :named_table, :public, read_concurrency: true] :ets.new(:champions, opts) :ets.new(:summoners, opts) {:ok, [], {:continue, :warmup}} end def handle_continue(:warmup, state) do request_and_cache_champions() request_and_cache_summoners() {:noreply, state} end def handle_call({:fetch_champion_img, champion_key}, _from, state) do response = case :ets.lookup(:champions, {:img, champion_key}) do [{_, champion_img}] -> {:ok, champion_img} [] -> {:error, :not_found} end {:reply, response, state} end def handle_call({:fetch_summoner_img, summoner_key}, _from, state) do response = case :ets.lookup(:summoners, {:img, summoner_key}) do [{_, summoner_img}] -> {:ok, summoner_img} [] -> {:error, :not_found} end {:reply, response, state} end defp request_and_cache_champions do with {:ok, %{body: versions}} <- Api.fetch_versions(), last_game_version <- List.first(versions), {:ok, %{body: champions_response}} <- Api.fetch_champions(last_game_version) do champions_img_map = create_champions_img_map(champions_response) Enum.each(champions_img_map, fn {key, img} -> :ets.insert(:champions, {{:img, key}, img}) end) end end defp request_and_cache_summoners do with {:ok, %{body: versions}} <- Api.fetch_versions(), last_game_version <- List.first(versions), {:ok, %{body: summoners_response}} <- Api.fetch_summoners(last_game_version) do summoners_img_map = create_summoners_img_map(summoners_response) Enum.each(summoners_img_map, fn {key, img} -> :ets.insert(:summoners, {{:img, key}, img}) end) end end defp create_champions_img_map(champions_response) do champions_response |> Map.get("data") |> Enum.map(fn {_champion_id, data} -> key = String.to_integer(data["key"]) value = data["image"]["full"] {key, value} end) |> Map.new() end defp create_summoners_img_map(summoners_response) do summoners_response |> Map.get("data") |> Enum.map(fn {_summoner_id, data} -> key = String.to_integer(data["key"]) value = data["image"]["full"] {key, value} end) |> Map.new() end end

Let's go slowly. First, we make use of the restart: :transient option to be able to stop our GenServer under :normal condition.

Let's break what is happening:

  • On Client:
    • start_link/1 function don't need any args here. The GenServer will be started under the module name using __MODULE__
    • fetch_champion_img/1 will receive a champion_id and return the name of to the champion image or an error if we can't find it
    • fetch_summoner_img/1 will receive a summoner_id and return the name to the summoner image or an error if we can't find it
  • On Server:
    • init/1 create two ETS tables then call warmup
    • handle_continue/2 will run the request_and_cache_*/0 functions
    • handle_call/2 fetch_*_img will do a :ets.lookup on the table and return the image of the ressource
    • request_and_cache_*/2 we fetch the last versions of the game then we retrieve the ressource and insert it in the ETS table

Create lib/probuild_ex/ddragon.ex

defmodule ProbuildEx.Ddragon do @moduledoc """ Convenience to access ddragon. """ alias ProbuildEx.Ddragon.Cache @ddragon_cdn "https://ddragon.leagueoflegends.com/cdn" @doc """ Get a champion image given the game_version and champion_key. ## Example iex> Ddragon.get_champion_image("12.16.1", 1) "https://ddragon.leagueoflegends.com/cdn/12.16.1/img/champion/Annie.png" """ def get_champion_image(game_version, champion_key) do case Cache.fetch_champion_img(champion_key) do {:ok, champion_img} -> "#{@ddragon_cdn}/#{game_version}/img/champion/#{champion_img}" {:error, _} -> nil end end @doc """ Get a summoner image given the game_version and summoner_key. ## Example iex> Ddragon.get_summoner_image("12.16.1", 4) "https://ddragon.leagueoflegends.com/cdn/12.16.1/img/spell/SummonerFlash.png" """ def get_summoner_image(game_version, summoner_key) do case Cache.fetch_summoner_img(summoner_key) do {:ok, summoner_img} -> "#{@ddragon_cdn}/#{game_version}/img/spell/#{summoner_img}" {:error, _} -> nil end end @doc """ Get a summoner image given the game_version and summoner_key. ## Example iex> Ddragon.get_item_image("12.16.1", 1038) "https://ddragon.leagueoflegends.com/cdn/12.16.1/img/item/1038.png" """ def get_item_image(game_version, item_key) def get_item_image(_game_version, 0), do: nil def get_item_image(game_version, item_key) do "#{@ddragon_cdn}/#{game_version}/img/item/#{item_key}.png" end end

We made this helper module with three functions to get the image for a ressource. In the end item url can be calculated easily with item_id only but it's not the case for champions and summoners.

Edit lib/probuild_ex/application.ex

def start(_type, _args) do children = [ ... # Ddragon ProbuildEx.Ddragon.Cache ] ... end

We add the Cache to the list of children, when the application start requests to ddragon will be done and cached.

Edit game_live/index.ex

... alias ProbuildEx.Ddragon ...

We add an alias to Ddragon module on top of GameLive.Index

Edit game_live/index.html.heex

... <%= img_tag(Ddragon.get_champion_image(participant.game.version, participant.champion_id), class: "w-8 h-8 rounded-full") %> <span>vs</span> <%= img_tag(Ddragon.get_champion_image(participant.game.version, participant.opponent_participant.champion_id), class: "w-8 h-8 rounded-full") %> ... <%= for summoner_key <- participant.summoners do %> <%= img_tag(Ddragon.get_summoner_image(participant.game.version, summoner_key), class: "w-8 h-8 border border-gray-400") %> <% end %> ... <%= for item_key <- participant.items do %> <%= if src = Ddragon.get_item_image(participant.game.version, item_key) do %> <img src="{src}" class="h-8 w-8" /> <% else %> <div class="h-8 w-8 border border-gray-400 bg-gray-900"></div> <% end %> <% end %> ...

We use our Ddragon module to convert the ressource id into images and replace the placeholder.

And now we should get our rows with proper images

Closing thoughts

Well done and thanks for sticking with me to the end! We built the foundation for our liveview application.

In the next part we will work on the search query and the integration in our liveview:

  • filter the query by pro, platform_id, team_position, champion
  • paginate the query with scrivener_ecto
  • make an infinite scroll with a liveview hook

Be sure to sign up to the newsletter so that you won't miss the next Part. Feel free to leave comments or feedback. I also appreciate if you can star โญ the companion code repo.

See you soon !

Stay up to date

Sign up for the mailing list and get notified via email when new blog posts come out.