使用 Phoenix LiveView 构建 Instagram (8)

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

在第 7 部分中,我们在顶部标题导航菜单中添加了搜索功能,在这部分中,我们将研究书签功能,并在以下内容向我们的主页添加新帖子时通知用户。您可以赶上Instagram 克隆 GitHub Repo。

当我们尝试创建未选择图像的新帖子时,让我们处理错误,为此,我们需要在内部的保存句柄函数中正确进行模式匹配lib/instagram_clone_web/live/post_live/new.ex

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: post}} -> # <- THIS LINE WAS UPDATED
        PostUploader.save(socket)

        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully")
         |> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))}
         |> push_redirect(to: Routes.live_path(socket, InstagramCloneWeb.PostLive.Show, post.url_id))}

      {:error, :post, %Ecto.Changeset{} = changeset, %{}} -> # <- THIS LINE WAS UPDATED
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

因为我们用来Ecto.Multi更新用户的帖子计数并创建帖子,所以在结果中我们必须进行相应的模式匹配。

现在,在lib/instagram_clone_web/live/post_live/new.html.leex第 23 行中添加一个 div 来显示错误:photo_url

  
<%= error_tag f, :photo_url, class: "text-red-700 block" %>

每次创建新帖子时,我们都会使用 phoenix pubsub 向主页实时视图发送消息,这样我们就可以显示一个 div,单击该 div 将重新加载实时视图。里面lib/instagram_clone/posts.ex添加以下内容:

 @pubsub_topic "new_posts_added"

  def pubsub_topic, do: @pubsub_topic

  def subscribe do
    InstagramCloneWeb.Endpoint.subscribe(@pubsub_topic)
  end

在里面lib/instagram_clone_web/live/post_live/new.ex让我们发送消息:

 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: post}} -> # <- THIS LINE WAS UPDATED
        PostUploader.save(socket)
        
        send_msg(post)
        
        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully")
         |> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))}
         |> push_redirect(to: Routes.live_path(socket, InstagramCloneWeb.PostLive.Show, post.url_id))}

      {:error, :post, %Ecto.Changeset{} = changeset, %{}} -> # <- THIS LINE WAS UPDATED
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

  defp send_msg(post) do
    # Broadcast that new post was added
    InstagramCloneWeb.Endpoint.broadcast_from(
      self(),
      Posts.pubsub_topic,
      "new_post",
      %{
        post: post
      }
    )
  end

在里面lib/instagram_clone_web/live/page_live.ex让我们处理将要发送的消息:

alias  InstagramClone.Posts.Post

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    if connected?(socket), do: Posts.subscribe

    {:ok,
      socket
      |> assign(page_title: "InstagraClone")
      |> assign(new_posts_added: false)
      |> assign(page: 1, per_page: 15),
      temporary_assigns: [user_feed: []]}
  end

  @impl true
  def handle_info(%{event: "new_post", payload: %{post: %Post{user_id: post_user_id}}}, socket) do
    if post_user_id in socket.assigns.following_list do
      {:noreply, socket |> assign(new_posts_added: true)}
    else
      {:noreply, socket}
    end
  end

在我们的 mount 函数中,我们订阅 pubsub 主题并分配页面标题,并new_posts_added确定是否必须在模板中显示 div。在我们的例子中handle_info,我们接收用户的消息和模式匹配以获取用户 ID,然后检查该用户 ID 是否在分配给套接字的当前用户的以下列表中,如果是,则设为new_posts_addedtrue在我们下面的列表中。

在第 2 行中lib/instagram_clone_web/live/page_live.html.leex添加以下内容:

 <%= if @new_posts_added do %>
    
<%= live_redirect to: Routes.page_path(@socket, :index), class: "user-profile-follow-btn" do %> Load New Posts <% end %>
<% end %>

现在,当我们关注的用户在我们的主页上添加新帖子时,我们会收到通知。

帖子书签

转到终端,让我们创建一个架构来处理帖子书签:

mix phx.gen.schema Posts.Bookmarks posts_bookmarks user_id:references:users post_id:references:posts

在生成的迁移中:

defmodule InstagramClone.Repo.Migrations.CreatePostsBookmarks do
  use Ecto.Migration

  def change do
    create table(:posts_bookmarks) do
      add :user_id, references(:users, on_delete: :delete_all)
      add :post_id, references(:posts, on_delete: :delete_all)

      timestamps()
    end

    create index(:posts_bookmarks, [:user_id])
    create index(:posts_bookmarks, [:post_id])
  end
end

里面lib/instagram_clone/posts/bookmarks.ex

defmodule InstagramClone.Posts.Bookmarks do
  use Ecto.Schema

  schema "posts_bookmarks" do
    belongs_to :user, InstagramClone.Accounts.User
    belongs_to :post, InstagramClone.Posts.Post

    timestamps()
  end

end

里面lib/instagram_clone/accounts/user.ex和lib/instagram_clone/posts/post.ex

  has_many :posts_bookmarks, InstagramClone.Posts.Bookmarks

更新lib/instagram_clone/posts.ex如下:

defmodule InstagramClone.Posts do
  @moduledoc """
  The Posts context.
  """

  import Ecto.Query, warn: false
  alias InstagramClone.Repo

  alias InstagramClone.Posts.Post
  alias InstagramClone.Accounts.User
  alias InstagramClone.Comments.Comment
  alias InstagramClone.Likes.Like
  alias InstagramClone.Posts.Bookmarks

  @pubsub_topic "new_posts_added"

  def pubsub_topic, do: @pubsub_topic

  def subscribe do
    InstagramCloneWeb.Endpoint.subscribe(@pubsub_topic)
  end
  @doc """
  Returns the list of posts.

  ## Examples

      iex> list_posts()
      [%Post{}, ...]

  """
  def list_posts do
    Repo.all(Post)
  end

  @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

  def list_saved_profile_posts(page: page, per_page: per_page, user_id: user_id) do
    Bookmarks
    |> where(user_id: ^user_id)
    |> join(:inner, [b], p in assoc(b, :post))
    |> select([b, p], %{url_id: p.url_id, photo_url: p.photo_url})
    |> limit(^per_page)
    |> offset(^((page - 1) * per_page))
    |> order_by(desc: :id)
    |> Repo.all
  end
  @doc """
  Returns the list of paginated posts of a given user id
  And posts of following list of given user id
  With user and likes preloaded
  With 2 most recent comments preloaded with user and likes
  User, page, and per_page are given with the socket assigns

  ## Examples

      iex> get_accounts_feed(following_list, assigns)
      [%{photo_url: "", url_id: ""}, ...]

  """
  def get_accounts_feed(following_list, assigns) do
    user = assigns.current_user
    page = assigns.page
    per_page = assigns.per_page
    query =
      from c in Comment,
      select: %{id: c.id, row_number: over(row_number(), :posts_partition)},
      windows: [posts_partition: [partition_by: :post_id, order_by: [desc: :id]]]
    comments_query =
      from c in Comment,
      join: r in subquery(query),
      on: c.id == r.id and r.row_number <= 2
    likes_query = Like |> select([l], l.user_id)
    bookmarks_query = Bookmarks |> select([b], b.user_id)

    Post
    |> where([p], p.user_id in ^following_list)
    |> or_where([p], p.user_id == ^user.id)
    |> limit(^per_page)
    |> offset(^((page - 1) * per_page))
    |> order_by(desc: :id)
    |> preload([:user, posts_bookmarks: ^bookmarks_query, likes: ^likes_query, comments: ^{comments_query, [:user, likes: likes_query]}])
    |> Repo.all()
  end

  def get_accounts_feed_total(following_list, assigns) do
    user = assigns.current_user

    Post
    |> where([p], p.user_id in ^following_list)
    |> or_where([p], p.user_id == ^user.id)
    |> select([p], count(p.id))
    |> Repo.one()
  end

  @doc """
  Gets a single post.

  Raises `Ecto.NoResultsError` if the Post does not exist.

  ## Examples

      iex> get_post!(123)
      %Post{}

      iex> get_post!(456)
      ** (Ecto.NoResultsError)

  """
  def get_post!(id) do
    likes_query = Like |> select([l], l.user_id)
    bookmarks_query = Bookmarks |> select([b], b.user_id)

    Repo.get!(Post, id)
    |> Repo.preload([:user, posts_bookmarks: bookmarks_query, likes: likes_query])
  end

  def get_post_feed!(id) do
    query =
      from c in Comment,
      select: %{id: c.id, row_number: over(row_number(), :posts_partition)},
      windows: [posts_partition: [partition_by: :post_id, order_by: [desc: :id]]]
    comments_query =
      from c in Comment,
      join: r in subquery(query),
      on: c.id == r.id and r.row_number <= 2
    likes_query = Like |> select([l], l.user_id)
    bookmarks_query = Bookmarks |> select([b], b.user_id)

    Post
    |> preload([:user, posts_bookmarks: ^bookmarks_query, likes: ^likes_query, comments: ^{comments_query, [:user, likes: likes_query]}])
    |> Repo.get!(id)
  end

  def get_post_by_url!(id) do
    likes_query = Like |> select([l], l.user_id)
    bookmarks_query = Bookmarks |> select([b], b.user_id)

    Repo.get_by!(Post, url_id: id)
    |> Repo.preload([:user, posts_bookmarks: bookmarks_query, likes: likes_query])
  end

  @doc """
  Creates a post.

  ## Examples

      iex> create_post(%{field: value})
      {:ok, %Post{}}

      iex> create_post(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

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

  @doc """
  Updates a post.

  ## Examples

      iex> update_post(post, %{field: new_value})
      {:ok, %Post{}}

      iex> update_post(post, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_post(%Post{} = post, attrs) do
    post
    |> Post.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a post.

  ## Examples

      iex> delete_post(post)
      {:ok, %Post{}}

      iex> delete_post(post)
      {:error, %Ecto.Changeset{}}

  """
  def delete_post(%Post{} = post) do
    Repo.delete(post)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking post changes.

  ## Examples

      iex> change_post(post)
      %Ecto.Changeset{data: %Post{}}

  """
  def change_post(%Post{} = post, attrs \\ %{}) do
    Post.changeset(post, attrs)
  end

  # Returns nil if not found
  def bookmarked?(user_id, post_id) do
    Repo.get_by(Bookmarks, [user_id: user_id, post_id: post_id])
  end

  def create_bookmark(user, post) do
    user = Ecto.build_assoc(user, :posts_bookmarks)
    post = Ecto.build_assoc(post, :posts_bookmarks, user)

    Repo.insert(post)
  end

  def unbookmark(bookmarked?) do
    Repo.delete(bookmarked?)
  end

  def count_user_saved(user) do
    Bookmarks
    |> where(user_id: ^user.id)
    |> select([b], count(b.id))
    |> Repo.one
  end
end

添加了以下功能:

  • list_saved_profile_posts/3获取所有已分页的已保存帖子。
  • bookmarked?/2检查书签是否存在。
  • create_bookmark/2创建书签。
  • unbookmark/1删除书签。
  • count_user_saved/1获取给定用户的已保存帖子总数。

另外,对于所有获取帖子功能,我们正在预加载帖子书签列表,因此我们可以将该列表发送到书签组件以设置我们要用于该功能的按钮。

在里面lib/instagram_clone_web/live/post_live/创建一个名为的文件bookmark_component.ex并添加以下内容:

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

  alias InstagramClone.Posts

  @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
    post = socket.assigns.post
    bookmarked? = Posts.bookmarked?(current_user.id, post.id)

    if bookmarked? do
      unbookmark(socket, bookmarked?)
    else
      bookmark(socket, current_user, post)
    end
  end

  defp unbookmark(socket, bookmarked?) do
    Posts.unbookmark(bookmarked?)

    {:noreply,
      socket
      |> assign(icon: bookmark_icon(socket.assigns))}
  end

  defp bookmark(socket, current_user, post) do
    Posts.create_bookmark(current_user, post)

    {:noreply,
      socket
      |> assign(icon: bookmarked_icon(socket.assigns))}
  end

  defp get_btn_status(socket, assigns) do
    if assigns.current_user.id in assigns.post.posts_bookmarks do
      get_socket_assigns(socket, assigns, bookmarked_icon(assigns))
    else
      get_socket_assigns(socket, assigns, bookmark_icon(assigns))
    end
  end

  defp get_socket_assigns(socket, assigns, icon) do
    {:ok,
      socket
      |> assign(assigns)
      |> assign(icon: icon)}
  end

  defp bookmark_icon(assigns) do
    ~L"""
    
      
    
    """
  end

  defp bookmarked_icon(assigns) do
    ~L"""
    
      
    
    """
  end

end

在第 94行将lib/instagram_clone_web/live/post_live/show.html.leex带有书签图标的 div 更改为以下内容:

        <%= if @current_user do %>
          <%= live_component @socket,
              InstagramCloneWeb.PostLive.BookmarkComponent,
              id: @post.id,
              post: @post,
              current_user: @current_user %>
        <% else %>
          <%= link to: Routes.user_session_path(@socket, :new), class: "w-8 h-8 ml-auto focus:outline-none" do %>
            
              
            
          <% end %>
        <% end %>

在第 41 行内lib/instagram_clone_web/live/page_post_feed_component.html.leex,将包含书签图标的 div 更改为以下内容:

      <%= live_component @socket,
              InstagramCloneWeb.PostLive.BookmarkComponent,
              id: @post.id,
              post: @post,
              current_user: @current_user %>

在第 72 行内部lib/instagram_clone_web/router.ex添加以下路由:

     live "/:username/saved", UserLive.Profile, :saved

第 102 行内部lib/instagram_clone_web/live/header_nav_component.html.leex

              <%= live_redirect to: Routes.user_profile_path(@socket, :saved, @current_user.username) do %>
                
  • Saved
  • <% end %>

    更新lib/instagram_clone_web/live/user_live/profile.ex如下:

    defmodule InstagramCloneWeb.UserLive.Profile do
      use InstagramCloneWeb, :live_view
    
      alias InstagramClone.Accounts
      alias InstagramCloneWeb.UserLive.FollowComponent
      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})"),
          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
    
      defp assign_saved_posts(socket) do
        socket
        |> assign(posts:
          Posts.list_saved_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 = get_total_posts_count(socket)
        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))
          |> get_posts()
        end
      end
    
      defp get_total_posts_count(socket) do
        if socket.assigns.saved_page? do
          Posts.count_user_saved(socket.assigns.user)
        else
          socket.assigns.user.posts_count
        end
      end
    
      defp get_posts(socket) do
        if socket.assigns.saved_page? do
          assign_saved_posts(socket)
        else
          assign_posts(socket)
        end
      end
    
      @impl true
      def handle_params(_params, _uri, socket) do
        {:noreply, apply_action(socket, socket.assigns.live_action)}
      end
    
      @impl true
      def handle_info({FollowComponent, :update_totals, updated_user}, socket) do
        {:noreply, apply_msg_action(socket, socket.assigns.live_action, updated_user)}
      end
    
      defp apply_msg_action(socket, :follow_component, updated_user) do
        socket |> assign(user: updated_user)
      end
    
      defp apply_msg_action(socket, _, _updated_user) do
        socket
      end
    
      defp apply_action(socket, :index) do
        selected_link_styles = "text-gray-600 border-t-2 border-black -mt-0.5"
        live_action = get_live_action(socket.assigns.user, socket.assigns.current_user)
    
        socket
        |> assign(selected_index: selected_link_styles)
        |> assign(selected_saved: "text-gray-400")
        |> assign(saved_page?: false)
        |> assign(live_action: live_action)
        |> show_saved_profile_link?()
        |> assign_posts()
      end
    
      defp apply_action(socket, :saved) do
        selected_link_styles = "text-gray-600 border-t-2 border-black -mt-0.5"
    
        socket
        |> assign(selected_index: "text-gray-400")
        |> assign(selected_saved: selected_link_styles)
        |> assign(live_action: :edit_profile)
        |> assign(saved_page?: true)
        |> show_saved_profile_link?()
        |> redirect_when_not_my_saved()
        |> assign_saved_posts()
      end
    
      defp apply_action(socket, :following) do
        following = Accounts.list_following(socket.assigns.user)
        socket |> assign(following: following)
      end
    
      defp apply_action(socket, :followers) do
        followers = Accounts.list_followers(socket.assigns.user)
        socket |> assign(followers: followers)
      end
    
      defp redirect_when_not_my_saved(socket) do
        username = socket.assigns.current_user.username
    
        if socket.assigns.my_saved? do
          socket
        else
          socket
          |> push_redirect(to: Routes.user_profile_path(socket, :index, username))
        end
      end
    
      defp show_saved_profile_link?(socket) do
        user = socket.assigns.user
        current_user = socket.assigns.current_user
    
        if current_user && current_user.id == user.id do
          socket |> assign(my_saved?: true)
        else
          socket |> assign(my_saved?: false)
        end
      end
    
      defp get_live_action(user, current_user) do
        cond do
          current_user && current_user.id == user.id -> :edit_profile
          current_user -> :follow_component
          true -> :login_btn
        end
      end
    
    end

    添加了以下功能:

    • assign_posts/1获取并分配个人资料保存的帖子。
    • apply_action(socket, :saved)在保存路线页面时分配保存的帖子,并live_action分配:edit_profile以显示编辑个人资料按钮。
    • redirect_when_not_my_saved/1当尝试直接转到不属于当前用户的已保存配置文件时重定向。
    • show_saved_profile_link?/1指定my_saved?当前用户是否拥有配置文件。
    • get_total_posts_count/1以确定我们必须获得的帖子总数。
    • get_posts/1以确定要获取哪些帖子。

    我们不再在挂载函数中分配帖子,而是在索引和保存的操作中完成。此外,在这些函数中,我们分配链接样式,并saved_page?确定当页脚中的钩子被触发时我们必须加载更多的帖子。

    更新lib/instagram_clone_web/live/user_live/profile.html.leex如下:

    <%= if @live_action == :following do %>
      <%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowingComponent,
        width: "w-1/4",
        current_user: @current_user,
        following: @following,
        return_to: Routes.user_profile_path(@socket, :index, @user.username) %>
    <% end %>
    
    <%= if @live_action == :followers do %>
      <%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowersComponent,
        width: "w-1/4",
        current_user: @current_user,
        followers: @followers,
        return_to: Routes.user_profile_path(@socket, :index, @user.username) %>
    <% end %>
    
    
    <%= img_tag @user.avatar_url, class: "w-40 h-40 rounded-full object-cover object-center" %>

    <%= @user.username %>

    <%= if @live_action == :edit_profile do %> <%= live_patch "Edit Profile", to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Settings), class: "py-1 px-2 border-2 rounded font-semibold hover:bg-gray-50" %> <% end %> <%= if @live_action == :follow_component do %> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: @user.id, user: @user, current_user: @current_user %> <% end %> <%= if @live_action == :login_btn do %> <%= link "Follow", to: Routes.user_session_path(@socket, :new), class: "user-profile-follow-btn" %> <% end %>
    • <%= @user.posts_count %> Posts
    • <%= live_patch to: Routes.user_profile_path(@socket, :followers, @user.username) do %>
    • <%= @user.followers_count %> Followers
    • <% end %> <%= live_patch to: Routes.user_profile_path(@socket, :following, @user.username) do %>
    • <%= @user.following_count %> Following
    • <% end %>

    <%= @user.full_name %>

    <%= if @user.bio do %>

    <%= @user.bio %>

    <% end %> <%= if @user.website do %> <%= link display_website_uri(@user.website), to: @user.website, target: "_blank", rel: "noreferrer", class: "text-blue-700" %> <% end %>
      <%= live_redirect to: Routes.user_profile_path(@socket, :index, @user.username) do %>
    • POSTS
    • <% end %>
    • IGTV
    • <%= if @my_saved? do %> <%= live_redirect to: Routes.user_profile_path(@socket, :saved, @user.username) do %>
    • SAVED
    • <% end %> <% end %>
    • TAGGED
    <%= 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 %>

    添加了帖子和保存的链接,仅当当前用户拥有该配置文件时才会显示保存的链接,并且我们在加载更多页脚中添加了一个加载图标。

    转自:Elixirprogrammer

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