Let's Build An Instagram Clone With The PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) Stack [PART 4]
Tags: elixir, webdev, programming, tutorial
Published: 2021-05-04 17:49:14

In [part 3](https://elixirprogrammer.com/learn/lets-build-an-instagram-clone-with-the-petal-phoenix-elixir-tailwindcss-alpinejs-liveview-stack-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](https://github.com/elixirprogrammer/InstagramClonePETAL).

&nbsp;

### User Posts

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

```elixir
  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`:

```elixir
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:

```elixir

  <%= 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:

```elixir
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:

```elixir
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:

```elixir

  @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:

```elixir
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`:

```elixir
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:
```elixir
~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`:

```html
<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>
```

&nbsp;
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n1apyf6aqo81o76j8xc2.jpg)
&nbsp;

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

```elixir
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:

```elixir
...

  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:

```elixir
  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:

```html
<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`:

```elixir

...


  @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:

```elixir

...

  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:

```html

...

<!-- 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:

```javascript

...

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.

&nbsp;
![InstagramProfilePostsPage](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8f4z1kh7h8yk4ovxgyzu.jpg)


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

```elixir

...

  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`:

```elixir
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.

&nbsp;

### CHECKOUT THE [INSTAGRAM CLONE GITHUB REPO](https://github.com/elixirprogrammer/InstagramClonePETAL)

&nbsp;
&nbsp;
&nbsp;

[![Join The Elixir Army](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cv2q172ztuekdtw82vo9.png)](https://elixirprogrammer.com)