使用 Phoenix LiveView 构建 Instagram (1)

发布时间 2023-09-01 09:41:50作者: MarkHoo

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

更好的学习方法是亲自动手构建东西,让我们使用很棒的 PETAL(Phoenix、Elixir、TailwindCSS、AlpineJS、LiveView)堆栈构建一个简化版的 Instagram Web 应用程序,并深入了解函数式的黑暗世界编程和最热门的孩子在凤凰框架与LiveView。

我不认为自己是一名老师,也不是任何方面的专家,我只是一个像你一样的普通人。任何人都可以遵循,即使您可能会被整个堆栈吓倒,这是一种新技术,不是很流行,而且没有很多资源和材料。如果您是一位经验丰富的开发人员,那么您不会有任何问题,这并不意味着如果您是初学者,您就无法跟上,我会尽力使其对初学者友好,但我不会详细介绍堆栈的每个基础知识或网络开发,所以你已经被警告了。

Elixir 是我有幸学习和尝试的最好的语言之一,我想与世界分享我的热情,我希望其他人能感受到我对这门语言的感受。

免责声明: Elixir、函数式编程、Phoenix 框架,可能听起来、看起来很困难和复杂,但它根本不是,比其他任何东西都容易,它可能不适合每个人,因为我们的想法并不相同,但对于那些认为就像我尝试的感觉一样。TailwindCSS 可能有些固执己见,看起来不值得尝试,我知道,因为我也是这么感觉的,但只要尝试一下,你用得越多,它就会变得越有意义,你就会越喜欢它,它让 CSS 变得不复杂,让你不放弃前端开发,CSS仍然会很痛苦,作为开发者,我们没有耐心把UI弄好,但它是一股新鲜空气。

我们不会在本文中完成整个项目,这将是一系列文章,因此这将是第 1 部分。我假设您有自己的开发环境,安装了 Elixir,我的开发环境是在带有 WSL 的 Windows 10 上。我们将尽力尽可能详细,但保持简单,这仅用于学习目的,因此它不会是精确的副本,也不会具有所有功能,我们将尽可能接近真实的东西,也我们不会专注于使网站具有响应能力,我们只会使其适用于大屏幕。

让我们首先转到终端并使用 LiveView 创建一个新的 Phoenix 应用程序。

$ mix phx.new instagram_clone --live

安装并获取所有依赖项后。

$ cd instagram_clone && mix ecto.create

我创建了一个 GitHub 存储库,您可以在此处访问Instagram 克隆 GitHub 存储库,您可以随意使用代码,欢迎贡献。

让我们运行服务器以确保一切正常。

$ iex -S mix phx.server

如果没有错误,当您访问 http://localhost:4000/ 时,您应该会看到默认的 Phoenix 框架主页

我使用 Visual Studio Code,因此我将使用以下命令打开项目文件夹。

$ code .

现在让我们在 mix.exs 文件中添加混合依赖项。

# mix.exs file

  defp deps do
    [
      {:phoenix, "~> 1.5.6"},
      {:phoenix_ecto, "~> 4.1"},
      {:ecto_sql, "~> 3.4"},
      {:postgrex, ">= 0.0.0"},
      {:floki, ">= 0.27.0", only: :test},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_dashboard, "~> 0.3 or ~> 0.2.9"},
      {:telemetry_metrics, "~> 0.4"},
      {:telemetry_poller, "~> 0.4"},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, "~> 0.15.4", override: true},
      {:timex, "~> 3.6"},
      {:faker, "~> 0.16.0"}
    ]
  end

我们将 :phoenix_live_view 更新到 15.4 版本,并添加了 timex 来处理时间,并在需要测试数据时添加 faker。

设置 TailwindCSS 和 AlpineJS

确保拥有最新的 Node 和 npm 版本。

$ cd assets

$ npm i tailwindcss postcss autoprefixer postcss-loader@4.2 --save-dev

接下来让我们配置 Webpack、PostCSS 和 TailwindCSS。

