DEV Community

Cover image for Let's Build An Instagram Clone With The PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) Stack
Anthony Gonzalez
Anthony Gonzalez

Posted on • Edited on • Originally published at elixirprogrammer.com

Let's Build An Instagram Clone With The PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) Stack

The better way to learn is by getting our hands dirty and building things, let's build a simplified version of the Instagram web application with the awesome PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) stack and deep dive into the dark world of functional programming and hottest kid on the block the Phoenix framework with LiveView.

I don't consider myself a teacher and no expert at anything, I'm just a regular dude just like you. Anybody can follow along even though you might get intimidated by the whole stack, it's kind of a new technology and not very popular, and not a lot of resources and materials out there. If you are an experienced developer you will have no problem, it doesn't mean that if you're a beginner you cannot follow along, I will do my best to make it beginner friendly but I will not go over every basic of the stack or web development so you've been warned.

Elixir's one of the best languages that I ever have the pleasure of learning, experimenting with, and I want to share my passion with the world, I want others to feel what I feel for the language.

Disclaimer: Elixir, Functional Programming, Phoenix Framework, might sound, look difficult and complicated but it is not at all, is easier than anything else out there, it might not be for everyone because we all do not think alike but for those who think like me will feel as I feel trying it. TailwindCSS might be opinionated, look not worth trying it, I know it because that's how I felt too, but just try it, the more you use it the more sense it will make and the more you will love it, it makes CSS uncomplicated, makes you not give up on front end development, CSS will still be painful, as developers, we don't have the patience that takes to get the UI right but it's a breath of fresh air.

We will not finish the whole project on this article, it will be a series of articles so this will be part 1. I will assume that you have your own development environment with Elixir installed, my development environment is on Windows 10 with WSL. We will try to be as detail as possible but keeping it simple, it is just for learning purposes only so it will not be an exact copy and it will not have every feature, we will get as close as possible to the real thing, also we will not focus on making the site responsive, we'll just make it work for large screens.

Let's start by going to the terminal and creating a fresh Phoenix app with LiveView.

$ mix phx.new instagram_clone --live

Once all dependencies are installed and fetched.

$ cd instagram_clone && mix ecto.create

I created a GitHub repo that you can visit here Instagram Clone GitHub Repo feel free to use the code as you wish, contributions are welcome.

Let's run the server to make sure that everything's working.

$ iex -S mix phx.server

If no errors you should have the default Phoenix framework homepage when you go to http://localhost:4000/

I use Visual Studio Code so I will open the project folder with the following command.

$ code .

Now let's add our mix dependencies in our mix.exs file.

# mix.exs file

  defp deps do
    [
      {:phoenix, "~> 1.5.6"},
      {:phoenix_ecto, "~> 4.1"},
      {:ecto_sql, "~> 3.4"},
      {:postgrex, ">= 0.0.0"},
      {:floki, ">= 0.27.0", only: :test},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_dashboard, "~> 0.3 or ~> 0.2.9"},
      {:telemetry_metrics, "~> 0.4"},
      {:telemetry_poller, "~> 0.4"},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, "~> 0.15.4", override: true},
      {:timex, "~> 3.6"},
      {:faker, "~> 0.16.0"}
    ]
  end
Enter fullscreen mode Exit fullscreen mode

We updated :phoenix_live_view to 15.4 version and added timex to handle times and faker for when we want test data.

Set Up TailwindCSS And AlpineJS

Make sure to have the latest node and npm versions.

$ cd assets

$ npm i tailwindcss postcss autoprefixer postcss-loader@4.2 --save-dev

Next let's configure Webpack, PostCSS, and TailwindCSS.

// /assets/webpack.config.js

use: [
  MiniCssExtractPlugin.loader,
  'css-loader',
  'sass-loader',
  'postcss-loader', // Add this
],
Enter fullscreen mode Exit fullscreen mode

Add /assets/postcss.config.js file with the following:

