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
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>
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.
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
Top comments (0)