// /assets/webpack.config.js

use: [
  MiniCssExtractPlugin.loader,
  'css-loader',
  'sass-loader',
  'postcss-loader', // Add this
],

添加包含/assets/postcss.config.js以下内容的文件:

// /assets/postcss.config.js

module.exports = {
    plugins: {
        "postcss-import": {},
        tailwindcss: {},
        autoprefixer: {}
    }
}

创建 TailwindCSS 配置文件。

$ npx tailwindcss init

将以下配置添加到该文件中:

const colors = require('tailwindcss/colors')

module.exports = {
  purge: {
    enabled: process.env.NODE_ENV === "production",
    content: [
      "../lib/**/*.eex",
      "../lib/**/*.leex",
      "../lib/**/*_view.ex"
    ],
    options: {
      whitelist: [/phx/, /nprogress/]
    }
  },
  theme: {
    extend: {
      colors: {
        'light-blue': colors.lightBlue,
        cyan: colors.cyan,
      },
    },
  },
  variants: {
    extend: {
      borderWidth: ['hover'],
    }
  },
  plugins: [require('@tailwindcss/forms')],
}

我们配置要清除的文件,添加自定义颜色和自定义表单插件。现在让我们将自定义表单添加到 npm 依赖项中。

$ npm i @tailwindcss/forms --save-dev

对于自定义组件冲突,让我们添加 postcss-import 插件。

$ npm i postcss-import --save-dev

转到/assets/css/app.scss文件顶部并添加以下内容:

/* This file is for your main application css. */
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

删掉/assets/css/phoenix.css我们就不需要了

让我们退出资产文件夹$ cd ..并运行我们的服务器$ iex -S mix phx.server

通过/lib/instagram_clone_web/live/page_live.html.leex删除所有内容并添加以下内容来测试它:

<h1 class="text-red-500 text-5xl font-bold text-center">Instagram Clone</h1>

我们的主页上应该有一个红色的大标题。

删除/lib/instagram_clone_web/live/page_live.ex所有内容,因为我们的主页中不需要任何内容,然后添加以下内容:

defmodule InstagramCloneWeb.PageLive do
  use InstagramCloneWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end

/lib/instagram_clone_web/templates/layout/root.html.leex删除默认的phoenix标头,该文件上应该有以下内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <%= csrf_meta_tag() %>
    <%= live_title_tag assigns[:page_title] || "InstagramClone", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </head>
  <body>
    <!-- Remove Everything Above Here -->
    <%= @inner_content %>
  </body>
</html>

现在让我们用 tailwind 自定义我们的主容器,转到/lib/instagram_clone_web/templates/layout/live.html.leex主标签并将以下类添加到主标签中:

<main role="main" class="container mx-auto max-w-full md:w-11/12 2xl:w-6/12 pt-24"> <!-- This the class that we added -->
  <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>

添加 AlpineJS

TailwindCSS 准备就绪后,让我们添加 AlpineJS。让我们再次进入我们的$ cd assets文件夹并运行以下命令:

$ npm i alpinejs@2.8.2

打开找到的app.js文件/assets/js/app.js并添加以下内容,这样我们就不会与LiveView自己的DOM修补发生任何冲突:

import Alpine from "alpinejs"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  dom: {
    onBeforeElUpdated(from, to) {
      if (from.__x) { Alpine.clone(from.__x, to) }
    }
  }
})

让我们退出资产文件夹$ cd ..并运行我们的服务器$ iex -S mix phx.server

测试一下购买并将/lib/instagram_clone_web/live/page_live.html.leex以下内容添加到我们的文件顶部:

<div x-data="{ open: false }">
    <button @click="open = true">Open Dropdown</button>

    <ul
        x-show="open"
        @click.away="open = false"
    >
        Dropdown Body
    </ul>
</div>

如果我们进入主页,我们应该有一个可点击的下拉菜单,如下例所示:

instagram-phoenix-p1-1.gif

