Let's Build An Instagram Clone With The PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) Stack [PART 5]
Show-Post Page
In part 4 we added the profile posts section and post page, in this part, we will work on the show-post page. You can catch up with the Instagram Clone GitHub Repo.
Let's start by adding our base template for our show page, open lib/instagram_clone_web/live/post_live/show.html.leex
and add the following:
<section class="flex">
<!-- Post Image section -->
<%= img_tag @post.photo_url,
class: "w-3/5 object-contain h-full" %>
<!-- End Post Image section -->
<div class="w-2/5 border-2 h-full">
<div class="flex p-4 items-center border-b-2">
<!-- Post header section -->
<%= live_redirect to: Routes.user_profile_path(@socket, :index, @post.user.username) do %>
<%= img_tag @post.user.avatar_url, class: "w-8 h-8 rounded-full object-cover object-center" %>
<% end %>
<div class="ml-3">
<%= live_redirect @post.user.username,
to: Routes.user_profile_path(@socket, :index, @post.user.username),
class: "truncate font-bold text-sm text-gray-500 hover:underline" %>
</div>
<!-- End post header section -->
</div>
<div class="no-scrollbar h-96 overflow-y-scroll p-4 flex flex-col">
<%= if @post.description do %>
<!-- Description section -->
<div class="flex mt-2">
<%= live_redirect to: Routes.user_profile_path(@socket, :index, @post.user.username) do %>
<%= img_tag Avatar.get_thumb(@post.user.avatar_url), class: "w-8 h-8 rounded-full object-cover object-center" %>
<% end %>
<div class="px-4 w-11/12">
<%= live_redirect @post.user.username,
to: Routes.user_profile_path(@socket, :index, @post.user.username),
class: "font-bold text-sm text-gray-500 hover:underline" %>
<span class="text-sm text-gray-700">
<p class="inline"><%= @post.description %></p></span>
</span>
<div class="flex mt-3">
<div class="text-gray-400 text-xs"><%= Timex.from_now @post.inserted_at %></div>
</div>
</div>
</div>
<!-- End Description Section -->
<% end %>
</div>
<div class="w-full border-t-2">
<!-- Action icons section -->
<div class="flex pl-4 pr-2 pt-2">
<div class="w-8 h-8 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<svg class="hidden text-red-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-4 w-8 h-8 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<div class="ml-4 w-8 h-8 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</div>
<div class="w-8 h-8 ml-auto cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
</div>
<!-- End Action icons section -->
<!-- Description section -->
<button class="px-5 text-xs text-gray-500 font-bold focus:outline-none"><%= @post.total_likes %> likes</button>
<h6 class="px-5 text-xs text-gray-400"><%= Timex.format!(@post.inserted_at, "{Mfull} {D}, {YYYY}") %></h6>
<!-- End Description Section -->
<!-- Comment input section -->
<div class="p-2 flex items-center mt-3 border-t-2 border-gray-100">
<div class="w-full">
<textarea
aria-label="Add a comment..."
placeholder="Add a comment..."
class="w-full border-0 focus:ring-transparent resize-none"
autocomplete="off"
autocorrect="off"
rows="1"></textarea>
</div>
<div><button class="text-light-blue-500 font-bold pb-2 text-sm">Post</button></div>
</div>
<!-- End Comment input section -->
</div>
</div>
</section>
Open assets/css/app.scss
and add the following styles to the bottom of the file to not show the scrollbar on the comments section of the page:
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
Likes
Let's create the likes context, in our terminal:
$ mix phx.gen.context Likes Like likes user_id:references:users liked_id:integer
Inside the migration that was generated:
defmodule InstagramClone.Repo.Migrations.CreateLikes do
use Ecto.Migration
def change do
create table(:likes) do
add :liked_id, :integer
add :user_id, references(:users, on_delete: :nothing)
timestamps()
end
create index(:likes, [:user_id, :liked_id])
end
end
Back in our terminal: $ mix ecto.migrate
Inside lib/instagram_clone/likes/like.ex
:
defmodule InstagramClone.Likes.Like do
use Ecto.Schema
schema "posts_likes" do
field :liked_id, :integer
belongs_to :user, InstagramClone.Accounts.User
timestamps()
end
end
Add the likes relationship to the post schema, open lib/instagram_clone/posts/post.ex
:
...
has_many :likes, InstagramClone.Likes.Like, foreign_key: :liked_id
...
Add the likes relationship to the user schema, open lib/instagram_clone/accounts/user.ex
:
...
has_many :likes, InstagramClone.Likes.Like
...
Inside lib/instagram_clone/likes.ex
:
defmodule InstagramClone.Likes do
import Ecto.Query, warn: false
alias InstagramClone.Repo
alias InstagramClone.Likes.Like
def create_like(user, liked) do
user = Ecto.build_assoc(user, :likes)
like = Ecto.build_assoc(liked, :likes, user)
update_total_likes = liked.__struct__ |> where(id: ^liked.id)
Ecto.Multi.new()
|> Ecto.Multi.insert(:like, like)
|> Ecto.Multi.update_all(:update_total_likes, update_total_likes, inc: [total_likes: 1])
|> Repo.transaction()
end
def unlike(user_id, liked) do
like = get_like(user_id, liked)
update_total_likes = liked.__struct__ |> where(id: ^liked.id)
Ecto.Multi.new()
|> Ecto.Multi.delete(:like, like)
|> Ecto.Multi.update_all(:update_total_likes, update_total_likes, inc: [total_likes: -1])
|> Repo.transaction()
end
# Returns nil if not found
defp get_like(user_id, liked) do
Enum.find(liked.likes, fn l ->
l.user_id == user_id
end)
end
end
Let's create a component to handle likes, under lib/instagram_clone_web/live/post_live
add a file named like_component.ex
and add the following:
defmodule InstagramCloneWeb.PostLive.LikeComponent do
use InstagramCloneWeb, :live_component
alias InstagramClone.Likes
@impl true
def update(assigns, socket) do
get_btn_status(socket, assigns)
end
@impl true
def render(assigns) do
~L"""
<button
phx-target="<%= @myself %>"
phx-click="toggle-status"
class="<%= @w_h %> focus:outline-none">
<%= @icon %>
</button>
"""
end
@impl true
def handle_event("toggle-status", _params, socket) do
current_user = socket.assigns.current_user
liked = socket.assigns.liked
if liked?(current_user.id, liked.likes) do
unlike(socket, current_user.id, liked)
else
like(socket, current_user, liked)
end
end
defp like(socket, current_user, liked) do
Likes.create_like(current_user, liked)
send_msg(liked)
{:noreply,
socket
|> assign(icon: unlike_icon(socket.assigns))}
end
defp unlike(socket, current_user_id, liked) do
Likes.unlike(current_user_id, liked)
send_msg(liked)
{:noreply,
socket
|> assign(icon: like_icon(socket.assigns))}
end
defp send_msg(liked) do
msg = get_struct_msg_atom(liked)
send(self(), {__MODULE__, msg, liked.id})
end
defp get_btn_status(socket, assigns) do
if liked?(assigns.current_user.id, assigns.liked.likes) do
get_socket_assigns(socket, assigns, unlike_icon(assigns))
else
get_socket_assigns(socket, assigns, like_icon(assigns))
end
end
defp get_socket_assigns(socket, assigns, icon) do
{:ok,
socket
|> assign(assigns)
|> assign(icon: icon)}
end
defp get_struct_name(struct) do
struct.__struct__
|> Module.split()
|> List.last()
|> String.downcase()
end
defp get_struct_msg_atom(struct) do
name = get_struct_name(struct)
update_struct_likes = "update_#{name}_likes"
String.to_atom(update_struct_likes)
end
defp like_icon(assigns) do
~L"""
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
"""
end
defp unlike_icon(assigns) do
~L"""
<svg class="text-red-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" />
</svg>
"""
end
# Returns true if id found in list
defp liked?(user_id, likes) do
Enum.any?(likes, fn l ->
l.user_id == user_id
end)
end
end
Inside lib/instagram_clone_web/live/post_live/show.html.leex
on line 50, replace the div containing the heart icon with the following:
...
<%= if @current_user do %>
<%= live_component @socket,
InstagramCloneWeb.PostLive.LikeComponent,
id: @post.id,
liked: @post,
w_h: "w-8 h-8",
current_user: @current_user %>
<% else %>
<%= link to: Routes.user_session_path(@socket, :new) do %>
<button class="w-8 h-8 focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
<% end %>
<% end %>
...
Inside lib/instagram_clone_web/live/post_live/show.ex
we need to handle the message sent from the component to update the likes count:
...
alias InstagramCloneWeb.PostLive.LikeComponent
@impl true
def handle_info({LikeComponent, :update_post_likes, post_id}, socket) do
{:noreply,
socket
|> assign(post: Posts.get_post!(post_id))}
end
Open lib/instagram_clone/posts.ex
and let's update get_post!()
and get_post_by_url()
functions to preload the user that belongs_to and the likes:
...
def get_post!(id) do
Repo.get!(Post, id)
|> Repo.preload([:user, :likes])
end
def get_post_by_url!(id) do
Repo.get_by!(Post, url_id: id)
|> Repo.preload([:user, :likes])
end
...
Post Comments
Let's create a comments context for comments, in our terminal type in the following command:
$ mix phx.gen.context Comments Comment comments post_id:references:posts user_id:references:users body:text total_likes:integer
Inside the migration that was generated:
defmodule InstagramClone.Repo.Migrations.CreateComments do
use Ecto.Migration
def change do
create table(:comments) do
add :body, :text
add :total_likes, :integer, default: 0
add :post_id, references(:posts, on_delete: :nothing)
add :user_id, references(:users, on_delete: :nothing)
timestamps()
end
create index(:comments, [:post_id])
create index(:comments, [:user_id])
end
end
Back in our terminal: $ mix ecto.migrate
Inside lib/instagram_clone/comments/comment.ex
:
defmodule InstagramClone.Comments.Comment do
use Ecto.Schema
import Ecto.Changeset
schema "comments" do
field :body, :string
field :total_likes, :integer, default: 0
belongs_to :post, InstagramClone.Posts.Post
belongs_to :user, InstagramClone.Accounts.User
has_many :likes, InstagramClone.Likes.Like, foreign_key: :liked_id
timestamps()
end
@doc false
def changeset(comment, attrs) do
comment
|> cast(attrs, [:body])
|> validate_required([:body])
end
end
Add the following inside lib/instagram_clone/accounts/user.ex
and lib/instagram_clone/posts/post.ex
:
...
has_many :comments, InstagramClone.Comments.Comment
...
Inside lib/instagram_clone/comments.ex
add the followings functions:
...
@doc """
Returns paginated comments sorted by current user id or by id if public
"""
def list_post_comments(assigns, public: public) do
user = assigns.current_user
post_id = assigns.post.id
per_page = assigns.per_page
page = assigns.page
Comment
|> where(post_id: ^post_id)
|> get_post_comments_sorting(public, user)
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> preload([:user, :likes])
|> Repo.all
end
defp get_post_comments_sorting(module, public, user) do
if public do
order_by(module, asc: :id)
else
order_by(module, fragment("(CASE WHEN user_id = ? then 1 else 2 end)", ^user.id))
end
end
@doc """
Gets a single comment.
Raises `Ecto.NoResultsError` if the Comment does not exist.
## Examples
iex> get_comment!(123)
%Comment{}
iex> get_comment!(456)
** (Ecto.NoResultsError)
"""
def get_comment!(id) do
Repo.get!(Comment, id)
|> Repo.preload([:user, :likes])
end
@doc """
Creates a comment and updates total comments count in post
Returns the comment created with likes preloaded
"""
def create_comment(user, post, attrs \\ %{}) do
update_total_comments = post.__struct__ |> where(id: ^post.id)
comment_attrs = %Comment{} |> Comment.changeset(attrs)
comment =
comment_attrs
|> Ecto.Changeset.put_assoc(:user, user)
|> Ecto.Changeset.put_assoc(:post, post)
Ecto.Multi.new()
|> Ecto.Multi.update_all(:update_total_comments, update_total_comments, inc: [total_comments: 1])
|> Ecto.Multi.insert(:comment, comment)
|> Repo.transaction()
|> case do
{:ok, %{comment: comment}} ->
comment |> Repo.preload(:likes)
end
end
...
Let's update lib/instagram_clone_web/live/post_live/show.ex
to the following:
defmodule InstagramCloneWeb.PostLive.Show do
use InstagramCloneWeb, :live_view
alias InstagramClone.Posts
alias InstagramClone.Uploaders.Avatar
alias InstagramCloneWeb.PostLive.LikeComponent
alias InstagramClone.Comments
alias InstagramClone.Comments.Comment
@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(changeset: Comments.change_comment(%Comment{}))
|> assign(comments_section_update: "prepend")
|> assign(post: post)
|> assign(page: 1, per_page: 15)
|> assign_comments()
|> set_load_more_comments_btn(),
temporary_assigns: [comments: []]}
end
defp assign_comments(socket) do
current_user = socket.assigns.current_user
if current_user do
comments = Comments.list_post_comments(socket.assigns, public: false)
socket |> assign(comments: comments)
else
comments = Comments.list_post_comments(socket.assigns, public: true)
socket |> assign(comments: comments)
end
end
defp set_load_more_comments_btn(socket) do
post_total_comments = socket.assigns.post.total_comments
per_page = socket.assigns.per_page
if post_total_comments > per_page do
socket |> assign(load_more_comments_btn: "flex")
else
socket |> assign(load_more_comments_btn: "hidden")
end
end
@impl true
def handle_info({LikeComponent, :update_comment_likes, comment_id}, socket) do
comment = Comments.get_comment!(comment_id)
{:noreply,
socket
|> update(:comments, fn comments -> [comment | comments] end)}
end
@impl true
def handle_info({LikeComponent, :update_post_likes, post_id}, socket) do
{:noreply,
socket
|> assign(post: Posts.get_post!(post_id))}
end
@impl true
def handle_event("load-more-comments", _, socket) do
{:noreply,
socket
|> assign(comments_section_update: "append")
|> load_comments()}
end
@impl true
def handle_event("save", %{"comment" => comment_param}, socket) do
%{"body" => body} = comment_param
current_user = socket.assigns.current_user
post = socket.assigns.post
if body == "" do
{:noreply, socket}
else
comment = Comments.create_comment(current_user, post, comment_param)
{:noreply,
socket
|> update(:comments, fn comments -> [comment | comments] end)
|> assign(comments_section_update: "prepend")
|> assign(changeset: Comments.change_comment(%Comment{}))}
end
end
defp load_comments(socket) do
total_comments = socket.assigns.post.total_comments
page = socket.assigns.page
per_page = socket.assigns.per_page
total_pages = ceil(total_comments / per_page)
socket
|> hide_btn?(page, total_pages)
|> update(:page, &(&1 + 1))
|> assign_comments()
end
defp hide_btn?(socket, page, total_pages) do
if (page + 1) == total_pages do
socket |> assign(load_more_comments_btn: "hidden")
else
socket
end
end
end
Under lib/instagram_clone_web/live/post_live
create the comment component comment_component.ex
:
defmodule InstagramCloneWeb.PostLive.CommentComponent do
use InstagramCloneWeb, :live_component
alias InstagramClone.Uploaders.Avatar
end
The comment component template under lib/instagram_clone_web/live/post_live/comment_component.html.leex
:
<div class="flex py-2" id="comment-<%= @comment.id %>">
<div class="w-1/12 pt-1">
<%= live_redirect to: Routes.user_profile_path(@socket, :index, @comment.user.username) do %>
<%= img_tag Avatar.get_thumb(@comment.user.avatar_url),
class: "w-8 h-8 rounded-full object-cover object-center" %>
<% end %>
</div>
<div class="px-4 w-10/12">
<%= live_redirect @comment.user.username,
to: Routes.user_profile_path(@socket, :index, @comment.user.username),
class: "truncate font-bold text-sm text-gray-500 hover:underline" %>
<span class="text-sm text-gray-700">
<p class="inline"><%= @comment.body %></p></span>
</span>
<div class="flex mt-3">
<div class="text-gray-400 text-xs"><%= Timex.from_now @comment.inserted_at %></div>
<button class="px-3 text-xs text-gray-700 focus:outline-none"><%= @comment.total_likes %> likes</button>
<button class="text-xs text-gray-700 focus:outline-none">Reply</button>
</div>
</div>
<%= if @current_user do %>
<%= live_component @socket,
InstagramCloneWeb.PostLive.LikeComponent,
id: @comment.id,
liked: @comment,
w_h: "w-6 h-6",
current_user: @current_user %>
<% else %>
<%= link to: Routes.user_session_path(@socket, :new) do %>
<button class="w-6 h-6 focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
<% end %>
<% end %>
</div>
Lastly let's update lib/instagram_clone_web/live/post_live/show.html.leex
:
<section class="flex">
<!-- Post Image section -->
<%= img_tag @post.photo_url,
class: "w-3/5 object-contain h-full" %>
<!-- End Post Image section -->
<div class="w-2/5 border-2 h-full">
<div class="flex p-4 items-center border-b-2">
<!-- Post header section -->
<%= live_redirect to: Routes.user_profile_path(@socket, :index, @post.user.username) do %>
<%= img_tag @post.user.avatar_url, class: "w-8 h-8 rounded-full object-cover object-center" %>
<% end %>
<div class="ml-3">
<%= live_redirect @post.user.username,
to: Routes.user_profile_path(@socket, :index, @post.user.username),
class: "truncate font-bold text-sm text-gray-500 hover:underline" %>
</div>
<!-- End post header section -->
</div>
<div class="no-scrollbar h-96 overflow-y-scroll p-4 flex flex-col">
<%= if @post.description do %>
<!-- Description section -->
<div class="flex mt-2">
<%= live_redirect to: Routes.user_profile_path(@socket, :index, @post.user.username) do %>
<%= img_tag Avatar.get_thumb(@post.user.avatar_url), class: "w-8 h-8 rounded-full object-cover object-center" %>
<% end %>
<div class="px-4 w-11/12">
<%= live_redirect @post.user.username,
to: Routes.user_profile_path(@socket, :index, @post.user.username),
class: "font-bold text-sm text-gray-500 hover:underline" %>
<span class="text-sm text-gray-700">
<p class="inline"><%= @post.description %></p></span>
</span>
<div class="flex mt-3">
<div class="text-gray-400 text-xs"><%= Timex.from_now @post.inserted_at %></div>
</div>
</div>
</div>
<!-- End Description Section -->
<% end %>
<section id="comments" phx-update="<%= @comments_section_update %>">
<%= for comment <- @comments do %>
<%= live_component @socket,
InstagramCloneWeb.PostLive.CommentComponent,
id: comment.id,
current_user: @current_user,
comment: comment %>
<% end %>
</section>
<button
class="w-full <%= @load_more_comments_btn %> justify-center pt-2 focus:outline-none"
phx-click="load-more-comments">
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
<div class="w-full border-t-2">
<!-- Action icons section -->
<div class="flex pl-4 pr-2 pt-2">
<%= if @current_user do %>
<%= live_component @socket,
InstagramCloneWeb.PostLive.LikeComponent,
id: @post.id,
liked: @post,
w_h: "w-8 h-8",
current_user: @current_user %>
<% else %>
<%= link to: Routes.user_session_path(@socket, :new) do %>
<button class="w-8 h-8 focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
<% end %>
<% end %>
<div class="ml-4 w-8 h-8 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<div class="ml-4 w-8 h-8 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</div>
<div class="w-8 h-8 ml-auto cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
</div>
<!-- End Action icons section -->
<!-- Description section -->
<button class="px-5 text-xs text-gray-500 font-bold focus:outline-none"><%= @post.total_likes %> likes</button>
<h6 class="px-5 text-xs text-gray-400"><%= Timex.format!(@post.inserted_at, "{Mfull} {D}, {YYYY}") %></h6>
<!-- End Description Section -->
<!-- Comment input section -->
<%= if @current_user do %>
<%= f = form_for @changeset, "#",
phx_submit: "save",
class: "p-2 flex items-center mt-3 border-t-2 border-gray-100",
x_data: "{
disableSubmit: true,
inputText: null,
displayCommentBtn: (refs) => {
refs.cbtn.classList.remove('opacity-30')
refs.cbtn.classList.remove('cursor-not-allowed')
},
disableCommentBtn: (refs) => {
refs.cbtn.classList.add('opacity-30')
refs.cbtn.classList.add('cursor-not-allowed')
}
}" %>
<div class="w-full">
<%= textarea f, :body,
class: "w-full border-0 focus:ring-transparent resize-none",
rows: 1,
placeholder: "Add a comment...",
aria_label: "Add a comment...",
autocorrect: "off",
autocomplete: "off",
x_model: "inputText",
"@input": "[
(inputText.length != 0) ? [disableSubmit = false, displayCommentBtn($refs)] : [disableSubmit = true, disableCommentBtn($refs)]
]" %>
</div>
<div>
<%= submit "Post",
phx_disable_with: "Posting...",
class: "text-light-blue-500 opacity-30 cursor-not-allowed font-bold pb-2 text-sm focus:outline-none",
x_ref: "cbtn",
"@click": "inputText = null",
"x_bind:disabled": "disableSubmit" %>
</div>
</form>
<% else %>
<div class="p-4 flex items-center mt-3 border-t-2 border-gray-100">
<%= link "Log in to comment",
to: Routes.user_session_path(@socket, :new),
class: "text-light-blue-600" %>
</div>
<% end %>
<!-- End Comment input section -->
</div>
</div>
</section>
We added a couple of AlpineJS directives to disable the submit button for comments when the textarea is empty.
That's it for this part, we have learned a lot throughout this series, still a lot of work to do, development never ends.
I really appreciate your time, thank you so much for reading.