// /assets/postcss.config.js

module.exports = {
    plugins: {
        "postcss-import": {},
        tailwindcss: {},
        autoprefixer: {}
    }
}
Enter fullscreen mode Exit fullscreen mode

Create a TailwindCSS configuration file.

$ npx tailwindcss init

Add the following configuration to that file:

const colors = require('tailwindcss/colors')

module.exports = {
  purge: {
    enabled: process.env.NODE_ENV === "production",
    content: [
      "../lib/**/*.eex",
      "../lib/**/*.leex",
      "../lib/**/*_view.ex"
    ],
    options: {
      whitelist: [/phx/, /nprogress/]
    }
  },
  theme: {
    extend: {
      colors: {
        'light-blue': colors.lightBlue,
        cyan: colors.cyan,
      },
    },
  },
  variants: {
    extend: {
      borderWidth: ['hover'],
    }
  },
  plugins: [require('@tailwindcss/forms')],
}
Enter fullscreen mode Exit fullscreen mode

We configure which files to purge, added a custom color, and custom forms plugin. So let's add custom forms to our npm dependencies now.

$ npm i @tailwindcss/forms --save-dev

For custom components conflicts let's add postcss-import plugin.

$ npm i postcss-import --save-dev

Go to /assets/css/app.scss and add the following at the top of the file:

/* This file is for your main application css. */
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
Enter fullscreen mode Exit fullscreen mode

Delete /assets/css/phoenix.css we won't need it.

Let's get out of our assets folder $ cd .. and run our server $ iex -S mix phx.server

Test it out by going to /lib/instagram_clone_web/live/page_live.html.leex delete everything and add the following:

<h1 class="text-red-500 text-5xl font-bold text-center">Instagram Clone</h1>
Enter fullscreen mode Exit fullscreen mode

We should have a big red headline on our homepage.

Go to /lib/instagram_clone_web/live/page_live.ex delete everything because we won't need any of that in our homepage, and add the following:

defmodule InstagramCloneWeb.PageLive do
  use InstagramCloneWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end
Enter fullscreen mode Exit fullscreen mode

Go to /lib/instagram_clone_web/templates/layout/root.html.leex delete the default phoenix header, you should have the following on that file:

<!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"/>
    <%= csrf_meta_tag() %>
    <%= live_title_tag assigns[:page_title] || "InstagramClone", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </head>
  <body>
    <!-- Remove Everything Above Here -->
    <%= @inner_content %>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now let's customize our main container with tailwind, go to /lib/instagram_clone_web/templates/layout/live.html.leex and add the following class to the main tag:

<main role="main" class="container mx-auto max-w-full md:w-11/12 2xl:w-6/12 pt-24"> <!-- This the class that we added -->
  <p class="alert alert-info" role="alert"
    phx-click="lv:clear-flash"
    phx-value-key="info"><%= live_flash(@flash, :info) %></p>

  <p class="alert alert-danger" role="alert"
    phx-click="lv:clear-flash"
    phx-value-key="error"><%= live_flash(@flash, :error) %></p>

  <%= @inner_content %>
</main>
Enter fullscreen mode Exit fullscreen mode

 

Add AlpineJS

With TailwindCSS ready to go let's add AlpineJS. Let's get into our $ cd assets folder again and run the following:

$ npm i alpinejs@2.8.2

Open the app.js file located /assets/js/app.js and add the following so that we don't have any conflict with LiveView's own DOM patching:

import Alpine from "alpinejs"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  dom: {
    onBeforeElUpdated(from, to) {
      if (from.__x) { Alpine.clone(from.__x, to) }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Let's get out of our assets folder $ cd .. and run our server $ iex -S mix phx.server

Test it out buy going to /lib/instagram_clone_web/live/page_live.html.leex and adding the following to our top of our file:

<div x-data="{ open: false }">
    <button @click="open = true">Open Dropdown</button>

    <ul
        x-show="open"
        @click.away="open = false"
    >
        Dropdown Body
    </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

We should have a clickable dropdown if we go to our homepage like the example below:

AlpineJS Test

 

Phx.Gen.Auth

With that set up out of the way the real fun begins. Let's add user authentication with phx.gen.auth package.

Let's add the package to our mix.exs file.

  defp deps do
    [
      {:phoenix, "~> 1.5.6"},
      {:phoenix_ecto, "~> 4.1"},
      {:ecto_sql, "~> 3.4"},
      {:postgrex, ">= 0.0.0"},
      {:floki, ">= 0.27.0", only: :test},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_dashboard, "~> 0.3 or ~> 0.2.9"},
      {:telemetry_metrics, "~> 0.4"},
      {:telemetry_poller, "~> 0.4"},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, "~> 0.15.4", override: true},
      {:timex, "~> 3.6"},
      {:faker, "~> 0.16.0"},
      {:phx_gen_auth, "~> 0.7", only: [:dev], runtime: false}
    ]
  end
Enter fullscreen mode Exit fullscreen mode

Install and compile the dependencies

$ mix do deps.get, deps.compile

Install the authentication system with the following command:

$ mix phx.gen.auth Accounts User users

After all the files were generated run the following command:

$ mix deps.get && mix ecto.migrate

Now we need to add some fields to our users table by running the following command:

$ mix ecto.gen.migration add_to_users_table

Then open the file that was generated priv/repo/migrations/20210409223611_add_to_users_table.exs and add the following:

defmodule InstagramClone.Repo.Migrations.AddToUsersTable do
  use Ecto.Migration

  def change do
    alter table(:users) do
      add :username, :string
      add :full_name, :string
      add :avatar_url, :string
      add :bio, :string
      add :website, :string
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Then $ mix ecto.migrate

Next open lib/instagram_clone/accounts/user.ex and add the following to your users schema:

field :username, :string
field :full_name, :string
field :avatar_url, :string, default: "/images/default-avatar.png"
field :bio, :string
field :website, :string
Enter fullscreen mode Exit fullscreen mode

 
default-avatar
 

Download the default avatar image above and rename it to default-avatar.png and add that image to priv/static/images

Now we need to add validations for our new users schema, so open lib/instagram_clone/accounts/user.ex again and change the registration_changeset to the following:

  def registration_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email, :password, :username, :full_name, :avatar_url, :bio, :website])
    |> validate_required([:username, :full_name])
    |> validate_length(:username, min: 5, max: 30)
    |> validate_format(:username, ~r/^[a-zA-Z0-9_.-]*$/, message: "Please use letters and numbers without space(only characters allowed _ . -)")
    |> unique_constraint(:username)
    |> validate_length(:full_name, min: 4, max: 30)
    |> validate_email()
    |> validate_password(opts)
  end
Enter fullscreen mode Exit fullscreen mode

Also, we need to change our validate_password function for when we update the user's account, we won't need to validate or hash the password so change it to the following:

  defp validate_password(changeset, opts) do
    register_user? = Keyword.get(opts, :register_user, true)
    if register_user? do
      changeset
      |> validate_required([:password])
      |> validate_length(:password, min: 6, max: 80)
      # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
      # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
      # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
      |> maybe_hash_password(opts)
    else
      changeset
    end
  end
Enter fullscreen mode Exit fullscreen mode

When updating the user's account we will send a register_user: false option to the changeset. Also, the minimum password length was changed to 6 for development purposes only it should be changed in production.

Let's run our server $ iex -S mix phx.server and open /lib/instagram_clone_web/live/page_live.html.leex to work on our homepage styles to add the registration form.

Before we do that we have to delete the authentication links auto generated by phx.gen.auth so go to /lib/instagram_clone_web/templates/layout/root.html.leex and delete the <%= render "_user_menu.html", assigns %> from the top of the body.

