使用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" %>
<%= 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 */
}
喜欢
让我们在终端中创建喜欢的上下文:
$ 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 @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 指令,以在文本区域为空时禁用评论提交按钮。
这部分就是这样,我们在这个系列中学到了很多东西,还有很多工作要做,开发永无止境。