DEV Community

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

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

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

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.

 

User Posts

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 %> 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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

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

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> 
Enter fullscreen mode Exit fullscreen mode

 
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 
Enter fullscreen mode Exit fullscreen mode

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 ... 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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 ... 
Enter fullscreen mode Exit fullscreen mode

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 ... 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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) } } } }) ... 
Enter fullscreen mode Exit fullscreen mode

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 ... 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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

 
 
 

Join The Elixir Army

Top comments (0)