Phx.Gen.Auth

完成这些设置后,真正的乐趣就开始了。让我们使用 phx.gen.auth 包添加用户身份验证。

让我们将包添加到我们的mix.exs文件中。

 defp deps do
    [
      {:phoenix, "~> 1.5.6"},
      {:phoenix_ecto, "~> 4.1"},
      {:ecto_sql, "~> 3.4"},
      {:postgrex, ">= 0.0.0"},
      {:floki, ">= 0.27.0", only: :test},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_dashboard, "~> 0.3 or ~> 0.2.9"},
      {:telemetry_metrics, "~> 0.4"},
      {:telemetry_poller, "~> 0.4"},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, "~> 0.15.4", override: true},
      {:timex, "~> 3.6"},
      {:faker, "~> 0.16.0"},
      {:phx_gen_auth, "~> 0.7", only: [:dev], runtime: false}
    ]
  end

安装并编译依赖项

$ mix do deps.get, deps.compile

使用以下命令安装身份验证系统:

$ mix phx.gen.auth Accounts User users

生成所有文件后运行以下命令:

$ mix deps.get && mix ecto.migrate

现在我们需要通过运行以下命令向用户表添加一些字段:

$ mix ecto.gen.migration add_to_users_table

然后打开生成的文件priv/repo/migrations/20210409223611_add_to_users_table.exs并添加以下内容:

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

  def change do
    alter table(:users) do
      add :username, :string
      add :full_name, :string
      add :avatar_url, :string
      add :bio, :string
      add :website, :string
    end
  end
end

然后$ mix ecto.migrate

接下来打开lib/instagram_clone/accounts/user.ex并将以下内容添加到您的用户架构中:

field :username, :string
field :full_name, :string
field :avatar_url, :string, default: "/images/default-avatar.png"
field :bio, :string
field :website, :string

instagram-phoenix-p1-2.png

下载上面的默认头像图像并将其重命名为default-avatar.png并将该图像添加到priv/static/images

现在我们需要为新用户架构添加验证,因此lib/instagram_clone/accounts/user.ex再次打开并将其更改registration_changeset为以下内容:

  def registration_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email, :password, :username, :full_name, :avatar_url, :bio, :website])
    |> validate_required([:username, :full_name])
    |> validate_length(:username, min: 5, max: 30)
    |> validate_format(:username, ~r/^[a-zA-Z0-9_.-]*$/, message: "Please use letters and numbers without space(only characters allowed _ . -)")
    |> unique_constraint(:username)
    |> validate_length(:full_name, min: 4, max: 30)
    |> validate_email()
    |> validate_password(opts)
  end

此外,我们需要更改我们的validate_password函数,以便在更新用户帐户时,我们不需要验证或散列密码,因此将其更改为以下内容:

  defp validate_password(changeset, opts) do
    register_user? = Keyword.get(opts, :register_user, true)
    if register_user? do
      changeset
      |> validate_required([:password])
      |> validate_length(:password, min: 6, max: 80)
      # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
      # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
      # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
      |> maybe_hash_password(opts)
    else
      changeset
    end
  end

更新用户帐户时,我们将向register_user: false变更集发送一个选项。此外,出于开发目的,最小密码长度已更改为 6,但应在生产中更改。

让我们运行我们的服务器$ iex -S mix phx.server并打开/lib/instagram_clone_web/live/page_live.html.leex我们的主页样式以添加注册表单。

在执行此操作之前,我们必须删除 phx.gen.auth 自动生成的身份验证链接,因此请转到正文顶部/lib/instagram_clone_web/templates/layout/root.html.leex并将其删除。<%= render "_user_menu.html", assigns %>

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <%= csrf_meta_tag() %>
    <%= live_title_tag assigns[:page_title] || "InstagramClone", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </head>
  <body>
    <%= render "_user_menu.html", assigns %><!-- REMOVE IT -->
    <%= @inner_content %>
  </body>
