使用 Phoenix LiveView 构建 Instagram (5)

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

在第 4 部分中,我们添加了个人资料帖子部分和帖子页面,在这部分中,我们将处理显示帖子页面。您可以赶上Instagram 克隆 GitHub Repo。

让我们首先为显示页面添加基本模板,打开lib/instagram_clone_web/live/post_live/show.html.leex并添加以下内容:

<%= img_tag @post.photo_url, class: "w-3/5 object-contain h-full" %>
<%= 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 %>
<%= 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" %>
<%= if @post.description do %>
<%= 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 %>
<%= 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" %>

<%= @post.description %>

<%= Timex.from_now @post.inserted_at %>
<% end %>
<%= Timex.format!(@post.inserted_at, "{Mfull} {D}, {YYYY}") %>

打开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 */
}

instagram-phoenix-p5-1.jpg

喜欢

让我们在终端中创建喜欢的上下文:

$ 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])
  end
end

回到我们的终端:$ 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()
  end
end

将喜欢关系添加到帖子架构中,打开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)
  end
end

让我们创建一个组件来处理点赞,在下面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"""
    
    """
  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"""
    
      
    
    """
  end

  defp unlike_icon(assigns) do
    ~L"""
    
      
    
    """
  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

在第 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 %>
            
          <% 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])
  end
end

回到我们的终端:$ 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])
  end
end

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

在下面lib/instagram_clone_web/live/post_live创建评论组件comment_component.ex

defmodule InstagramCloneWeb.PostLive.CommentComponent do
  use InstagramCloneWeb, :live_component

  alias InstagramClone.Uploaders.Avatar
end

下的评论组件模板lib/instagram_clone_web/live/post_live/comment_component.html.leex

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

<%= @comment.body %>

<%= Timex.from_now @comment.inserted_at %>
<%= 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 %> <% end %> <% end %>

最后更新一下lib/instagram_clone_web/live/post_live/show.html.leex

<%= img_tag @post.photo_url, class: "w-3/5 object-contain h-full" %>
<%= 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 %>
<%= 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" %>
<%= if @post.description do %>
<%= 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 %>
<%= 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" %>

<%= @post.description %>

<%= Timex.from_now @post.inserted_at %>
<% end %>
<%= for comment <- @comments do %> <%= live_component @socket, InstagramCloneWeb.PostLive.CommentComponent, id: comment.id, current_user: @current_user, comment: comment %> <% end %>
<%= 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 %> <% end %> <% end %>
<%= Timex.format!(@post.inserted_at, "{Mfull} {D}, {YYYY}") %>
<%= 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') } }" %>
<%= 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)] ]" %>
<%= 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" %>
<% else %>
<%= link "Log in to comment", to: Routes.user_session_path(@socket, :new), class: "text-light-blue-600" %>
<% end %>

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

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

转自:Elixirprogrammer

你可能感兴趣的:(使用 Phoenix LiveView 构建 Instagram (5))