<!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"/>
    <%= csrf_meta_tag() %>
    <%= live_title_tag assigns[:page_title] || "InstagramClone", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </head>
  <body>
    <%= render "_user_menu.html", assigns %><!-- REMOVE IT -->
    <%= @inner_content %>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

And lastly, delete /lib/instagram_clone_web/templates/layout/_user_menu.html.eex partial file, we won't need it.

Okay now back to /lib/instagram_clone_web/live/page_live.html.leex add the following:

<section class="w-1/2 border-2 shadow-lg flex flex-col place-items-center mx-auto p-6">
    <h1 class="text-4xl font-bold italic text-gray-600">InstagramClone</h1>
    <p class="text-gray-400 font-semibold text-lg my-6">Sign up to see photos and videos from your friends.</p>
</section>
Enter fullscreen mode Exit fullscreen mode

We need to add a form so go to /lib/instagram_clone_web/live/page_live.ex change the mount function to the following:

  alias InstagramClone.Accounts
  alias InstagramClone.Accounts.User

  @impl true
  def mount(_params, _session, socket) do
    changeset = Accounts.change_user_registration(%User{})
    {:ok,
      socket
      |> assign(changeset: changeset)}
  end
Enter fullscreen mode Exit fullscreen mode

Let's add our form and new styles by editing /lib/instagram_clone_web/live/page_live.html.leex to:

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
  <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

  <%= f = form_for @changeset, "#",
    phx_change: "validate",
    phx_submit: "save",
    phx_trigger_action: @trigger_submit,
    class: "flex flex-col space-y-4 w-full px-6" %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :full_name, class: "text-gray-400" %>
      <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :username, class: "text-gray-400" %>
      <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :username, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>

  </form>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@socket, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>
Enter fullscreen mode Exit fullscreen mode

We need to tweak a little bit our error_tag() helper functions before we go further, so we can add classes to it, open lib/instagram_clone_web/views/error_helpers.ex file and change the function to the following:

  def error_tag(form, field, class \\ [class: "invalid-feedback"]) do
    Enum.map(Keyword.get_values(form.errors, field), fn error ->
      content_tag(:span, translate_error(error),
        class: Keyword.get(class, :class),
        phx_feedback_for: input_id(form, field)
      )
    end)
  end
Enter fullscreen mode Exit fullscreen mode

InstagramCloneHomepage

That's going to serve us as our base landing and sign-up page. We need to add our validation() and save() functions, and assign trigger_submit to the socket in the mount function to be able to trigger the form over HTTP to send the form to the register controller directly on our /lib/instagram_clone_web/live/page_live.ex liveview module so we can register our user, so let's do that next.

  @impl true
  def mount(_params, _session, socket) do
    changeset = Accounts.change_user_registration(%User{})
    {:ok,
      socket
      |> assign(changeset: changeset)
      |> assign(trigger_submit: false)}
  end

  @impl true
  def handle_event("validate", %{"user" => user_params}, socket) do
    changeset =
      %User{}
      |> User.registration_changeset(user_params)
      |> Map.put(:action, :validate)
    :timer.sleep(9000)
    {:noreply, socket |> assign(changeset: changeset)}
  end

  def handle_event("save", _, socket) do
    {:noreply, assign(socket, trigger_submit: true)}
  end
Enter fullscreen mode Exit fullscreen mode

To handle and display errors we have to edit our register user page, because they are regular Phoenix views handled by controllers, so it should look just like our liveview. Before we do that we have to add a class to the main tag in our container for regular views, open lib/instagram_clone_web/templates/layout/app.html.eex:

<main role="main" class="container mx-auto max-w-full md:w-11/12 2xl:w-6/12 pt-24">
  <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
  <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
  <%= @inner_content %>
</main>
Enter fullscreen mode Exit fullscreen mode

