Let's Build An Instagram Clone With The PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) Stack [PART 4]
User Posts
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>
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.