关于elixir:使用-Phoenix-LiveView-构建-Instagram-4

41次阅读

共计 11203 个字符,预计需要花费 29 分钟才能阅读完成。

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

<!–more–>

在第 3 局部中,咱们增加了个人资料页面以及关注和显示帐户的性能,在这部分中,咱们将解决用户的帖子。您能够赶上 Instagram 克隆 GitHub Repo。

让咱们首先增加一个路由来显示用于增加帖子的表单,关上lib/instagram_clone_web/router.ex

  scope "/", InstagramCloneWeb do
    pipe_through :browser

    live "/", PageLive, :index
    live "/:username", UserLive.Profile, :index
    live "/p/:id", PostLive.Show # <-- THIS LINE WAS ADDED
  end


  scope "/", InstagramCloneWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
    live "/accounts/edit", UserLive.Settings
    live "/accounts/password/change", UserLive.PassSettings
    live "/:username/following", UserLive.Profile, :following
    live "/:username/followers", UserLive.Profile, :followers
    live "/p/new", PostLive.New # <-- THIS LINE WAS ADDED
  end

在文件夹中创立咱们的实时视图文件lib/instagram_clone_web/live/post_live

lib/instagram_clone_web/live/post_live/new.ex lib/instagram_clone_web/live/post_live/new.html.leex lib/instagram_clone_web/live/post_live/show.ex lib/instagram_clone_web/live/post_live/show.html.leex

外面lib/instagram_clone_web/live/post_live/new.ex

defmodule InstagramCloneWeb.PostLive.New do
  use InstagramCloneWeb, :live_view

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)

    {:ok,
      socket
      |> assign(page_title: "New Post")}
  end
end

关上 lib/instagram_clone_web/live/header_nav_component.html.leex 在第 18 行,应用咱们的新 route:


  <%= live_redirect to: Routes.live_path(@socket, InstagramCloneWeb.PostLive.New)  do  %>
  

让咱们创立一个帖子上下文,转到终端:

$ mix phx.gen.context Posts Post posts url_id:string description:text photo_url:string user_id:references:users total_likes:integer total_comments:integer

关上生成的迁徙并增加以下内容:

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

  def change do
    create table(:posts) do
      add :url_id, :string
      add :description, :text
      add :photo_url, :string
      add :total_likes, :integer, default: 0
      add :total_comments, :integer, default: 0
      add :user_id, references(:users, on_delete: :nothing)

      timestamps()
    end

    create index(:posts, [:user_id])
    create unique_index(:posts, [:url_id])
  end
end

返回终端:$ mix ecto.migrate

咱们还能够在终端中将帖子计数增加到用户架构中:

$ mix ecto.gen.migration adds_posts_count_to_users

关上生成的迁徙并增加以下内容:

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

  def change do
    alter table(:users) do
      add :posts_count, :integer, default: 0
    end
  end
end

返回终端:$ mix ecto.migrate

关上 lib/instagram_clone/accounts/user.ex 并将架构编辑为以下内容:


  @derive {Inspect,  except:  [:password]}
  schema "users"  do
    field :email,  :string
    field :password,  :string,  virtual:  true
    field :hashed_password,  :string
    field :confirmed_at,  :naive_datetime
    field :username,  :string
    field :full_name,  :strin
    field :avatar_url,  :string,  default:  "/images/default-avatar.png"
    field :bio,  :string
    field :website,  :string
    field :followers_count, :integer, default: 0
    field :following_count, :integer, default: 0
    field :posts_count,  :integer,  default:  0 # <-- THIS LINE WAS ADDED
    has_many :following, Follows,  foreign_key:  :follower_id
    has_many :followers, Follows,  foreign_key:  :followed_id
    has_many :posts, InstagramClone.Posts.Post # <-- THIS LINE WAS ADDED
    timestamps()
  end
  

关上 lib/instagram_clone/posts/post.ex 增加以下内容:

defmodule InstagramClone.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :description, :string
    field :photo_url, :string
    field :url_id, :string
    field :total_likes, :integer, default: 0
    field :total_comments, :integer, default: 0
    belongs_to :user, InstagramClone.Accounts.User

    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:url_id, :description, :photo_url])
    |> validate_required([:url_id, :photo_url])
  end