Then open lib/instagram_clone_web/templates/user_registration/new.html.eex and edit the file with the following:

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
  <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

  <%= form_for @changeset, Routes.user_registration_path(@conn, :create), [class: "flex flex-col space-y-4 w-full px-6"], fn f -> %>
    <%= if @changeset.action do %>
      <div class="alert alert-danger">
        <p>Oops, something went wrong! Please check the errors below.</p>
      </div>
    <% end %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :full_name, class: "text-gray-400" %>
      <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :username, class: "text-gray-400" %>
      <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :username, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>
  <% end %>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>
Enter fullscreen mode Exit fullscreen mode

Let's style our login page and login. Open lib/instagram_clone_web/templates/user_session/new.html.eex and add the following:

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>

  <%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "flex flex-col space-y-4 w-full px-6"], fn f -> %>
    <%= if @error_message do %>
      <div class="alert alert-danger">
        <p><%= @error_message %></p>
      </div>
    <% end %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, value: input_value(f, :password), class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Log In", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>
  <% end %>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold"><%= link "Forgot password?", to: Routes.user_reset_password_path(@conn, :new) %></p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Don't have an account? <%= link "Sign up", to: Routes.user_registration_path(@conn, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>
Enter fullscreen mode Exit fullscreen mode

It should look like the image below.

InstagramCloneLoginPage

Things are getting really interesting and exciting, but we don't have a way to get the currently logged in user in our liveviews, we have to get it manually on each liveview mount, we can do it manually but we are lazy so we are going to add a helper function that we can call and have access to the current user.

Add a new file to lib/instagram_clone_web/live folder named lib/instagram_clone_web/live/live_helpers.ex and add the following:

defmodule InstagramCloneWeb.LiveHelpers do
  import Phoenix.LiveView
  alias InstagramClone.Accounts
  alias InstagramClone.Accounts.User
  alias InstagramCloneWeb.UserAuth

  def assign_defaults(session, socket) do
    if connected?(socket), do: InstagramCloneWeb.Endpoint.subscribe(UserAuth.pubsub_topic())

    socket =
      assign_new(socket, :current_user, fn ->
        find_current_user(session)
      end)
    socket
  end

  defp find_current_user(session) do
    with user_token when not is_nil(user_token) <- session["user_token"],
         %User{} = user <- Accounts.get_user_by_session_token(user_token),
         do: user
  end
end
Enter fullscreen mode Exit fullscreen mode

It's simple we are finding the current user with the session token and assign it back to socket, we are also subscribing to a pubsub topic to logged out all live current session when login out with sockets, let's create that function next, open lib/instagram_clone_web/controllers/user_auth.ex and add the following:

  # Added to the top of our file
  @pubsub_topic "user_updates"

  def pubsub_topic, do: @pubsub_topic
  # We changed a line on this function
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    Accounts.log_out_user(user_token) #Line changed

    if live_socket_id = get_session(conn, :live_socket_id) do
      InstagramCloneWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session()
    |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: "/")
  end
Enter fullscreen mode Exit fullscreen mode

Now let's add the log_out_user() function to our Accounts context, open lib/instagram_clone/accounts.ex add:

...

alias InstagramCloneWeb.UserAuth

...

  def log_out_user(token) do
    user = get_user_by_session_token(token)
    # Delete all user tokens
    Repo.delete_all(UserToken.user_and_contexts_query(user, :all))

    # Broadcast to all liveviews to immediately disconnect the user
    InstagramCloneWeb.Endpoint.broadcast_from(
      self(),
      UserAuth.pubsub_topic(),
      "logout_user",
      %{
        user: user
      }
    )
  end

...
Enter fullscreen mode Exit fullscreen mode

