使用PETAL(Phoenix、Elixir、TailwindCSS、AlpineJS、LiveView)技术栈构建一个简化版的Instagram Web应用程序
<!--more-->
在第 6 部分中,我们添加了主页,在这部分中,我们将研究顶部标题导航菜单中的搜索功能。您可以赶上Instagram 克隆 GitHub Repo。
搜索功能将提供按用户名或全名搜索用户的能力,我们只需要一个包含头像 URL、用户名和全名的地图,让我们添加一个函数来在我们的帐户上下文中获取它。里面lib/instagram_clone/accounts.ex
:
...
def search_users(q) do
User
|> where([u], ilike(u.username, ^"%#{q}%"))
|> or_where([u], ilike(u.full_name, ^"%#{q}%"))
|> select([u], map(u, [:avatar_url, :username, :full_name]))
|> Repo.all()
end
...
我们将处理该事件以在标头导航组件 open 中进行搜索lib/instagram_clone_web/templates/layout/live.html.leex
,然后将 ID 发送到我们的组件以便能够处理该事件:
<%= if @current_user do %>
<%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, id: 1, current_user: @current_user %>
<% else %>
<%= if @live_action !== :root_path do %>
<%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, id: 1, current_user: @current_user %>
<% end %>
<% end %>
<main role="main" class="container mx-auto max-w-full md:w-11/12 2xl:w-6/12 pt-24">
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
<p class="alert alert-danger" role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
<%= @inner_content %>
</main>
在里面lib/instagram_clone_web/live/header_nav_component.html.leex
让我们使用 AlpineJs 打开 UL,当输入至少有一个字母时,我们将在其中显示结果,如果里面没有任何内容或单击输入,则不会显示任何内容。让我们使用phx-change
表单事件来运行我们的搜索,我们还将分配给我们的套接字 a@overflow_y_scroll_ul
以在结果大于 6 时显示滚动条。
<div x-data="{open: false, inputText: null}" class="w-2/5 flex justify-end relative">
<form id="search-users-form" phx-change="search_users" phx-target="<%= @myself %>">
<input
phx-debounce="800"
x-model="inputText"
x-on:input="[(inputText.length != 0) ? open = true : open = false]"
name="q"
type="search"
placeholder="Search"
autocomplete="off"
class="h-7 bg-gray-50 shadow-sm border-gray-300 focus:ring-gray-300 focus:ring-opacity-50 focus:border-gray-400 px-0.5 rounded-sm">
</form>
<ul
x-show="open"
@click.away="open = false"
class="<%= @overflow_y_scroll_ul %> absolute top-10 -right-24 w-96 shadow-md h-96 bg-gray-50">
</ul>
</div>
在我们将显示搜索结果的 UL 中,我们需要 3 个赋值,@searched_users
这将是我们将循环遍历的结果,@while_searching_users?
这将是一个布尔值,用于确定在连接正常的情况下何时显示加载指示器缓慢或需要一段时间,为了用户界面友好的反馈,@users_not_found?
另一个布尔值显示未找到结果消息。
<ul
x-show="open"
@click.away="open = false"
class="<%= @overflow_y_scroll_ul %> absolute top-10 -right-24 w-96 shadow-md h-96 bg-gray-50">
<%= for user <- @searched_users do %>
<%= live_redirect to: Routes.user_profile_path(@socket, :index, user.username) do %>
<li class="flex items-center px-4 py-3 hover:bg-gray-100">
<%= img_tag Avatar.get_thumb(user.avatar_url), class: "w-10 h-10 rounded-full object-cover object-center" %>
<div class="ml-3">
<h2 class="truncate font-bold text-sm text-gray-500"><%= user.username %></h2>
<h3 class="truncate text-sm text-gray-500"><%= user.full_name %></h3>
</div>
</li>
<% end %>
<% end %>
<%= if @while_searching_users? do %>
<li class="flex justify-center items-center h-full">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</li>
<% end %>
<%= if @users_not_found? do %>
<li class="text-sm text-gray-400 flex justify-center items-center h-full">No results found.</li>
<% end %>
</ul>
我们的更新lib/instagram_clone_web/live/header_nav_component.html.leex
应如下所示:
<div class="h-14 border-b-2 flex fixed w-full bg-white z-40">
<header class="flex items-center container mx-auto max-w-full md:w-11/12 2xl:w-6/12">
<%= live_redirect to: Routes.page_path(@socket, :index) do %>
<h1 class="text-2xl font-bold italic">#InstagramClone</h1>
<% end %>
<div x-data="{open: false, inputText: null}" class="w-2/5 flex justify-end relative">
<form id="search-users-form" phx-change="search_users" phx-target="<%= @myself %>">
<input
phx-debounce="800"
x-model="inputText"
x-on:input="[(inputText.length != 0) ? open = true : open = false]"
name="q"
type="search"
placeholder="Search"
autocomplete="off"
class="h-7 bg-gray-50 shadow-sm border-gray-300 focus:ring-gray-300 focus:ring-opacity-50 focus:border-gray-400 px-0.5 rounded-sm">
</form>
<ul
x-show="open"
@click.away="open = false"
class="<%= @overflow_y_scroll_ul %> absolute top-10 -right-24 w-96 shadow-md h-96 bg-gray-50">
<%= for user <- @searched_users do %>
<%= live_redirect to: Routes.user_profile_path(@socket, :index, user.username) do %>
<li class="flex items-center px-4 py-3 hover:bg-gray-100">
<%= img_tag Avatar.get_thumb(user.avatar_url), class: "w-10 h-10 rounded-full object-cover object-center" %>
<div class="ml-3">
<h2 class="truncate font-bold text-sm text-gray-500"><%= user.username %></h2>
<h3 class="truncate text-sm text-gray-500"><%= user.full_name %></h3>
</div>
</li>
<% end %>
<% end %>
<%= if @while_searching_users? do %>
<li class="flex justify-center items-center h-full">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</li>
<% end %>
<%= if @users_not_found? do %>
<li class="text-sm text-gray-400 flex justify-center items-center h-full">No results found.</li>
<% end %>
</ul>
</div>
<nav class="w-3/5 relative">
<ul x-data="{open: false}" class="flex justify-end">
<%= if @current_user do %>
<li class="w-7 h-7 text-gray-600">
<%= live_redirect to: Routes.page_path(@socket, :index) do %>
<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="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<% end %>
</li>
<li class="w-7 h-7 ml-6 text-gray-600">
<%= live_redirect to: Routes.live_path(@socket, InstagramCloneWeb.PostLive.New) do %>
<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="2" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<% end %>
</li>
<li class="w-7 h-7 ml-6 text-gray-600">
<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="2" 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>
</li>
<li class="w-7 h-7 ml-6 text-gray-600">
<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="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</li>
<li class="w-7 h-7 ml-6 text-gray-600">
<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="2" 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>
</li>
<li
@click="open = true"
class="w-7 h-7 ml-6 shadow-md rounded-full overflow-hidden cursor-pointer"
>
<%= img_tag InstagramClone.Uploaders.Avatar.get_thumb(@current_user.avatar_url),
class: "w-full h-full object-cover object-center" %>
</li>
<ul class="absolute top-14 w-56 bg-white shadow-md text-sm -right-8"
x-show="open"
@click.away="open = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-90"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-90"
>
<%= live_redirect to: Routes.user_profile_path(@socket, :index, @current_user.username) do %>
<li class="py-2 px-4 hover:bg-gray-50">Profile</li>
<% end %>
<li class="py-2 px-4 hover:bg-gray-50">Saved</li>
<%= live_redirect to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Settings) do %>
<li class="py-2 px-4 hover:bg-gray-50">Settings</li>
<% end %>
<%= link to: Routes.user_session_path(@socket, :delete), method: :delete do %>
<li class="border-t-2 py-2 px-4 hover:bg-gray-50">Log Out</li>
<% end %>
</ul>
<% else %>
<li>
<%= link "Log In", to: Routes.user_session_path(@socket, :new), class: "w-24 py-1 px-3 border-none shadow rounded text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 font-semibold" %>
</li>
<li>
<%= link "Sign Up", to: Routes.user_registration_path(@socket, :new), class: "w-24 py-1 px-3 border-none text-light-blue-500 hover:text-light-blue-600 font-semibold" %>
</li>
<% end %>
</ul>
</nav>
</header>
</div>
里面lib/instagram_clone_web/live/header_nav_component.ex
:
defmodule InstagramCloneWeb.HeaderNavComponent do
use InstagramCloneWeb, :live_component
alias InstagramClone.Uploaders.Avatar
@impl true
def mount(socket) do
{:ok,
socket
|> assign(while_searching_users?: false)
|> assign(users_not_found?: false)
|> assign(overflow_y_scroll_ul: "")
|> assign(searched_users: [])}
end
@impl true
def handle_event("search_users", %{"q" => search}, socket) do
if search == "" do
{:noreply, socket}
else
send(self(), {__MODULE__, :search_users_event, search})
{:noreply,
socket
|> assign(users_not_found?: false)
|> assign(searched_users: [])
|> assign(overflow_y_scroll_ul: "")
|> assign(while_searching_users?: true)}
end
end
end
在我们的处理事件函数中,首先,我们检查参数是否为空字符串,什么都不会发生。当参数不为空时,我们将发送一条带有搜索参数的消息,以在父 LiveView 中运行搜索,这样我们就可以在搜索时显示加载指示器,每次表单更改时,我们都必须重置我们的分配,设置while_searching_users?boolean true
以在搜索时显示加载指示器。
我们必须发送消息,因为如果我们尝试在标头导航组件套接字中执行此操作,则分配首先同时发生,因此如果我们这样做,我们将无法在搜索时以及在组件,我们无法将handle_info
其消息发送到父级并将父级中的分配更新回组件。
标题导航组件在每个页面上都使用,因此我们不必在每个 LiveView 上处理消息,而是为每个 LiveView 处理一次,在第lib/instagram_clone_web.ex
45 行函数内live_view()
添加以下内容:
...
def live_view do
quote do
use Phoenix.LiveView,
layout: {InstagramCloneWeb.LayoutView, "live.html"}
unquote(view_helpers())
import InstagramCloneWeb.LiveHelpers
alias InstagramClone.Accounts.User
alias InstagramClone.Accounts
@impl true
def handle_info(%{event: "logout_user", payload: %{user: %User{id: id}}}, socket) do
with %User{id: ^id} <- socket.assigns.current_user do
{:noreply,
socket
|> redirect(to: "/")
|> put_flash(:info, "Logged out successfully.")}
else
_any -> {:noreply, socket}
end
end
@impl true
def handle_info({InstagramCloneWeb.HeaderNavComponent, :search_users_event, search}, socket) do
case Accounts.search_users(search) do
[] ->
send_update(InstagramCloneWeb.HeaderNavComponent,
id: 1,
searched_users: [],
users_not_found?: true,
while_searching_users?: false
)
{:noreply, socket}
users ->
send_update(InstagramCloneWeb.HeaderNavComponent,
id: 1,
searched_users: users,
users_not_found?: false,
while_searching_users?: false,
overflow_y_scroll_ul: check_search_result(users)
)
{:noreply, socket}
end
end
defp check_search_result(users) do
if length(users) > 6, do: "overflow-y-scroll", else: ""
end
end
end
...
我们使用在帐户上下文中添加的功能创建一个案例,我们send_update/3添加到标题导航组件中,while_searching_users?
在每个案例上设置为 false,以便在搜索完成时不显示加载指示器。
就是这样,现在你有了一个功能齐全的搜索输入,还有很多事情要做,很多可以添加的功能,但我们已经走了很长一段路,我们有一个值得我们自豪的大应用程序,直到下一个时间。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。