应用PETAL(Phoenix、Elixir、TailwindCSS、AlpineJS、LiveView)技术栈构建一个简化版的Instagram Web应用程序

<!--more-->

在第 4 局部中,咱们增加了个人资料帖子局部和帖子页面,在这部分中,咱们将解决显示帖子页面。您能够赶上Instagram 克隆 GitHub Repo。

让咱们首先为显示页面增加根本模板,关上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 %>    </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>

关上assets/css/app.scss并将以下款式增加到文件底部,以使页面评论局部不显示滚动条:

/* Chrome, Safari and Opera */.no-scrollbar::-webkit-scrollbar {  display: none;}.no-scrollbar {  -ms-overflow-style: none;  /* IE and Edge */  scrollbar-width: none;  /* Firefox */}

喜爱

让咱们在终端中创立喜爱的上下文:

$ mix phx.gen.context Likes Like likes user_id:references:users liked_id:integer

在生成的迁徙中:

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])  endend

回到咱们的终端:$ mix ecto.migrate

外面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()  endend

将喜爱关系增加到帖子架构中,关上lib/instagram_clone/posts/post.ex

...has_many :likes, InstagramClone.Likes.Like, foreign_key: :liked_id...

将喜爱关系增加到用户架构中,关上lib/instagram_clone/accounts/user.ex

...has_many :likes, InstagramClone.Likes.Like...

外面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)  endend

让咱们创立一个组件来解决点赞,在上面lib/instagram_clone_web/live/post_live增加一个名为的文件like_component.ex并增加以下内容:

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)  endend

在第 50 行内lib/instagram_clone_web/live/post_live/show.html.leex,将蕴含心形图标的 div 替换为以下内容:

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

在外部lib/instagram_clone_web/live/post_live/show.ex咱们须要解决从组件发送的音讯以更新喜爱计数:

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

关上并lib/instagram_clone/posts.ex更新函数来预加载belongs_to等用户:get_post!() get_post_by_url()

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

发表评论

让咱们为评论创立一个评论上下文,在终端中输出以下命令:

$ mix phx.gen.context Comments Comment comments post_id:references:posts user_id:references:users body:text total_likes:integer

在生成的迁徙中:

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])  endend

回到咱们的终端:$ mix ecto.migrate

外面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])  endend

lib/instagram_clone/accounts/user.ex在和内增加以下内容lib/instagram_clone/posts/post.ex

...    has_many :comments, InstagramClone.Comments.Comment...

外面lib/instagram_clone/comments.ex增加以下函数:

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

让咱们更新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  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  endend

在上面lib/instagram_clone_web/live/post_live创立评论组件comment_component.ex

defmodule InstagramCloneWeb.PostLive.CommentComponent do  use InstagramCloneWeb, :live_component  alias InstagramClone.Uploaders.Avatarend

下的评论组件模板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>

最初更新一下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>

咱们增加了几个 AlpineJS 指令,以在文本区域为空时禁用评论提交按钮。

这部分就是这样,咱们在这个系列中学到了很多货色,还有很多工作要做,开发永无止境。

转自:Elixirprogrammer