Now we have to make our helper function available in our liveviews, open lib/instagram_clone_web.ex and add the following:

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {InstagramCloneWeb.LayoutView, "live.html"}

      unquote(view_helpers())
      # Added Start   
      import InstagramCloneWeb.LiveHelpers

      alias InstagramClone.Accounts.User
      @impl true
      def handle_info(%{event: "logout_user", payload: %{user: %User{id: id}}}, socket) do
        with %User{id: ^id} <- socket.assigns.current_user do
          {:noreply,
            socket
            |> redirect(to: "/")
            |> put_flash(:info, "Logged out successfully.")}
        else
          _any -> {:noreply, socket}
        end
      end
      # Added END 
    end
  end
Enter fullscreen mode Exit fullscreen mode

We also added the handle_info() function to automatically react to the logout message in all our liveviews.

Open /lib/instagram_clone_web/live/page_live.ex and changed the mount function to the following:

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    changeset = Accounts.change_user_registration(%User{})
    {:ok,
      socket
      |> assign(changeset: changeset)
      |> assign(trigger_submit: false)}
  end
Enter fullscreen mode Exit fullscreen mode

Then open /lib/instagram_clone_web/live/page_live.html.leex and changed the file to the following:

<%= if @current_user do %>
  <%= link "Log Out", to: Routes.user_session_path(@socket, :delete), method: :delete %>
  <h1>User Logged In Homepage</h1>
<% else %>
  <section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
    <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
    <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

    <%= f = form_for @changeset, Routes.user_registration_path(@socket, :create),
      phx_change: "validate",
      phx_submit: "save",
      phx_trigger_action: @trigger_submit,
      class: "flex flex-col space-y-4 w-full px-6" %>

      <div class="flex flex-col">
        <%= label f, :email, class: "text-gray-400" %>
        <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :email, class: "text-red-700 text-sm" %>
      </div>

      <div class="flex flex-col">
        <%= label f, :full_name, class: "text-gray-400" %>
        <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
      </div>

      <div class="flex flex-col">
        <%= label f, :username, class: "text-gray-400" %>
        <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :username, class: "text-red-700 text-sm" %>
      </div>

      <div class="flex flex-col">
        <%= label f, :password, class: "text-gray-400" %>
        <%= password_input f, :password, value: input_value(f, :password), class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :password, class: "text-red-700 text-sm" %>
      </div>

      <div>
        <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
      </div>

    </form>

    <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
  </section>

  <section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
    <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@socket, :new), class: "text-light-blue-500 font-semibold" %></p>
  </section>
<% end %>
Enter fullscreen mode Exit fullscreen mode

At this point, you should reload your server and test your homepage by login in and login out. Everything should work fine but let's componentized our homepage, under lib/instagram_clone_web/live let's create 2 files, lib/instagram_clone_web/live/page_live_component.ex and lib/instagram_clone_web/live/page_live_component.html.leex

#lib/instagram_clone_web/live/page_live_component.ex

defmodule InstagramCloneWeb.PageLiveComponent do
  use InstagramCloneWeb, :live_component
end
Enter fullscreen mode Exit fullscreen mode

Take the form from lib/instagram_clone_web/live/page_live.html.leex and let's add it to lib/instagram_clone_web/live/page_live_component.html.leex:

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
  <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

  <%= f = form_for @changeset, Routes.user_registration_path(@socket, :create),
    phx_change: "validate",
    phx_submit: "save",
    phx_trigger_action: @trigger_submit,
    class: "flex flex-col space-y-4 w-full px-6" %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :full_name, class: "text-gray-400" %>
      <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :username, class: "text-gray-400" %>
      <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :username, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, value: input_value(f, :password), class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>

  </form>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@socket, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>
Enter fullscreen mode Exit fullscreen mode

Now your lib/instagram_clone_web/live/page_live.html.leex should look like the following example:

<%= if @current_user do %>
  <h1>User Logged In Homepage</h1>
<% else %>
  <%= live_component @socket, InstagramCloneWeb.PageLiveComponent, changeset: @changeset, trigger_submit: @trigger_submit %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

That helps us to clean up our code and for later on when we start working on the homepage for logged-in users.

