Let's Build An Instagram Clone With The PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) Stack [PART 3]
In part 2 we added the ability to edit accounts and upload user's avatars, in this part, we will work on user's profiles. You can catch up with the Instagram Clone GitHub Repo.
User Profiles
First, we need the route, open lib/instagram_clone_web/router.ex
add the following route under root :browser
scope:
scope "/", InstagramCloneWeb do pipe_through :browser live "/", PageLive, :index live "/:username", UserLive.Profile # THIS LINE WAS ADDEDend
Then let's create our liveview files inside lib/instagram_clone_web/live/user_live
folder:
lib/instagram_clone_web/live/user_live/profile.ex
lib/instagram_clone_web/live/user_live/profile.html.leex
Add the following inside lib/instagram_clone_web/live/user_live/profile.ex
:
defmodule InstagramCloneWeb.UserLive.Profile do use InstagramCloneWeb, :live_view @impl true def mount(%{"username" => username}, session, socket) do socket = assign_defaults(session, socket) {:ok, socket |> assign(username: username)} endend
Inside lib/instagram_clone_web/live/user_live/profile.html.leex
:
<h1 class="text-5xl"><%= @username %></h1>
Open our navigation header lib/instagram_clone_web/live/header_nav_component.html.leex
on line 56 let's add our new route:
<%= live_patch to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Profile, @current_user.username) do %>
We need to find the user with the username param passed to our Liveview, open lib/instagram_clone/accounts.ex
add a profile()
function that will do that for us:
... @doc """ Gets the user with the given username param. """ def profile(param) do Repo.get_by!(User, username: param) end...
Let's update our mount function inside lib/instagram_clone_web/live/user_live/profile.ex
:
defmodule InstagramCloneWeb.UserLive.Profile do use InstagramCloneWeb, :live_view @impl true def mount(_params, session, socket) do socket = assign_defaults(session, socket) {:ok, socket} endend
We need to handle the username param, open lib/instagram_clone_web.ex
and update our live_view()
macro to 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 alias InstagramClone.Accounts # <-- THIS LINE WAS ADDED @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 @doc """ Because we are calling this function in each liveview, and we needed access to the username param in our profile liveview, we updated this function for when the username param is present, get the user and assign it along with page title to the socket """ @impl true def handle_params(params, uri, socket) do if Map.has_key?(params, "username") do %{"username" => username} = params user = Accounts.profile(username) {:noreply, socket |> assign(current_uri_path: URI.parse(uri).path) |> assign(user: user, page_title: "#{user.full_name} (@#{user.username})")} else {:noreply, socket |> assign(current_uri_path: URI.parse(uri).path)} end end end end
Repo.get_by!(queryable, clauses, opts)
Fetches a single result from the query. Raises Ecto.NoResultsError
if no record was found, or more than one entry. In production 404 error when no record found.
Open lib/instagram_clone_web/live/header_nav_component.htm.leex
on line 57 add the following to the profile list tag to close the dropdown menu when selected:
<li @click="open = false" class="py-2 px-4 hover:bg-gray-50">Profile</li>
Inside lib/instagram_clone_web/live/user_live/profile.html.leex
:
<header class="flex justify-center px-10"> <!-- Profile Picture Section --> <section class="w-1/4"> <%= img_tag @user.avatar_url, class: "w-40 h-40 rounded-full object-cover object-center" %> </section> <!-- END Profile Picture Section --> <!-- Profile Details Section --> <section class="w-3/4"> <div class="flex px-3 pt-3"> <h1 class="truncate md:overflow-clip text-2xl md:text-2xl text-gray-500 mb-3"><%= @user.username %></h1> <span class="ml-11"><button class="py-1 px-5 border-none shadow rounded text-gray-50 hover:bg-light-blue-600 bg-light-blue-500">Follow</button></span> </div> <div> <ul class="flex p-3"> <li><b>0</b> Posts</li> <li class="ml-11"><b>0</b> Followers</li> <li class="ml-11"><b>0</b> Following</li> </ul> </div> <div class="p-3"> <h2 class="text-md text-gray-600 font-bold"><%= @user.full_name %></h2> <%= if @user.bio do %> <p class="max-w-full break-words"><%= @user.bio %></p> <% end %> <%= if @user.website do %> <%= link display_website_uri(@user.website), to: @user.website, target: "_blank", rel: "noreferrer", class: "text-blue-700" %> <% end %> </div> </section> <!-- END Profile Details Section --></header><section class="border-t-2 mt-5"> <ul class="flex justify-center text-center space-x-20"> <li class="pt-4 px-1 text-sm text-gray-600 border-t-2 border-black -mt-0.5"> POSTS </li> <li class="pt-4 px-1 text-sm text-gray-400"> IGTV </li> <li class="pt-4 px-1 text-sm text-gray-400"> SAVED </li> <li class="pt-4 px-1 text-sm text-gray-400"> TAGGED </li> </ul></section>
Open lib/instagram_clone_web/live/render_helpers.ex
add the following 2 functions to display and get the website uri:
def display_website_uri(website) do website = website |> String.replace_leading("https://", "") |> String.replace_leading("http://", "") website end
Now we need to create our user follow component inside lib/instagram_clone_web/live/user_live
:
lib/instagram_clone_web/live/user_live/follow_component.ex
defmodule InstagramCloneWeb.UserLive.FollowComponent do use InstagramCloneWeb, :live_component def render(assigns) do ~L""" <button phx-target="<%= @myself %>" phx-click="toggle-status" class="<%= @follow_btn_styles? %>"><%= @follow_btn_name? %></button> """ end def handle_event("toggle-status", _params, socket) do follow_btn_name? = get_follow_btn_name?(socket.assigns.follow_btn_name?) follow_btn_styles? = get_follow_btn_styles?(socket.assigns.follow_btn_name?) :timer.sleep(200) {:noreply, socket |> assign(follow_btn_name?: follow_btn_name?) |> assign(follow_btn_styles?: follow_btn_styles?)} end defp get_follow_btn_name?(name) when name == "Follow" do "Unfollow" end defp get_follow_btn_name?(name) when name == "Unfollow" do "Follow" end defp get_follow_btn_styles?(name) when name == "Follow" do "py-1 px-2 text-red-500 border-2 rounded font-semibold hover:bg-gray-50 focus:outline-none" end defp get_follow_btn_styles?(name) when name == "Unfollow" do "py-1 px-5 border-none shadow rounded text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 focus:outline-none" endend
In our render function, we just have the button, with a click function that's going to get handle inside the component. It has 2 assigns, one for the name of the button and the other one for the styles, those are going to get set in our profile LiveView, then in our event function, we are assigning them back to the socket.
Now let's use our component in our profile template, open lib/instagram_clone_web/live/user_live/profile.html.leex
and update the file to the following:
<header class="flex justify-center px-10"> <!-- Profile Picture Section --> <section class="w-1/4"> <%= img_tag @user.avatar_url, class: "w-40 h-40 rounded-full object-cover object-center" %> </section> <!-- END Profile Picture Section --> <!-- Profile Details Section --> <section class="w-3/4"> <div class="flex px-3 pt-3"> <h1 class="truncate md:overflow-clip text-2xl md:text-2xl text-gray-500 mb-3"><%= @user.username %></h1> <span class="ml-11"> <!-- THE BUTTON WAS REPLACED FOR THE COMPONENT --> <%= cond do %> <% @current_user && @current_user == @user -> %> <%= live_patch "Edit Profile", to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Settings), class: "py-1 px-2 border-2 rounded font-semibold hover:bg-gray-50" %> <% @current_user -> %> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: @user.id, follow_btn_name?: @follow_btn_name?, follow_btn_styles?: @follow_btn_styles? %> <% true -> %> <%= link "Follow", to: Routes.user_session_path(@socket, :new), class: "py-1 px-5 border-none shadow rounded text-gray-50 hover:bg-light-blue-600 bg-light-blue-500" %> <% end %> <!-- ALL THIS UNTIL HERE WAS ADDED --> </span> </div> <div> <ul class="flex p-3"> <li><b>0</b> Posts</li> <li class="ml-11"><b>0</b> Followers</li> <li class="ml-11"><b>0</b> Following</li> </ul> </div> <div class="p-3"> <h2 class="text-md text-gray-600 font-bold"><%= @user.full_name %></h2> <%= if @user.bio do %> <p class="max-w-full break-words"><%= @user.bio %></p> <% end %> <%= if @user.website do %> <%= link display_website_uri(@user.website), to: @user.website, target: "_blank", rel: "noreferrer", class: "text-blue-700" %> <% end %> </div> </section> <!-- END Profile Details Section --></header><section class="border-t-2 mt-5"> <ul class="flex justify-center text-center space-x-20"> <li class="pt-4 px-1 text-sm text-gray-600 border-t-2 border-black -mt-0.5"> POSTS </li> <li class="pt-4 px-1 text-sm text-gray-400"> IGTV </li> <li class="pt-4 px-1 text-sm text-gray-400"> SAVED </li> <li class="pt-4 px-1 text-sm text-gray-400"> TAGGED </li> </ul></section>
We created a conditional to display the right button to users, when logged in and the user is on his profile it will get an edit profile link, when logged in and any other profile, we display the component, when not logged in, just a link to the login page. Now we need the assigns that we are sending to the component, just when the component is being displayed, open lib/instagram_clone_web/live/user_live/profile.ex
and update the file to the following:
defmodule InstagramCloneWeb.UserLive.Profile do use InstagramCloneWeb, :live_view alias InstagramClone.Accounts @impl true def mount(%{"username" => username}, session, socket) do socket = assign_defaults(session, socket) current_user = socket.assigns.current_user user = Accounts.profile(username) get_assigns(socket, current_user, user) end defp get_follow_btn_name? do "Follow" end defp get_follow_btn_styles? do "py-1 px-5 border-none shadow rounded text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 focus:outline-none" end defp get_assigns(socket, current_user, user) do if current_user && current_user !== user do follow_btn_name? = get_follow_btn_name?() follow_btn_styles? = get_follow_btn_styles?() {:ok, socket |> assign(follow_btn_name?: follow_btn_name?) |> assign(follow_btn_styles?: follow_btn_styles?)} else {:ok, socket} end endend
Let's create a Follow schema to handle followers in our terminal $ mix phx.gen.schema Accounts.Follows accounts_follows follower_id:references:user followed_id:references:user
then open the migration that was generated and add the following:
defmodule InstagramClone.Repo.Migrations.CreateAccountsFollows do use Ecto.Migration def change do create table(:accounts_follows) do add :follower_id, references(:users, on_delete: :delete_all) add :followed_id, references(:users, on_delete: :delete_all) timestamps() end create index(:accounts_follows, [:follower_id]) create index(:accounts_follows, [:followed_id]) endend
Also let's add 2 new fields to users table to keep track of total followers and followings, back in our terminal:
$ mix ecto.gen.migration adds_follower_followings_count_to_users_table
Open the migration generated and add the following:
defmodule InstagramClone.Repo.Migrations.AddsFollowerFollowingsCountToUsersTable do use Ecto.Migration def change do alter table(:users) do add :followers_count, :integer, default: 0 add :following_count, :integer, default: 0 end endend
Back to the terminal run $ mix ecto.migrate
Now open the new schema that was generated under lib/instagram_clone/accounts/follows.ex
inside that file add the following:
defmodule InstagramClone.Accounts.Follows do use Ecto.Schema alias InstagramClone.Accounts.User schema "accounts_follows" do belongs_to :follower, User belongs_to :followed, User timestamps() endend
Inside lib/instagram_clone/accounts/user.ex
add the following:
alias InstagramClone.Accounts.Follows @derive {Inspect, except: [:password]} schema "users" do field :email, :string field :password, :string, virtual: true field :hashed_password, :string field :confirmed_at, :naive_datetime field :username, :string field :full_name, :strin field :avatar_url, :string, default: "/images/default-avatar.png" field :bio, :string field :website, :string field :followers_count, :integer, default: 0 field :following_count, :integer, default: 0 has_many :following, Follows, foreign_key: :follower_id has_many :followers, Follows, foreign_key: :followed_id timestamps() end 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) |> unsafe_validate_unique(:username, InstagramClone.Repo) |> validate_length(:full_name, min: 4, max: 255) |> validate_format(:full_name, ~r/^[a-zA-Z0-9 ]*$/, message: "Please use letters and numbers") |> validate_website_schemes() |> validate_website_authority() |> validate_email() |> validate_password(opts) end defp validate_website_schemes(changeset) do validate_change(changeset, :website, fn :website, website -> uri = URI.parse(website) if uri.scheme, do: check_uri_scheme(uri.scheme), else: [website: "Enter a valid website"] end) end defp validate_website_authority(changeset) do validate_change(changeset, :website, fn :website, website -> authority = URI.parse(website).authority if String.match?(authority, ~r/^[a-zA-Z0-9.-]*$/) do [] else [website: "Enter a valid website"] end end) end defp check_uri_scheme(scheme) when scheme == "http", do: [] defp check_uri_scheme(scheme) when scheme == "https", do: [] defp check_uri_scheme(_scheme), do: [website: "Enter a valid website"]
Inside lib/instagram_clone/accounts.ex
add the following functions at the bottom of your file, and alias InstagramClone.Accounts.Follows
at the top:
@doc """ Creates a follow to the given followed user, and builds user association to be able to preload the user when associations are loaded, gets users to update counts, then performs 3 Repo operations, creates the follow, updates user followings count, and user followers count, we select the user in our updated followers count query, that gets returned """ def create_follow(follower, followed, user) do follower = Ecto.build_assoc(follower, :following) follow = Ecto.build_assoc(followed, :followers, follower) update_following_count = from(u in User, where: u.id == ^user.id) update_followers_count = from(u in User, where: u.id == ^followed.id, select: u) Ecto.Multi.new() |> Ecto.Multi.insert(:follow, follow) |> Ecto.Multi.update_all(:update_following, update_following_count, inc: [following_count: 1]) |> Ecto.Multi.update_all(:update_followers, update_followers_count, inc: [followers_count: 1]) |> Repo.transaction() |> case do {:ok, %{update_followers: update_followers}} -> {1, user} = update_followers hd(user) end end @doc """ Deletes following association with given user, then performs 3 Repo operations, to delete the association, update followings count, update and select followers count, updated followers count gets returned """ def unfollow(follower_id, followed_id) do follow = following?(follower_id, followed_id) update_following_count = from(u in User, where: u.id == ^follower_id) update_followers_count = from(u in User, where: u.id == ^followed_id, select: u) Ecto.Multi.new() |> Ecto.Multi.delete(:follow, follow) |> Ecto.Multi.update_all(:update_following, update_following_count, inc: [following_count: -1]) |> Ecto.Multi.update_all(:update_followers, update_followers_count, inc: [followers_count: -1]) |> Repo.transaction() |> case do {:ok, %{update_followers: update_followers}} -> {1, user} = update_followers hd(user) end end @doc """ Returns nil if not found """ def following?(follower_id, followed_id) do Repo.get_by(Follows, [follower_id: follower_id, followed_id: followed_id]) end @doc """ Returns all user's followings """ def list_following(user) do user = user |> Repo.preload(:following) user.following |> Repo.preload(:followed) end @doc """ Returns all user's followers """ def list_followers(user) do user = user |> Repo.preload(:followers) user.followers |> Repo.preload(:follower) end
Now let's update our file lib/instagram_clone_web/live/user_live/profile.ex
:
defmodule InstagramCloneWeb.UserLive.Profile do use InstagramCloneWeb, :live_view alias InstagramClone.Accounts alias InstagramCloneWeb.UserLive.FollowComponent @impl true def mount(_params, session, socket) do socket = assign_defaults(session, socket) {:ok, socket} end @impl true def handle_info({FollowComponent, :update_totals, updated_user}, socket) do {:noreply, socket |> assign(user: updated_user)} endend
We are going to set the follow button inside the component and just send a message to parent liveview to update the count.
Inside lib/instagram_clone_web/live/user_live/profile.html.leex
update the assings names on line 20, and 27:
<% @current_user -> %> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: @user.id, user: @user, current_user: @current_user %><% true -> %> <%= link "Follow", to: Routes.user_session_path(@socket, :new), class: "user-profile-follow-btn" %>
Open assets/css/app.scss
and add the following:
/* This file is for your main application css. */@import "tailwindcss/base";@import "tailwindcss/components";@import "tailwindcss/utilities";@import "../node_modules/nprogress/nprogress.css";@layer components { .user-profile-unfollow-btn { @apply py-1 px-2 text-red-500 border-2 rounded font-semibold hover:bg-gray-50 } .user-profile-follow-btn { @apply py-1 px-5 border-none shadow rounded text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 }}/* Styles for handling buttons click events */.while-submitting { display: none; }.phx-click-loading { .while-submitting { display: inline; } .btns { display: none; }}
Open lib/instagram_clone_web/live/user_live/follow_component.ex
and update the file to the following:
defmodule InstagramCloneWeb.UserLive.FollowComponent do use InstagramCloneWeb, :live_component alias InstagramClone.Accounts @impl true def update(assigns, socket) do get_btn_status(socket, assigns) end @impl true def render(assigns) do ~L""" <button phx-target="<%= @myself %>" phx-click="toggle-status" class="focus:outline-none"> <span class="while-submitting"> <span class="<%= @follow_btn_styles %> inline-flex items-center transition ease-in-out duration-150 cursor-not-allowed"> <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> Saving </span> </span> <span class="<%= @follow_btn_styles %> btns"><%= @follow_btn_name %><span> </button> """ end @impl true def handle_event("toggle-status", _params, socket) do current_user = socket.assigns.current_user user = socket.assigns.user :timer.sleep(300) if Accounts.following?(current_user.id, user.id) do unfollow(socket, current_user.id, user.id) else follow(socket, current_user, user) end end defp get_btn_status(socket, assigns) do if Accounts.following?(assigns.current_user.id, assigns.user.id) do get_socket_assigns(socket, assigns, "Unfollow", "user-profile-unfollow-btn") else get_socket_assigns(socket, assigns, "Follow", "user-profile-follow-btn") end end defp get_socket_assigns(socket, assigns, btn_name, btn_styles) do {:ok, socket |> assign(assigns) |> assign(follow_btn_name: btn_name) |> assign(follow_btn_styles: btn_styles)} end defp follow(socket, current_user, user) do updated_user = Accounts.create_follow(current_user, user, current_user) # Message sent to the parent liveview to update totals send(self(), {__MODULE__, :update_totals, updated_user}) {:noreply, socket |> assign(follow_btn_name: "Unfollow") |> assign(follow_btn_styles: "user-profile-unfollow-btn")} end defp unfollow(socket, current_user_id, user_id) do updated_user = Accounts.unfollow(current_user_id, user_id) # Message sent to the parent liveview to update totals send(self(), {__MODULE__, :update_totals, updated_user}) {:noreply, socket |> assign(follow_btn_name: "Follow") |> assign(follow_btn_styles: "user-profile-follow-btn")} endend
Inside lib/instagram_clone_web/live/user_live/profile.html.leex
update lines 37, 38:
<li class="ml-11"><b><%= @user.followers_count %></b> Followers</li><li class="ml-11"><b><%= @user.following_count %></b> Following</li>
Everything should work fine, but there is a problem that was introduced, you can see it in the gif image down below.
When the follow button gets triggered and we navigate to our profile, we are still on the same route, we don't get the right edit profile button, because we are using live_patch/2
in our header navigation, so when we go to our profile, there's no change, the only thing changing is the @user
so in our template the case that we are using to display the right button never gets called.
link/2
andredirect/2
do full page reloadslive_redirect/2
andpush_redirect/2
reloads the LiveView but
keeps the current layoutlive_patch/2
andpush_patch/2
updates the current LiveView and
sends only the minimal diff
The only solution that we can think of that can solve that problem is using live_redirect/2
in our header navigation to reload the LiveView. Open lib/instagram_clone_web/live/header_nav_component.html.leex
on line 56 do the following:
<%= live_redirect to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Profile, @current_user.username) do %>
Now the LiveView reloads and we get the right button displayed. I also noticed why we needed to use the handle_params()
function to assign the user, because of the conflict that when we use live_patch/2
nothing was changing and the user assign was not getting reload, but now the LiveView reloads so we can set the user in our LiveView mount()
function.
Open lib/instagram_clone_web/live/user_live/profile.ex
and update the mount()
function to the following:
@impl true def mount(%{"username" => username}, session, socket) do socket = assign_defaults(session, socket) user = Accounts.profile(username) {:ok, socket |> assign(user: user) |> assign(page_title: "#{user.full_name} (@#{user.username})")} end
Open lib/instagram_clone_web.ex
and inside our live_view()
macro update the handle_params()
function to the following:
@impl true def handle_params(_params, uri, socket) do {:noreply, socket |> assign(current_uri_path: URI.parse(uri).path)} end
Also we no longer need to close the dropdown menu when selected, when we are on the profile page, so open lib/instagram_clone_web/live/header_nav_component.htm.leex
on line 57, delete the AlpineJS directive:
<li class="py-2 px-4 hover:bg-gray-50">Profile</li>
Let's stick to only use live_patch/2
when we just want to display something that gets updated with URLS params, that doesn't trigger any action or changes any state.
I also decided to handle the conditional for the follow button inside the LiveView, it's personal preference, you can choose either way or how you feel comfortable. Open lib/instagram_clone_web/live/user_live/profile.ex
and add the following private function:
defp get_action(user, current_user) do cond do current_user && current_user == user -> :edit_profile current_user -> :follow_component true -> :login_btn endend
Then inside lib/instagram_clone_web/live/user_live/profile.ex
in our mount function, let's assign my_action
:
@impl true def mount(%{"username" => username}, session, socket) do socket = assign_defaults(session, socket) user = Accounts.profile(username) my_action = get_action(user, socket.assigns.current_user) {:ok, socket |> assign(my_action: my_action) |> assign(user: user) |> assign(page_title: "#{user.full_name} (@#{user.username})")} end
Then open lib/instagram_clone_web/live/user_live/profile.html.leex
and lets update the conditional to the following:
<header class="flex justify-center px-10"> <!-- Profile Picture Section --> <section class="w-1/4"> <%= img_tag @user.avatar_url, class: "w-40 h-40 rounded-full object-cover object-center" %> </section> <!-- END Profile Picture Section --> <!-- Profile Details Section --> <section class="w-3/4"> <div class="flex px-3 pt-3"> <h1 class="truncate md:overflow-clip text-2xl md:text-2xl text-gray-500 mb-3"><%= @user.username %></h1> <span class="ml-11"> <%= if @my_action in [:edit_profile] do %> <%= live_patch "Edit Profile", to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Settings), class: "py-1 px-2 border-2 rounded font-semibold hover:bg-gray-50" %> <% end %> <%= if @my_action in [:follow_component] do %> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: @user.id, user: @user, current_user: @current_user %> <% end %> <%= if @my_action in [:login_btn] do %> <%= link "Follow", to: Routes.user_session_path(@socket, :new), class: "user-profile-follow-btn" %> <% end %> </span> </div> <div> <ul class="flex p-3"> <li><b>0</b> Posts</li> <li class="ml-11"><b><%= @user.followers_count %></b> Followers</li> <li class="ml-11"><b><%= @user.following_count %></b> Following</li> </ul> </div> <div class="p-3"> <h2 class="text-md text-gray-600 font-bold"><%= @user.full_name %></h2> <%= if @user.bio do %> <p class="max-w-full break-words"><%= @user.bio %></p> <% end %> <%= if @user.website do %> <%= link display_website_uri(@user.website), to: @user.website, target: "_blank", rel: "noreferrer", class: "text-blue-700" %> <% end %> </div> </section> <!-- END Profile Details Section --></header><section class="border-t-2 mt-5"> <ul class="flex justify-center text-center space-x-20"> <li class="pt-4 px-1 text-sm text-gray-600 border-t-2 border-black -mt-0.5"> POSTS </li> <li class="pt-4 px-1 text-sm text-gray-400"> IGTV </li> <li class="pt-4 px-1 text-sm text-gray-400"> SAVED </li> <li class="pt-4 px-1 text-sm text-gray-400"> TAGGED </li> </ul></section>
Let's work on displaying the following and followers, we are going to use modals for that, we will need to use the handle_params()
function that is defined in a macro on every LiveView so let's take that function and properly assign it.
Open lib/instagram_clone_web.ex
and delete the handle_params()
from our live_view()
macro:
# DELETE THIS FUNCTION AND MOVE IT TO: #lib/instagram_clone_web/live/user_live/settings.ex #lib/instagram_clone_web/live/user_live/pass_settings.ex #lib/instagram_clone_web/live/page_live.ex @impl true def handle_params(_params, uri, socket) do {:noreply, socket |> assign(current_uri_path: URI.parse(uri).path)} end
For now until we find another way we will need to manually assign the current_uri_path
on each liveview, so let's keep that in mind we will have to remember it.
Open lib/instagram_clone_web/router.ex
:
scope "/", InstagramCloneWeb do pipe_through :browser live "/", PageLive, :index live "/:username", UserLive.Profile, :index # THIS LINE WAS UPDATED end scope "/", InstagramCloneWeb do pipe_through [:browser, :require_authenticated_user] get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email live "/accounts/edit", UserLive.Settings live "/accounts/password/change", UserLive.PassSettings live "/:username/following", UserLive.Profile, :following # THIS LINE WAS ADDED live "/:username/followers", UserLive.Profile, :followers # THIS LINE WAS ADDED end
Open our navigation header lib/instagram_clone_web/live/header_nav_component.html.leex
on line 56 lets update our route:
<%= live_patch to: Routes.user_profile_path(@socket, :index, @current_user.username) do %>
Update lib/instagram_clone_web/live/user_live/profile.ex
:
defmodule InstagramCloneWeb.UserLive.Profile do use InstagramCloneWeb, :live_view alias InstagramClone.Accounts alias InstagramCloneWeb.UserLive.FollowComponent @impl true def mount(%{"username" => username}, session, socket) do socket = assign_defaults(session, socket) user = Accounts.profile(username) {:ok, socket |> assign(user: user) |> assign(page_title: "#{user.full_name} (@#{user.username})")} end @impl true def handle_params(_params, uri, socket) do socket = socket |> assign(current_uri_path: URI.parse(uri).path) {:noreply, apply_action(socket, socket.assigns.live_action)} end @impl true def handle_info({FollowComponent, :update_totals, updated_user}, socket) do {:noreply, apply_msg_action(socket, socket.assigns.live_action, updated_user)} end defp apply_msg_action(socket, :index, updated_user) do socket |> assign(user: updated_user) end defp apply_msg_action(socket, _, _updated_user) do socket end defp apply_action(socket, :index) do live_action = get_live_action(socket.assigns.user, socket.assigns.current_user) socket |> assign(live_action: live_action) end defp apply_action(socket, :following) do following = Accounts.list_following(socket.assigns.user) socket |> assign(following: following) end defp apply_action(socket, :followers) do followers = Accounts.list_followers(socket.assigns.user) socket |> assign(followers: followers) end defp get_live_action(user, current_user) do cond do current_user && current_user == user -> :edit_profile current_user -> :follow_component true -> :login_btn end endend
Update lib/instagram_clone_web/live/user_live/profile.html.leex
:
<%= if @live_action == :following do %> <%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowingComponent, id: @user.id || :following, width: "w-1/4", current_user: @current_user, following: @following, return_to: Routes.user_profile_path(@socket, :index, @user.username) %><% end %><%= if @live_action == :followers do %> <%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowersComponent, id: @user.id || :followers, width: "w-1/4", current_user: @current_user, followers: @followers, return_to: Routes.user_profile_path(@socket, :index, @user.username) %><% end %><header class="flex justify-center px-10"> <!-- Profile Picture Section --> <section class="w-1/4"> <%= img_tag @user.avatar_url, class: "w-40 h-40 rounded-full object-cover object-center" %> </section> <!-- END Profile Picture Section --> <!-- Profile Details Section --> <section class="w-3/4"> <div class="flex px-3 pt-3"> <h1 class="truncate md:overflow-clip text-2xl md:text-2xl text-gray-500 mb-3"><%= @user.username %></h1> <span class="ml-11"> <%= if @live_action == :edit_profile do %> <%= live_patch "Edit Profile", to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Settings), class: "py-1 px-2 border-2 rounded font-semibold hover:bg-gray-50" %> <% end %> <%= if @live_action == :follow_component do %> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: @user.id, user: @user, current_user: @current_user %> <% end %> <%= if @live_action == :login_btn do %> <%= link "Follow", to: Routes.user_session_path(@socket, :new), class: "user-profile-follow-btn" %> <% end %> </span> </div> <div> <ul class="flex p-3"> <li><b>0</b> Posts</li> <%= live_patch to: Routes.user_profile_path(@socket, :followers, @user.username) do %> <li class="ml-11"><b><%= @user.followers_count %></b> Followers</li> <% end %> <%= live_patch to: Routes.user_profile_path(@socket, :following, @user.username) do %> <li class="ml-11"><b><%= @user.following_count %></b> Following</li> <% end %> </ul> </div> <div class="p-3"> <h2 class="text-md text-gray-600 font-bold"><%= @user.full_name %></h2> <%= if @user.bio do %> <p class="max-w-full break-words"><%= @user.bio %></p> <% end %> <%= if @user.website do %> <%= link display_website_uri(@user.website), to: @user.website, target: "_blank", rel: "noreferrer", class: "text-blue-700" %> <% end %> </div> </section> <!-- END Profile Details Section --></header><section class="border-t-2 mt-5"> <ul class="flex justify-center text-center space-x-20"> <li class="pt-4 px-1 text-sm text-gray-600 border-t-2 border-black -mt-0.5"> POSTS </li> <li class="pt-4 px-1 text-sm text-gray-400"> IGTV </li> <li class="pt-4 px-1 text-sm text-gray-400"> SAVED </li> <li class="pt-4 px-1 text-sm text-gray-400"> TAGGED </li> </ul></section>
Open lib/instagram_clone_web/live/render_helpers.ex
and add the following to help us displayed modals:
import Phoenix.LiveView.Helpers @doc """ Renders a component inside the `LiveviewPlaygroundWeb.ModalComponent` component. The rendered modal receives a `:return_to` option to properly update the URL when the modal is closed. The rendered modal also receives a `:width` option for the style width ## Examples <%= live_modal @socket, LiveviewPlaygroundWeb.PostLive.FormComponent, id: @post.id || :new, width: "w-1/2", post: @post, return_to: Routes.post_index_path(@socket, :index) %> """ def live_modal(socket, component, opts) do path = Keyword.fetch!(opts, :return_to) width = Keyword.fetch!(opts, :width) modal_opts = [id: :modal, return_to: path, width: width, component: component, opts: opts] live_component(socket, InstagramCloneWeb.ModalComponent, modal_opts) end
Create the modal component lib/instagram_clone_web/live/modal_component.ex
:
defmodule InstagramCloneWeb.ModalComponent do use InstagramCloneWeb, :live_component @impl true def render(assigns) do ~L""" <div class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-40 z-50" phx-capture-click="close" phx-window-keydown="close" phx-key="escape" phx-target="<%= @myself %>" phx-page-loading> <div class="<%= @width %> h-auto bg-white rounded-xl shadow-xl"> <%= live_patch raw("×"), to: @return_to, class: "float-right text-gray-500 text-4xl px-4" %> <%= live_component @socket, @component, @opts %> </div> </div> """ end @impl true def handle_event("close", _, socket) do {:noreply, push_patch(socket, to: socket.assigns.return_to)} endend
Create the followers component lib/instagram_clone_web/live/user_live/followers_component.ex
:
defmodule InstagramCloneWeb.UserLive.Profile.FollowersComponent do use InstagramCloneWeb, :live_component alias InstagramClone.Uploaders.Avatarend
And the template lib/instagram_clone_web/live/user_live/followers_component.html.leex
:
<header class="bg-gray-50 p-2 border-b-2 rounded-t-xl"> <h1 class="flex justify-center text-xl font-semibold">Followers</h1></header><%= for follow <- @followers do %> <div class="p-4"> <div class="flex items-center"> <%= live_redirect to: Routes.user_profile_path(@socket, :index, follow.follower.username) do %> <%= img_tag Avatar.get_thumb(follow.follower.avatar_url), class: "w-10 h-10 rounded-full object-cover object-center" %> <% end %> <div class="ml-3"> <%= live_redirect follow.follower.username, to: Routes.user_profile_path(@socket, :index, follow.follower.username), class: "font-semibold text-sm truncate text-gray-700 hover:underline" %> <h6 class="font-semibold text-sm truncate text-gray-400"> <%= follow.follower.full_name %> </h6> </div> <%= if @current_user !== follow.follower do %> <span class="ml-auto"> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: follow.follower.id, user: follow.follower, current_user: @current_user %> </span> <% end %> </div> </div><% end %>
Create the following component lib/instagram_clone_web/live/user_live/following_component.ex
:
defmodule InstagramCloneWeb.UserLive.Profile.FollowingComponent do use InstagramCloneWeb, :live_component alias InstagramClone.Uploaders.Avatarend
And the template lib/instagram_clone_web/live/user_live/following_component.html.leex
:
<header class="bg-gray-50 p-2 border-b-2 rounded-t-xl"> <h1 class="flex justify-center text-xl font-semibold">Following</h1></header><%= for follow <- @following do %> <div class="p-4"> <div class="flex items-center"> <%= live_redirect to: Routes.user_profile_path(@socket, :index, follow.followed.username) do %> <%= img_tag Avatar.get_thumb(follow.followed.avatar_url), class: "w-10 h-10 rounded-full object-cover object-center" %> <% end %> <div class="ml-3"> <%= live_redirect follow.followed.username, to: Routes.user_profile_path(@socket, :index, follow.followed.username), class: "font-semibold text-sm truncate text-gray-700 hover:underline" %> <h6 class="font-semibold text-sm truncate text-gray-400"> <%= follow.followed.full_name %> </h6> </div> <%= if @current_user !== follow.followed do %> <span class="ml-auto"> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: follow.followed.id, user: follow.followed, current_user: @current_user %> </span> <% end %> </div> </div><% end %>
Also we need to make a minor tweak in our avatar uploaders, open lib/instagram_clone_web/live/uploaders/avatar.ex
and the following:
# This was added to return the default image when no avatar uploaded def get_thumb(avatar_url) when avatar_url == "/images/default-avatar.png" do avatar_url end def get_thumb(avatar_url) do file_name = String.replace_leading(avatar_url, "/uploads/", "") ["/#{@upload_directory_name}", "thumb_#{file_name}"] |> Path.join() end
Let's do some minor tweaks to our header nav menu to not have to assign the current_uri_path on each LiveView, and to our page_live
component to set the form inside the component instead of the parent LiveView.
Open lib/instagram_clone_web/live/page_live.ex
and update the file to the following:
defmodule InstagramCloneWeb.PageLive do use InstagramCloneWeb, :live_view @impl true def mount(_params, session, socket) do socket = assign_defaults(session, socket) {:ok, socket} end @impl true def handle_params(_params, _uri, socket) do {:noreply, socket |> assign(live_action: apply_action(socket.assigns.current_user))} end defp apply_action(current_user) do if !current_user, do: :root_path endend
Update lib/instagram_clone_web/live/page_live_component.ex
to the following:
defmodule InstagramCloneWeb.PageLiveComponent do use InstagramCloneWeb, :live_component alias InstagramClone.Accounts alias InstagramClone.Accounts.User @impl true def mount(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) {:noreply, socket |> assign(changeset: changeset)} end def handle_event("save", _, socket) do {:noreply, assign(socket, trigger_submit: true)} endend
Inside lib/instagram_clone_web/live/page_live_component.html.leex
on line 5 add a target to the form:
<%= f = form_for @changeset, Routes.user_registration_path(@socket, :create), phx_change: "validate", phx_submit: "save", phx_target: @myself, # <-- THIS LINE WAS ADDED phx_trigger_action: @trigger_submit, class: "flex flex-col space-y-4 w-full px-6" %>
Update lib/instagram_clone_web/live/page_live.html.leex
to the following:
<%= if @current_user do %> <h1>User Logged In Homepage</h1><% else %> <%= live_component @socket, InstagramCloneWeb.PageLiveComponent, id: 1 %><% end %>
Open lib/instagram_clone_web/templates/layout/live.html.leex
and update the top logic to the following:
<%= if @current_user do %> <%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, current_user: @current_user %><% else %> <%= if @live_action !== :root_path do %> <%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, current_user: @current_user %> <% end %><% end %>
Now we won't need @curent_uri_path
on each liveview, we can delete it from lib/instagram_clone_web/live/user_live/profile.ex
on line 20 inside handle_params()
:
@impl true def handle_params(_params, uri, socket) do socket = socket |> assign(current_uri_path: URI.parse(uri).path) # <-- DELETE THIS LINE {:noreply, apply_action(socket, socket.assigns.live_action)} end
Making this part was harder than I anticipated, it's getting a little challenging, this application is not as easy as we might think to get it right. My perfectionism got the best of me, that's why it took me longer to release it, I was ignorant on some things that I had to figure out, but definitely, I enjoyed the process and learned a ton. In the next part, we will work with user's posts.
I really appreciate your time, thank you so much for reading.
CHECKOUT THE INSTAGRAM CLONE GITHUB REPO
Original Link: https://dev.to/elixirprogrammer/let-s-build-an-instagram-clone-with-the-petal-phoenix-elixir-tailwindcss-alpinejs-liveview-stack-part-3-48bc
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To