使用 Phoenix LiveView 构建 Instagram (4)
使用PETAL(Phoenix、Elixir、TailwindCSS、AlpineJS、LiveView)技术栈构建一个简化版的Instagram Web应用程序
在第 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 页面。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异
· 三行代码完成国际化适配,妙~啊~