Lastly let's add a header navigation menu component. First we need to know the current URL path, we are going to do that for all liveviews with a macro, open lib/instagram_clone_web.ex add the following function to live_view():

      @impl true
      def handle_params(_unsigned_params, uri, socket) do
        {:noreply,
          socket
          |> assign(current_uri_path: URI.parse(uri).path)}
      end
Enter fullscreen mode Exit fullscreen mode

That will give us access to the current URL path on all liveviews with the current_uri_path assigned to the socket. So in our lib/instagram_clone_web.ex file the new updated live_view() should look like the following:

...
  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {InstagramCloneWeb.LayoutView, "live.html"}

      unquote(view_helpers())
      import InstagramCloneWeb.LiveHelpers

      alias InstagramClone.Accounts.User
      @impl true
      def handle_info(%{event: "logout_user", payload: %{user: %User{id: id}}}, socket) do
        with %User{id: ^id} <- socket.assigns.current_user do
          {:noreply,
            socket
            |> redirect(to: "/")
            |> put_flash(:info, "Logged out successfully.")}
        else
          _any -> {:noreply, socket}
        end
      end

      @impl true
      def handle_params(_unsigned_params, uri, socket) do
        {:noreply,
          socket
          |> assign(current_uri_path: URI.parse(uri).path)}
      end
    end
  end
...
Enter fullscreen mode Exit fullscreen mode

Now under lib/instagram_clone_web/live add 2 files, header_nav_component.ex and header_nav_component.html.leex. To lib/instagram_clone_web/live/header_nav_component.ex add the following:

defmodule InstagramCloneWeb.HeaderNavComponent do
  use InstagramCloneWeb, :live_component
end
Enter fullscreen mode Exit fullscreen mode

Add the following to lib/instagram_clone_web/live/header_nav_component.html.leex:

<div class="h-14 border-b-2 flex fixed w-full bg-white z-50">
  <header class="flex items-center container mx-auto max-w-full md:w-11/12 2xl:w-6/12">
    <%= live_patch to: Routes.page_path(@socket, :index) do %>
      <h1 class="text-2xl font-bold italic">#InstagramClone</h1>
    <% end %>
    <div class="w-2/5 flex justify-end"><input type="search" placeholder="Search" class="h-7 bg-gray-50 shadow-sm border-gray-300 focus:ring-gray-300 focus:ring-opacity-50 focus:border-gray-400  px-0.5 rounded-sm"></div>
    <nav class="w-3/5 relative">
      <ul x-data="{open: false}" class="flex justify-end">
        <%= if @current_user do %>
          <li class="w-7 h-7 text-gray-600">
            <%= live_patch to: Routes.page_path(@socket, :index) do %>
              <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
              </svg>
            <% end %>
          </li>
          <li class="w-7 h-7 ml-6 text-gray-600">
            <%= live_patch to: "" do %>
              <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
              </svg>
            <% end %>
          </li>
          <li class="w-7 h-7 ml-6 text-gray-600">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
            </svg>
          </li>
          <li class="w-7 h-7 ml-6 text-gray-600">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
            </svg>
          </li>
          <li class="w-7 h-7 ml-6 text-gray-600">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
            </svg>
          </li>
          <li
            @click="open = true"
            class="w-7 h-7 ml-6 shadow-md rounded-full overflow-hidden cursor-pointer"
          >
            <%= img_tag @current_user.avatar_url,
            class: "w-full h-full object-cover object-center" %>
          </li>
          <ul class="absolute top-14 w-56 bg-white shadow-md text-sm -right-8"
              x-show="open"
              @click.away="open = false"
              x-transition:enter="transition ease-out duration-200"
              x-transition:enter-start="opacity-0 transform scale-90"
              x-transition:enter-end="opacity-100 transform scale-100"
              x-transition:leave="transition ease-in duration-200"
              x-transition:leave-start="opacity-100 transform scale-100"
              x-transition:leave-end="opacity-0 transform scale-90"
            >
              <%= live_patch to: "" do %>
                <li class="py-2 px-4 hover:bg-gray-50">Profile</li>
              <% end %>
              <li class="py-2 px-4 hover:bg-gray-50">Saved</li>
              <%= live_patch to: "" do %>
                <li class="py-2 px-4 hover:bg-gray-50">Settings</li>
              <% end %>
              <li class="border-t-2 py-2 px-4 hover:bg-gray-50"><%= link "Log Out", to: Routes.user_session_path(@socket, :delete), method: :delete %></li>
            </ul>
        <% else %>
          <li>
            <%= link "Log In", to: Routes.user_session_path(@socket, :new), class: "md:w-24 py-1 px-3 border-none shadow rounded text-gray-50 hover:bg-blue-600 bg-blue-500 font-semibold" %>
          </li>
          <li>
            <%= link "Sign Up", to: Routes.user_registration_path(@socket, :new), class: "md:w-24 py-1 px-3 border-none text-blue-500 hover:text-blue-600 font-semibold" %>
          </li>
        <% end %>
      </ul>
    </nav>
  </header>
