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

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

User Posts

Subscribe to my newsletter and never miss my upcoming articles

In part 3 we added the profile page and the ability to follow and display accounts, in this part, we will work on user's posts. You can catch up with the Instagram Clone GitHub Repo.

Let's start by adding a route to display a form to add posts, open lib/instagram_clone_web/router.ex:

  scope "/", InstagramCloneWeb do
    pipe_through :browser

    live "/", PageLive, :index
    live "/:username", UserLive.Profile, :index
    live "/p/:id", PostLive.Show # <-- THIS LINE WAS ADDED
  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
    live "/:username/followers", UserLive.Profile, :followers
    live "/p/new", PostLive.New # <-- THIS LINE WAS ADDED
  end

Create our liveview files inside lib/instagram_clone_web/live/post_live folder:

lib/instagram_clone_web/live/post_live/new.ex lib/instagram_clone_web/live/post_live/new.html.leex lib/instagram_clone_web/live/post_live/show.ex lib/instagram_clone_web/live/post_live/show.html.leex

Inside lib/instagram_clone_web/live/post_live/new.ex:

defmodule InstagramCloneWeb.PostLive.New do
  use InstagramCloneWeb, :live_view

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)

    {:ok,
      socket
      |> assign(page_title: "New Post")}
  end
end

Open lib/instagram_clone_web/live/header_nav_component.html.leex on line 18 let's use our new route:


  <%= live_redirect to: Routes.live_path(@socket, InstagramCloneWeb.PostLive.New)  do  %>

Let's create a posts context, go to the terminal:

$ mix phx.gen.context Posts Post posts url_id:string description:text photo_url:string user_id:references:users total_likes:integer total_comments:integer

Open the migration that was generated and add the following:

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

  def change do
    create table(:posts) do
      add :url_id, :string
      add :description, :text
      add :photo_url, :string
      add :total_likes, :integer, default: 0
      add :total_comments, :integer, default: 0
      add :user_id, references(:users, on_delete: :nothing)

      timestamps()
    end

    create index(:posts, [:user_id])
    create unique_index(:posts, [:url_id])
  end
end

Back to the terminal: $ mix ecto.migrate

Let's also add a posts count to the user schema, back in the terminal:

$ mix ecto.gen.migration adds_posts_count_to_users

Open the migration that was generated and add the following:

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

  def change do
    alter table(:users) do
      add :posts_count, :integer, default: 0
    end
  end
end

Back to the terminal: $ mix ecto.migrate

Open lib/instagram_clone/accounts/user.ex and let's edit our schema to the following:


  @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
    field :posts_count,  :integer,  default:  0 # <-- THIS LINE WAS ADDED
    has_many :following, Follows,  foreign_key:  :follower_id
    has_many :followers, Follows,  foreign_key:  :followed_id
    has_many :posts, InstagramClone.Posts.Post # <-- THIS LINE WAS ADDED
    timestamps()
  end

Open lib/instagram_clone/posts/post.ex add the following:

defmodule InstagramClone.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :description, :string
    field :photo_url, :string
    field :url_id, :string
    field :total_likes, :integer, default: 0
    field :total_comments, :integer, default: 0
    belongs_to :user, InstagramClone.Accounts.User

    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:url_id, :description, :photo_url])
    |> validate_required([:url_id, :photo_url])
  end
end

Let's add our new schema, and allow uploads inside lib/instagram_clone_web/live/post_live/new.ex:

defmodule InstagramCloneWeb.PostLive.New do
  use InstagramCloneWeb, :live_view

  alias InstagramClone.Posts.Post
  alias InstagramClone.Posts

  @extension_whitelist ~w(.jpg .jpeg .png)

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)

    {:ok,
      socket
      |> assign(page_title: "New Post")
      |> assign(changeset: Posts.change_post(%Post{}))
      |> allow_upload(:photo_url,
      accept: @extension_whitelist,
      max_file_size: 30_000_000)}
  end

  @impl true
  def handle_event("validate", %{"post" => post_params}, socket) do
    changeset =
      Posts.change_post(%Post{}, post_params)
      |> Map.put(:action, :validate)

    {:noreply, socket |> assign(changeset: changeset)}
  end

  def handle_event("cancel-entry", %{"ref" => ref}, socket) do
    {:noreply, cancel_upload(socket, :photo_url, ref)}
  end
end

Open config/dev.exs edit line 61 to the following:

~r"priv/static/[^uploads].*(js|css|png|jpeg|jpg|gif|svg)$",

That configuration avoids live reload from reloading the uploads folder every time that we upload a file because otherwise, you will run into weird behaviors when trying to upload.

Add the following inside lib/instagram_clone_web/live/post_live/new.html.leex:

