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
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
],
Add /assets/postcss.config.js
file with the following:
// /assets/postcss.config.js
module.exports = {
plugins: {
"postcss-import": {},
tailwindcss: {},
autoprefixer: {}
}
}
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')],
}
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";
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>
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
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>
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>
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) }
}
}
})
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>
We should have a clickable dropdown if we go to our homepage like the example below:
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
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
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
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
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
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>
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>
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
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>
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
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
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>
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>
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>
It should look like the image below.
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
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
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
...
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
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
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 %>
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
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>
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 %>
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
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
...
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
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>
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 %>
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.
Top comments (12)
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...".
I will, thanks for pointing out the typo, I fixed it.
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
Glad you like it! All contributions are welcome.
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. :)
I'm almost done with part 2, I might publish it tonight, part 2 it's about user settings, and avatar uploads.
I never see the use of the:
phx_trigger_action: @trigger_submit
It's because of the Alpinejs?
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.
is there a form helper library to combine, the error_tag, input and label?
hex.pm/packages/formulator
This is very impressive article, not so much of them are out there.
Thank you
I must be missing something but I can't found the /assets/webpack.config.js file... update needed?