</div>
Enter fullscreen mode Exit fullscreen mode

Now to display that component open lib/instagram_clone_web/templates/layout/live.html.leex and add to the top of the file:

<%= if @current_user do %>
  <%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, current_user: @current_user %>
<% else %>
  <%= if @current_uri_path !== "/" do %>
    <%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, current_user: @current_user %>
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

That will check if a user is logged in to display the header nav, if not and not the homepage, like the profile or post page that we will create later on, it will display it also otherwise if homepage and not logged in it will not get displayed.

That's enough for part 1, we still have a long way to go and a lot of really exciting and fun things to do, I'm building it as I write this series so if there are any mistakes or errors will get fixed as we go, let me know what you think in the comments down below and you can contribute to the repo Instagram Clone GitHub Repo, I really appreciate your time, thank you so much for reading.

 
 
 

Join The Elixir Army

Top comments (12)

Collapse
 
belgoros profile image
Serguei Cambour

Great article, Anthony, thank you for sharing, keep on posting the stuff like that :). Just one typo to point out, - "Test it out buy going to..", should probably be "Test it out by going to...".

Collapse
 
elixirprogrammer profile image
Anthony Gonzalez

I will, thanks for pointing out the typo, I fixed it.

Collapse
 
ambareesha7 profile image
Ambareesha AV

hey there, a nice write up, i'm learning elixir and phoenix and i'm enjoying your article
i'm wondering if i can contribute something to this repo
thank you

Collapse
 
elixirprogrammer profile image
Anthony Gonzalez

Glad you like it! All contributions are welcome.

Collapse
 
samxdesc profile image
Samuel

Nice! I followed the way through and worked perfectly, now, I'll take the time to analyze the code and learn more from it.

I'm waiting for the next topic. :)

Collapse
 
elixirprogrammer profile image
Anthony Gonzalez

I'm almost done with part 2, I might publish it tonight, part 2 it's about user settings, and avatar uploads.

Collapse
 
romenigld profile image
Romenig Lima Damasio

I never see the use of the:
phx_trigger_action: @trigger_submit

It's because of the Alpinejs?

Collapse
 
elixirprogrammer profile image
Anthony Gonzalez

No, nothing to do with AlpineJS. That's to submit directly to the controller action from the LiveView event, you can learn more about it in the docs here.

Collapse
 
matthewcford profile image
Matthew Ford

is there a form helper library to combine, the error_tag, input and label?

Collapse
 
elixirprogrammer profile image
Anthony Gonzalez
Collapse
 
daniel4lm profile image
Daniel Molnar

This is very impressive article, not so much of them are out there.
Thank you

Collapse
 
julismzok profile image
Julián Somoza

I must be missing something but I can't found the /assets/webpack.config.js file... update needed?