<div class="flex flex-col w-1/2 mx-auto">
  <h2 class="text-xl font-bold text-gray-600"><%= @page_title %></h2>

  <%= f = form_for @changeset, "#",
    class: "mt-8",
    phx_change: "validate",
    phx_submit: "save" %>

    <%= for {_ref, err} <- @uploads.photo_url.errors do %>
        <p class="alert alert-danger"><%= Phoenix.Naming.humanize(err) %></p>
    <% end %>

    <div class="border border-dashed border-gray-500 relative" phx-drop-target="<%= @uploads.photo_url.ref %>">
      <%= live_file_input @uploads.photo_url, class: "cursor-pointer relative block opacity-0 w-full h-full p-20 z-30" %>
      <div class="text-center p-10 absolute top-0 right-0 left-0 m-auto">
          <h4>
              Drop files anywhere to upload
              <br/>or
          </h4>
          <p class="">Select Files</p>
      </div>
    </div>

    <%= for entry <- @uploads.photo_url.entries do %>
      <div class="my-8 flex items-center">
        <div>
          <%= live_img_preview entry, height: 250, width: 250 %>
        </div>
        <div class="px-4">
          <progress max="100" value="<%= entry.progress %>" />
        </div>
        <span><%= entry.progress %>%</span>
        <div class="px-4">
          <a href="#" class="text-red-600 text-lg font-semibold" phx-click="cancel-entry" phx-value-ref="<%= entry.ref %>">cancel</a>
        </div>
      </div>
    <% end %>

    <div class="mt-6">
      <%= label f, :description, class: "font-semibold" %>
    </div>
    <div class="mt-3">
      <%= textarea f, :description, class: "w-full border-2 border-gray-400 rounded p-1 text-semibold text-gray-500 focus:ring-transparent focus:border-gray-600", rows: 5 %>
      <%= error_tag f, :description, class: "text-red-700 text-sm block" %>
    </div>

    <div class="mt-6">
      <%= submit "Submit",
        phx_disable_with: "Saving...",
        class: "py-2 px-6 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>

  </form>
</div>

  Alt Text  

Under lib/instagram_clone_web/live/uploaders create a file named post.ex add the following inside that file:

defmodule InstagramClone.Uploaders.Post do
  alias InstagramCloneWeb.Router.Helpers, as: Routes

  alias InstagramClone.Posts.Post

  @upload_directory_name "uploads"
  @upload_directory_path "priv/static/uploads"

  defp ext(entry) do
    [ext | _] = MIME.extensions(entry.client_type)
    ext
  end

  def put_image_url(socket, %Post{} = post) do
    {completed, []} = Phoenix.LiveView.uploaded_entries(socket, :photo_url)
    urls =
      for entry <- completed do
        Routes.static_path(socket, "/#{@upload_directory_name}/#{entry.uuid}.#{ext(entry)}")
      end

    %Post{post | photo_url: List.to_string(urls)}
  end

  def save(socket) do
    if !File.exists?(@upload_directory_path), do: File.mkdir!(@upload_directory_path)

    Phoenix.LiveView.consume_uploaded_entries(socket, :photo_url, fn meta, entry ->
      dest = Path.join(@upload_directory_path, "#{entry.uuid}.#{ext(entry)}")
      File.cp!(meta.path, dest)
    end)

    :ok
  end

end

Open lib/instagram_clone/posts.ex edit the create_post() and add a private function to put the url id:

...

  def create_post(%Post{} = post, attrs \\ %{}, user) do
    post = Ecto.build_assoc(user, :posts, put_url_id(post))
    changeset = Post.changeset(post, attrs)
    update_posts_count = from(u in User, where: u.id == ^user.id)

    Ecto.Multi.new()
    |> Ecto.Multi.update_all(:update_posts_count, update_posts_count, inc: [posts_count:  1])
    |> Ecto.Multi.insert(:post, changeset)
    |> Repo.transaction()
  end

  # Generates a base64-encoding 8 bytes
  defp put_url_id(post) do
    url_id = Base.encode64(:crypto.strong_rand_bytes(8), padding: false)

    %Post{post | url_id: url_id}
  end

...

Add to lib/instagram_clone_web/live/post_live/new.ex the following event handler function:

  alias InstagramClone.Uploaders.Post, as: PostUploader


  def handle_event("save", %{"post" => post_params}, socket) do
    post = PostUploader.put_image_url(socket, %Post{})
    case Posts.create_post(post, post_params, socket.assigns.current_user) do
      {:ok, post} ->
        PostUploader.save(socket, post)

        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully")
         |> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

Open lib/instagram_clone_web/live/user_live/profile.html.leex on line 52 let's display our posts count:

<li><b><%= @user.posts_count %></b> Posts</li>

Now let's create a function to get the profile posts and paginate the results with infinite scroll, open lib/instagram_clone/posts.ex:


...


  @doc """
  Returns the list of paginated posts of a given user id.

  ## Examples

      iex> list_user_posts(page: 1, per_page: 10, user_id: 1)
      [%{photo_url: "", url_id: ""}, ...]

  """
  def list_profile_posts(page: page, per_page: per_page, user_id: user_id) do
    Post
    |> select([p], map(p, [:url_id, :photo_url]))
    |> where(user_id: ^user_id)
    |> limit(^per_page)
    |> offset(^((page - 1) * per_page))
    |> order_by(desc: :id)
    |> Repo.all
  end

...

Open lib/instagram_clone_web/live/user_live/profile.ex and let's assign the posts:


...

  alias InstagramClone.Posts

  @impl true
  def mount(%{"username" => username}, session, socket) do
    socket = assign_defaults(session, socket)
    user = Accounts.profile(username)

    {:ok,
      socket
      |> assign(page: 1, per_page: 15)
      |> assign(user: user)
      |> assign(page_title: "#{user.full_name} (@#{user.username})")
      |> assign_posts(),
      temporary_assigns: [posts: []]}
  end

  defp assign_posts(socket) do
    socket
    |> assign(posts:
      Posts.list_profile_posts(
        page: socket.assigns.page,
        per_page: socket.assigns.per_page,
        user_id: socket.assigns.user.id
      )
    )
  end

  @impl true
  def handle_event("load-more-profile-posts", _, socket) do
    {:noreply, socket |> load_posts}
  end

  defp load_posts(socket) do
    total_posts = socket.assigns.user.posts_count
    page = socket.assigns.page
    per_page = socket.assigns.per_page
    total_pages = ceil(total_posts / per_page)

    if page == total_pages do
      socket
    else
      socket
      |> update(:page, &(&1 + 1))
      |> assign_posts()
    end
  end

...

Everything stays the same, we just assign the page and set the limit per page, then assign the profile posts in our mount() function. We added an event handler function that's going to get trigger with a javascript hook in our template, it will load more pages if not the last page.

Open lib/instagram_clone_web/live/user_live/profile.html.leex at the following at the bottom of the file:


...

<!-- Gallery Grid -->
<div id="posts" phx-update="append" class="mt-9 grid gap-8 grid-cols-3">
  <%= for post <- @posts do %>
    <%= live_redirect img_tag(post.photo_url, class: "object-cover h-80 w-full"),
      id: post.url_id,
      to: Routes.live_path(@socket, InstagramCloneWeb.PostLive.Show, post.url_id) %>
  <% end %>
</div>

<div
  id="profile-posts-footer"
  class="flex justify-center"
  phx-hook="ProfilePostsScroll">
</div>

We are appending each new page to the posts div, and there's an empty div at the bottom that every time that is visible triggers the event to load more pages.

Open assets/js/app.js and let's add our hook:


...

let Hooks = {}

Hooks.ProfilePostsScroll = {
  mounted() {
    this.observer = new IntersectionObserver(entries => {
      const entry = entries[0];
      if (entry.isIntersecting) {  
        this.pushEvent("load-more-profile-posts");
      }
    });

    this.observer.observe(this.el);
  },
  destroyed() {
    this.observer.disconnect();
  },
}

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

...

We are using an observer to push an event to load more posts every time that the empty footer div is reached or visible.

  InstagramProfilePostsPage

Open lib/instagram_clone/posts.ex and let's add a function to get the posts by the url id:


...

  def get_post_by_url!(id) do 
    Repo.get_by!(Post, url_id: id)
    |> Repo.preload(:user)
  end
...

Let's assign the post in our mount function inside lib/instagram_clone_web/live/post_live/show.ex:

defmodule InstagramCloneWeb.PostLive.Show do
  use InstagramCloneWeb, :live_view

  alias InstagramClone.Posts
  alias InstagramClone.Uploaders.Avatar

  @impl true
  def mount(%{"id" => id}, session, socket) do
    socket = assign_defaults(session, socket)
    post = Posts.get_post_by_url!(URI.decode(id))

    {:ok, socket |> assign(post: post)}
  end
end

We are decoding the URL ID because back in our profile template when we do live_redirect the post URL ID gets encoded. The Base.encode64 that we use to generate the ids, sometimes results in special characters like / that need to get encoded in our URL.

That's it for this part, this is a work in progress. In the next part, we will work with the show-post page.

I really appreciate your time, thank you so much for reading.

 

CHECKOUT THE INSTAGRAM CLONE GITHUB REPO

 
Share this