</html>

最后,删除/lib/instagram_clone_web/templates/layout/_user_menu.html.eex部分文件,我们不需要它。

好的,现在回来/lib/instagram_clone_web/live/page_live.html.leex添加以下内容:

<section class="w-1/2 border-2 shadow-lg flex flex-col place-items-center mx-auto p-6">
    <h1 class="text-4xl font-bold italic text-gray-600">InstagramClone</h1>
    <p class="text-gray-400 font-semibold text-lg my-6">Sign up to see photos and videos from your friends.</p>
</section>

我们需要添加一个表单,因此将/lib/instagram_clone_web/live/page_live.ex mount 函数更改为以下内容:

  alias InstagramClone.Accounts
  alias InstagramClone.Accounts.User

  @impl true
  def mount(_params, _session, socket) do
    changeset = Accounts.change_user_registration(%User{})
    {:ok,
      socket
      |> assign(changeset: changeset)}
  end

让我们通过编辑来添加表单和新样式/lib/instagram_clone_web/live/page_live.html.leex

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
  <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

  <%= f = form_for @changeset, "#",
    phx_change: "validate",
    phx_submit: "save",
    phx_trigger_action: @trigger_submit,
    class: "flex flex-col space-y-4 w-full px-6" %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :full_name, class: "text-gray-400" %>
      <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :username, class: "text-gray-400" %>
      <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :username, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>

  </form>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@socket, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>

error_tag()在继续之前,我们需要稍微调整一下我们的辅助函数,这样我们就可以向其中添加类,打开lib/instagram_clone_web/views/error_helpers.ex文件并将函数更改为以下内容:

  def error_tag(form, field, class \\ [class: "invalid-feedback"]) do
    Enum.map(Keyword.get_values(form.errors, field), fn error ->
      content_tag(:span, translate_error(error),
        class: Keyword.get(class, :class),
        phx_feedback_for: input_id(form, field)
      )
    end)
  end

instagram-phoenix-p1-3.jpg

这将作为我们的基础登陆和注册页面。我们需要添加我们的validation()save()函数,并分配trigger_submit给 mount 函数中的套接字,以便能够通过 HTTP 触发表单,将表单直接发送到我们的/lib/instagram_clone_web/live/page_live.ex liveview 模块上的注册控制器,以便我们可以注册我们的用户,所以让我们这样做下一个。

  @impl true
  def mount(_params, _session, socket) do
    changeset = Accounts.change_user_registration(%User{})
    {:ok,
      socket
      |> assign(changeset: changeset)
      |> assign(trigger_submit: false)}
  end

  @impl true
  def handle_event("validate", %{"user" => user_params}, socket) do
    changeset =
      %User{}
      |> User.registration_changeset(user_params)
      |> Map.put(:action, :validate)
    :timer.sleep(9000)
    {:noreply, socket |> assign(changeset: changeset)}
  end

  def handle_event("save", _, socket) do
    {:noreply, assign(socket, trigger_submit: true)}
  end

为了处理和显示错误,我们必须编辑我们的注册用户页面,因为它们是由控制器处理的常规 Phoenix 视图,所以它应该看起来像我们的实时视图。在此之前,我们必须向容器中的主标记添加一个类以用于常规视图,打开lib/instagram_clone_web/templates/layout/app.html.eex

<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"><%= get_flash(@conn, :info) %></p>
  <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
  <%= @inner_content %>
</main>

然后使用以下内容打开lib/instagram_clone_web/templates/user_registration/new.html.eex并编辑该文件:

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
  <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

  <%= form_for @changeset, Routes.user_registration_path(@conn, :create), [class: "flex flex-col space-y-4 w-full px-6"], fn f -> %>
    <%= if @changeset.action do %>
      <div class="alert alert-danger">
        <p>Oops, something went wrong! Please check the errors below.</p>
      </div>
    <% end %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :full_name, class: "text-gray-400" %>
      <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :username, class: "text-gray-400" %>
      <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :username, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>
  <% end %>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>