end

让咱们增加新的架构,并容许在其中上传lib/instagram_clone_web/live/post_live/new.ex

defmodule InstagramCloneWeb.PostLive.New do
  use InstagramCloneWeb, :live_view

  alias InstagramClone.Posts.Post
  alias InstagramClone.Posts

  @extension_whitelist ~w(.jpg .jpeg .png)

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)

    {:ok,
      socket
      |> assign(page_title: "New Post")
      |> assign(changeset: Posts.change_post(%Post{}))
      |> allow_upload(:photo_url,
      accept: @extension_whitelist,
      max_file_size: 30_000_000)}
  end

  @impl true
  def handle_event("validate", %{"post" => post_params}, socket) do
    changeset =
      Posts.change_post(%Post{}, post_params)
      |> Map.put(:action, :validate)

    {:noreply, socket |> assign(changeset: changeset)}
  end

  def handle_event("cancel-entry", %{"ref" => ref}, socket) do
    {:noreply, cancel_upload(socket, :photo_url, ref)}
  end
end

关上 config/dev.exs 第 61 行编辑:

~r"priv/static/[^uploads].*(js|css|png|jpeg|jpg|gif|svg)$",

该配置能够防止每次上传文件时从新加载上传文件夹的实时从新加载,否则,您在尝试上传时会遇到奇怪的行为。

在外面增加以下内容lib/instagram_clone_web/live/post_live/new.html.leex

<div class="flex flex-col w-1/2 mx-auto">
  <h2 class="text-xl font-bold text-gray-600"><%= @page_title %></h2>

  <%= f = form_for @changeset, "#",
    class: "mt-8",
    phx_change: "validate",
    phx_submit: "save" %>

    <%= for {_ref, err} <- @uploads.photo_url.errors do %>
        <p class="alert alert-danger"><%= Phoenix.Naming.humanize(err) %></p>
    <% end %>

    <div class="border border-dashed border-gray-500 relative" phx-drop-target="<%= @uploads.photo_url.ref %>">
      <%= live_file_input @uploads.photo_url, class: "cursor-pointer relative block opacity-0 w-full h-full p-20 z-30" %>
      <div class="text-center p-10 absolute top-0 right-0 left-0 m-auto">
          <h4>
              Drop files anywhere to upload
              <br/>or
          </h4>
          <p class="">Select Files</p>
      </div>
    </div>
    
    <%= for entry <- @uploads.photo_url.entries do %>
      <div class="my-8 flex items-center">
        <div>
          <%= live_img_preview entry, height: 250, width: 250 %>
        </div>
        <div class="px-4">
          <progress max="100" value="<%= entry.progress %>" />
        </div>
        <span><%= entry.progress %>%</span>
        <div class="px-4">
          <a href="#" class="text-red-600 text-lg font-semibold" phx-click="cancel-entry" phx-value-ref="<%= entry.ref %>">cancel</a>
        </div>
      </div>
    <% end %>

    <div class="mt-6">
      <%= label f, :description, class: "font-semibold" %>
    </div>
    <div class="mt-3">
      <%= textarea f, :description, class: "w-full border-2 border-gray-400 rounded p-1 text-semibold text-gray-500 focus:ring-transparent focus:border-gray-600", rows: 5 %>
      <%= error_tag f, :description, class: "text-red-700 text-sm block" %>
    </div>

    <div class="mt-6">
      <%= submit "Submit",
        phx_disable_with: "Saving...",
        class: "py-2 px-6 border-none shadow rounded font-semibold text-sm text-gray-50 hover:bg-light-blue-600 bg-light-blue-500 cursor-pointer" %>
    </div>

  </form>
</div>

lib/instagram_clone_web/live/uploaders在创立一个名为的文件下,post.ex在该文件中增加以下内容:

defmodule InstagramClone.Uploaders.Post do
  alias InstagramCloneWeb.Router.Helpers, as: Routes

  alias InstagramClone.Posts.Post

  @upload_directory_name "uploads"
  @upload_directory_path "priv/static/uploads"

  defp ext(entry) do
    [ext | _] = MIME.extensions(entry.client_type)
    ext
  end

  def put_image_url(socket, %Post{} = post) do
    {completed, []} = Phoenix.LiveView.uploaded_entries(socket, :photo_url)
    urls =
      for entry <- completed do
        Routes.static_path(socket, "/#{@upload_directory_name}/#{entry.uuid}.#{ext(entry)}")
      end

    %Post{post | photo_url: List.to_string(urls)}
  end

  def save(socket) do
    if !File.exists?(@upload_directory_path), do: File.mkdir!(@upload_directory_path)
    
    Phoenix.LiveView.consume_uploaded_entries(socket, :photo_url, fn meta, entry ->
      dest = Path.join(@upload_directory_path, "#{entry.uuid}.#{ext(entry)}")
      File.cp!(meta.path, dest)
    end)

    :ok
  end

end

关上 lib/instagram_clone/posts.ex 编辑 create_post() 并增加一个公有函数来搁置 url id:

...

  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

...

增加 lib/instagram_clone_web/live/post_live/new.ex 以下事件处理函数:

  alias InstagramClone.Uploaders.Post, as: PostUploader


  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} ->
        PostUploader.save(socket, post)
        
        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully")
         |> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

在第 52 行关上,lib/instagram_clone_web/live/user_live/profile.html.leex让咱们显示咱们的帖子数:

<li><b><%= @user.posts_count %></b> Posts</li>

当初让咱们创立一个函数来获取个人资料帖子并应用有限滚动对后果进行分页,关上lib/instagram_clone/posts.ex


...


  @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

...

关上 lib/instagram_clone_web/live/user_live/profile.ex 并调配帖子:


...

  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})")
      |> assign_posts(),
      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

  @impl true
  def handle_event("load-more-profile-posts", _, socket) do
    {:noreply, socket |> load_posts}
  end

  defp load_posts(socket) do
    total_posts = socket.assigns.user.posts_count
    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))
      |> assign_posts()
    end
  end

...

所有都放弃不变,咱们只需调配页面并设置每页的限度,而后在咱们的 mount() 函数中调配个人资料帖子。咱们增加了一个事件处理函数,该函数将在模板中应用 javascript 挂钩触发,如果不是最初一页,它将加载更多页面。

lib/instagram_clone_web/live/user_live/profile.html.leex在文件底部关上以下内容:


...

<!-- Gallery Grid -->
<div id="posts" phx-update="append" class="mt-9 grid gap-8 grid-cols-3">
  <%= 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 %>
</div>

<div
  id="profile-posts-footer"
  class="flex justify-center"
  phx-hook="ProfilePostsScroll">
</div>

咱们将每个新页面附加到 posts div 中,底部有一个空的 div,每次可见时都会触发事件来加载更多页面。

关上 assets/js/app.js 并增加咱们的钩子:


...

let Hooks = {}

Hooks.ProfilePostsScroll = {mounted() {
    this.observer = new IntersectionObserver(entries => {const entry = entries[0];
      if (entry.isIntersecting) {this.pushEvent("load-more-profile-posts");
      }
    });

    this.observer.observe(this.el);
  },
  destroyed() {this.observer.disconnect();
  },
}

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

...

每次达到或可见空页脚 div 时,咱们应用观察者来推送事件以加载更多帖子。

关上 lib/instagram_clone/posts.ex 并增加一个函数来通过 url id 获取帖子:


...

  def get_post_by_url!(id) do 
    Repo.get_by!(Post, url_id: id)
    |> Repo.preload(:user)
  end
...

让咱们在 mount 函数中调配 post 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

  @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(post: post)}
  end
end

咱们正在对 URL ID 进行解码,因为在咱们的个人资料模板中,当咱们公布帖子时,live_redirect URL ID 会被编码。咱们 Base.encode64 用来生成 id 的,有时会产生特殊字符,例如 / 须要在 URL 中进行编码的字符。

这就是这部分的内容,这是一项正在进行的工作。在下一部分中,咱们将应用 show-post 页面。

转自:Elixirprogrammer

正文完
 0