共计 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