让我们设计登录页面并登录。打开lib/instagram_clone_web/templates/user_session/new.html.eex并添加以下内容:

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>

  <%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "flex flex-col space-y-4 w-full px-6"], fn f -> %>
    <%= if @error_message do %>
      <div class="alert alert-danger">
        <p><%= @error_message %></p>
      </div>
    <% end %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, value: input_value(f, :password), class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Log In", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>
  <% end %>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold"><%= link "Forgot password?", to: Routes.user_reset_password_path(@conn, :new) %></p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Don't have an account? <%= link "Sign up", to: Routes.user_registration_path(@conn, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>

它应该如下图所示。

instagram-phoenix-p1-4.jpg

事情变得非常有趣和令人兴奋,但我们没有办法在实时视图中获取当前登录的用户,我们必须在每个实时视图安装上手动获取它,我们可以手动执行此操作,但我们很懒,所以我们将添加一个我们可以调用并有权访问当前用户的辅助函数。

将新文件添加到lib/instagram_clone_web/live名为的文件夹中lib/instagram_clone_web/live/live_helpers.ex并添加以下内容:

defmodule InstagramCloneWeb.LiveHelpers do
  import Phoenix.LiveView
  alias InstagramClone.Accounts
  alias InstagramClone.Accounts.User
  alias InstagramCloneWeb.UserAuth

  def assign_defaults(session, socket) do
    if connected?(socket), do: InstagramCloneWeb.Endpoint.subscribe(UserAuth.pubsub_topic())

    socket =
      assign_new(socket, :current_user, fn ->
        find_current_user(session)
      end)
    socket
  end

  defp find_current_user(session) do
    with user_token when not is_nil(user_token) <- session["user_token"],
         %User{} = user <- Accounts.get_user_by_session_token(user_token),
         do: user
  end
end

很简单,我们找到带有会话令牌的当前用户并将其分配回套接字,我们还订阅了一个 pubsub 主题,以便在使用套接字登录时注销所有实时当前会话,接下来让我们创建该函数,打开并lib/instagram_clone_web/controllers/user_auth.ex添加下列的:

  # Added to the top of our file
  @pubsub_topic "user_updates"

  def pubsub_topic, do: @pubsub_topic
  # We changed a line on this function
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    Accounts.log_out_user(user_token) #Line changed

    if live_socket_id = get_session(conn, :live_socket_id) do
      InstagramCloneWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session()
    |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: "/")
  end

现在让我们将log_out_user()函数添加到我们的Accounts上下文中,打开lib/instagram_clone/accounts.ex添加:

...

alias InstagramCloneWeb.UserAuth

...

  def log_out_user(token) do
    user = get_user_by_session_token(token)
    # Delete all user tokens
    Repo.delete_all(UserToken.user_and_contexts_query(user, :all))

    # Broadcast to all liveviews to immediately disconnect the user
    InstagramCloneWeb.Endpoint.broadcast_from(
      self(),
      UserAuth.pubsub_topic(),
      "logout_user",
      %{
        user: user
      }
    )
  end

...

现在我们必须在实时视图中提供辅助函数,打开lib/instagram_clone_web.ex并添加以下内容:

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {InstagramCloneWeb.LayoutView, "live.html"}

      unquote(view_helpers())
      # Added Start   
      import InstagramCloneWeb.LiveHelpers

      alias InstagramClone.Accounts.User
      @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
      # Added END 
    end
  end

我们还添加了handle_info()自动对所有实时视图中的注销消息做出反应的功能。

打开/lib/instagram_clone_web/live/page_live.ex并将挂载函数更改为以下内容:

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    changeset = Accounts.change_user_registration(%User{})
    {:ok,
      socket
      |> assign(changeset: changeset)
      |> assign(trigger_submit: false)}
  end

然后打开/lib/instagram_clone_web/live/page_live.html.leex文件并将其更改为以下内容:

<%= if @current_user do %>
  <%= link "Log Out", to: Routes.user_session_path(@socket, :delete), method: :delete %>
  <h1>User Logged In Homepage</h1>
<% else %>
  <section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
    <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
    <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

    <%= f = form_for @changeset, Routes.user_registration_path(@socket, :create),
      phx_change: "validate",
      phx_submit: "save",
      phx_trigger_action: @trigger_submit,
      class: "flex flex-col space-y-4 w-full px-6" %>

      <div class="flex flex-col">
        <%= label f, :email, class: "text-gray-400" %>
        <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :email, class: "text-red-700 text-sm" %>
      </div>

      <div class="flex flex-col">
        <%= label f, :full_name, class: "text-gray-400" %>
        <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
      </div>

      <div class="flex flex-col">
        <%= label f, :username, class: "text-gray-400" %>
        <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :username, class: "text-red-700 text-sm" %>
      </div>

      <div class="flex flex-col">
        <%= label f, :password, class: "text-gray-400" %>
        <%= password_input f, :password, value: input_value(f, :password), class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
        <%= error_tag f, :password, class: "text-red-700 text-sm" %>
      </div>

      <div>
        <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
      </div>

    </form>

    <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
  </section>

  <section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
    <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@socket, :new), class: "text-light-blue-500 font-semibold" %></p>
  </section>
<% end %>

此时,您应该重新加载服务器并通过登录和注销来测试您的主页。一切都应该工作正常,但是让我们组件化我们的主页,在下面lib/instagram_clone_web/live创建 2 个文件,lib/instagram_clone_web/live/page_live_component.ex然后lib/instagram_clone_web/live/page_live_component.html.leex

#lib/instagram_clone_web/live/page_live_component.ex

defmodule InstagramCloneWeb.PageLiveComponent do
  use InstagramCloneWeb, :live_component
end

获取表格lib/instagram_clone_web/live/page_live.html.leex并将其添加到lib/instagram_clone_web/live/page_live_component.html.leex

<section class="w-1/2 border-2 shadow flex flex-col place-items-center mx-auto p-6">
  <h1 class="text-4xl font-bold italic text-gray-700">InstagramClone</h1>
  <p class="text-gray-500 font-semibold text-lg mt-6 text-center px-8">Sign up to see photos and videos from your friends.</p>

  <%= f = form_for @changeset, Routes.user_registration_path(@socket, :create),
    phx_change: "validate",
    phx_submit: "save",
    phx_trigger_action: @trigger_submit,
    class: "flex flex-col space-y-4 w-full px-6" %>

    <div class="flex flex-col">
      <%= label f, :email, class: "text-gray-400" %>
      <%= email_input f, :email, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :email, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :full_name, class: "text-gray-400" %>
      <%= text_input f, :full_name, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :full_name, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :username, class: "text-gray-400" %>
      <%= text_input f, :username, class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :username, class: "text-red-700 text-sm" %>
    </div>

    <div class="flex flex-col">
      <%= label f, :password, class: "text-gray-400" %>
      <%= password_input f, :password, value: input_value(f, :password), class: "rounded border-gray-300 shadow-sm focus:ring-gray-900 focus:ring-opacity-50 focus:border-gray-900" %>
      <%= error_tag f, :password, class: "text-red-700 text-sm" %>
    </div>

    <div>
      <%= submit "Sign up", phx_disable_with: "Saving...", class: "block w-full py-2 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>

  </form>

  <p class="text-sm px-10 text-center mt-6 text-gray-400 font-semibold">By signing up, you agree to our Terms , Data Policy and Cookies Policy .</p>
</section>

<section class="w-1/2 border-2 shadow flex justify-center mx-auto p-6 mt-6">
  <p class="text-lg text-gray-600">Have an account? <%= link "Log in", to: Routes.user_session_path(@socket, :new), class: "text-light-blue-500 font-semibold" %></p>
</section>

现在您的lib/instagram_clone_web/live/page_live.html.leex示例应该如下所示:

<%= if @current_user do %>
  <h1>User Logged In Homepage</h1>
<% else %>
  <%= live_component @socket, InstagramCloneWeb.PageLiveComponent, changeset: @changeset, trigger_submit: @trigger_submit %>
<% end %>

这有助于我们清理代码,以便稍后我们开始为登录用户处理主页时。

最后让我们添加一个标题导航菜单组件。首先我们需要知道当前的 URL 路径,我们将使用宏为所有实时视图执行此操作,打开lib/instagram_clone_web.ex添加以下函数live_view()

      @impl true
      def handle_params(_unsigned_params, uri, socket) do
        {:noreply,
          socket
          |> assign(current_uri_path: URI.parse(uri).path)}
      end

current_uri_path这将使我们能够访问分配给套接字的所有实时视图的当前 URL 路径。因此,在我们的lib/instagram_clone_web.ex文件中,新更新的内容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
      @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_params(_unsigned_params, uri, socket) do
        {:noreply,
          socket
          |> assign(current_uri_path: URI.parse(uri).path)}
      end
    end
  end
...

现在在下面lib/instagram_clone_web/live添加 2 个文件,header_nav_component.ex以及header_nav_component.html.leex. 添加lib/instagram_clone_web/live/header_nav_component.ex以下内容:

defmodule InstagramCloneWeb.HeaderNavComponent do
  use InstagramCloneWeb, :live_component
end

将以下内容添加到lib/instagram_clone_web/live/header_nav_component.html.leex

<div class="h-14 border-b-2 flex fixed w-full bg-white z-50">
  <header class="flex items-center container mx-auto max-w-full md:w-11/12 2xl:w-6/12">
    <%= live_patch to: Routes.page_path(@socket, :index) do %>
      <h1 class="text-2xl font-bold italic">#InstagramClone</h1>
    <% end %>
    <div class="w-2/5 flex justify-end"><input type="search" placeholder="Search" 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"></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_patch 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_patch to: "" 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 @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_patch to: "" 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_patch to: "" do %>
                <li class="py-2 px-4 hover:bg-gray-50">Settings</li>
              <% end %>
              <li class="border-t-2 py-2 px-4 hover:bg-gray-50"><%= link "Log Out", to: Routes.user_session_path(@socket, :delete), method: :delete %></li>
            </ul>
        <% else %>
          <li>
            <%= link "Log In", to: Routes.user_session_path(@socket, :new), class: "md:w-24 py-1 px-3 border-none shadow rounded text-gray-50 hover:bg-blue-600 bg-blue-500 font-semibold" %>
          </li>
          <li>
            <%= link "Sign Up", to: Routes.user_registration_path(@socket, :new), class: "md:w-24 py-1 px-3 border-none text-blue-500 hover:text-blue-600 font-semibold" %>
          </li>
        <% end %>
      </ul>
    </nav>
  </header>
</div>

现在要显示该组件,请打开lib/instagram_clone_web/templates/layout/live.html.leex并将其添加到文件顶部:

<%= if @current_user do %>
  <%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, current_user: @current_user %>
<% else %>
  <%= if @current_uri_path !== "/" do %>
    <%= live_component @socket, InstagramCloneWeb.HeaderNavComponent, current_user: @current_user %>
  <% end %>
<% end %>

这将检查用户是否已登录以显示标题导航,如果没有,则不是主页,例如我们稍后将创建的个人资料或帖子页面,它也会显示它,否则如果主页且未登录,则不会显示得到显示。

对于第 1 部分来说,这已经足够了,我们还有很长的路要走,还有很多真正令人兴奋和有趣的事情要做,我在撰写本系列文章时正在构建它,因此如果有任何错误或错误,我们会在我们进行过程中得到修复,请在下面的评论中告诉我您的想法,您可以为Instagram 克隆 GitHub 存储库做出贡献。

转自:Elixirprogrammer