Let's Build An Instagram Clone With The PETAL(Phoenix, Elixir, TailwindCSS, AlpineJS, LiveView) Stack [PART 8]
Bookmarks Functionality
In part 7 we added the search functionality in our top header navigation menu, in this part we will work on the bookmarks functionality and notify users when a following adds new posts to our homepage. You can catch up with the Instagram Clone GitHub Repo.
Let's handle errors when we try to create a new post with no image selected, to do that we need to pattern match correctly in our save handle function inside lib/instagram_clone_web/live/post_live/new.ex
:
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: post}} -> # <- THIS LINE WAS UPDATED
PostUploader.save(socket)
{:noreply,
socket
|> put_flash(:info, "Post created successfully")
|> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))}
|> push_redirect(to: Routes.live_path(socket, InstagramCloneWeb.PostLive.Show, post.url_id))}
{:error, :post, %Ecto.Changeset{} = changeset, %{}} -> # <- THIS LINE WAS UPDATED
{:noreply, assign(socket, changeset: changeset)}
end
end
Because we are using Ecto.Multi
to update posts count to users and creating the post, in our result we have to pattern match accordingly.
Now inside lib/instagram_clone_web/live/post_live/new.html.leex
on line 23 let's add a div to display the :photo_url
error:
<div class="flex justify-center">
<%= error_tag f, :photo_url, class: "text-red-700 block" %>
</div>
Every time that a new post is created we are going to use phoenix pubsub to send a message to the homepage liveview, so we can display a div that when clicked will reload the liveview. Inside lib/instagram_clone/posts.ex
add the following:
@pubsub_topic "new_posts_added"
def pubsub_topic, do: @pubsub_topic
def subscribe do
InstagramCloneWeb.Endpoint.subscribe(@pubsub_topic)
end
Inside lib/instagram_clone_web/live/post_live/new.ex
let's send the message:
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: post}} -> # <- THIS LINE WAS UPDATED
PostUploader.save(socket)
send_msg(post)
{:noreply,
socket
|> put_flash(:info, "Post created successfully")
|> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))}
|> push_redirect(to: Routes.live_path(socket, InstagramCloneWeb.PostLive.Show, post.url_id))}
{:error, :post, %Ecto.Changeset{} = changeset, %{}} -> # <- THIS LINE WAS UPDATED
{:noreply, assign(socket, changeset: changeset)}
end
end
defp send_msg(post) do
# Broadcast that new post was added
InstagramCloneWeb.Endpoint.broadcast_from(
self(),
Posts.pubsub_topic,
"new_post",
%{
post: post
}
)
end
Inside lib/instagram_clone_web/live/page_live.ex
let's handle the message that's going to be sent:
alias InstagramClone.Posts.Post
@impl true
def mount(_params, session, socket) do
socket = assign_defaults(session, socket)
if connected?(socket), do: Posts.subscribe
{:ok,
socket
|> assign(page_title: "InstagraClone")
|> assign(new_posts_added: false)
|> assign(page: 1, per_page: 15),
temporary_assigns: [user_feed: []]}
end
@impl true
def handle_info(%{event: "new_post", payload: %{post: %Post{user_id: post_user_id}}}, socket) do
if post_user_id in socket.assigns.following_list do
{:noreply, socket |> assign(new_posts_added: true)}
else
{:noreply, socket}
end
end
In our mount function, we subscribe to the pubsub topic and assign a page title and new_posts_added
to determine if we have to display the div in our template. In our handle_info
we are receiving the message and pattern matching on the user to just get the user ID, then we check if that user ID is in our following list of the current user that is assigned to the socket, and making the new_posts_added
true if it is in our following list.
Inside lib/instagram_clone_web/live/page_live.html.leex
on line 2 add the following:
<%= if @new_posts_added do %>
<div class="flex justify-center w-3/5 sticky top-14">
<%= live_redirect to: Routes.page_path(@socket, :index), class: "user-profile-follow-btn" do %>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Load New Posts
<% end %>
</div>
<% end %>
Now when a user that we are following adds a new post, while we are on our homepage, we get notified.
Posts Bookmarks
Go to the terminal and let's create a schema to handle the posts bookmarks:
mix phx.gen.schema Posts.Bookmarks posts_bookmarks user_id:references:users post_id:references:posts
Inside the migration that was generated:
defmodule InstagramClone.Repo.Migrations.CreatePostsBookmarks do
use Ecto.Migration
def change do
create table(:posts_bookmarks) do
add :user_id, references(:users, on_delete: :delete_all)
add :post_id, references(:posts, on_delete: :delete_all)
timestamps()
end
create index(:posts_bookmarks, [:user_id])
create index(:posts_bookmarks, [:post_id])
end
end
Inside lib/instagram_clone/posts/bookmarks.ex
:
defmodule InstagramClone.Posts.Bookmarks do
use Ecto.Schema
schema "posts_bookmarks" do
belongs_to :user, InstagramClone.Accounts.User
belongs_to :post, InstagramClone.Posts.Post
timestamps()
end
end
Inside lib/instagram_clone/accounts/user.ex
and lib/instagram_clone/posts/post.ex
:
has_many :posts_bookmarks, InstagramClone.Posts.Bookmarks
Update lib/instagram_clone/posts.ex
to the following:
defmodule InstagramClone.Posts do
@moduledoc """
The Posts context.
"""
import Ecto.Query, warn: false
alias InstagramClone.Repo
alias InstagramClone.Posts.Post
alias InstagramClone.Accounts.User
alias InstagramClone.Comments.Comment
alias InstagramClone.Likes.Like
alias InstagramClone.Posts.Bookmarks
@pubsub_topic "new_posts_added"
def pubsub_topic, do: @pubsub_topic
def subscribe do
InstagramCloneWeb.Endpoint.subscribe(@pubsub_topic)
end
@doc """
Returns the list of posts.
## Examples
iex> list_posts()
[%Post{}, ...]
"""
def list_posts do
Repo.all(Post)
end
@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
def list_saved_profile_posts(page: page, per_page: per_page, user_id: user_id) do
Bookmarks
|> where(user_id: ^user_id)
|> join(:inner, [b], p in assoc(b, :post))
|> select([b, p], %{url_id: p.url_id, photo_url: p.photo_url})
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> order_by(desc: :id)
|> Repo.all
end
@doc """
Returns the list of paginated posts of a given user id
And posts of following list of given user id
With user and likes preloaded
With 2 most recent comments preloaded with user and likes
User, page, and per_page are given with the socket assigns
## Examples
iex> get_accounts_feed(following_list, assigns)
[%{photo_url: "", url_id: ""}, ...]
"""
def get_accounts_feed(following_list, assigns) do
user = assigns.current_user
page = assigns.page
per_page = assigns.per_page
query =
from c in Comment,
select: %{id: c.id, row_number: over(row_number(), :posts_partition)},
windows: [posts_partition: [partition_by: :post_id, order_by: [desc: :id]]]
comments_query =
from c in Comment,
join: r in subquery(query),
on: c.id == r.id and r.row_number <= 2
likes_query = Like |> select([l], l.user_id)
bookmarks_query = Bookmarks |> select([b], b.user_id)
Post
|> where([p], p.user_id in ^following_list)
|> or_where([p], p.user_id == ^user.id)
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> order_by(desc: :id)
|> preload([:user, posts_bookmarks: ^bookmarks_query, likes: ^likes_query, comments: ^{comments_query, [:user, likes: likes_query]}])
|> Repo.all()
end
def get_accounts_feed_total(following_list, assigns) do
user = assigns.current_user
Post
|> where([p], p.user_id in ^following_list)
|> or_where([p], p.user_id == ^user.id)
|> select([p], count(p.id))
|> Repo.one()
end
@doc """
Gets a single post.
Raises `Ecto.NoResultsError` if the Post does not exist.
## Examples
iex> get_post!(123)
%Post{}
iex> get_post!(456)
** (Ecto.NoResultsError)
"""
def get_post!(id) do
likes_query = Like |> select([l], l.user_id)
bookmarks_query = Bookmarks |> select([b], b.user_id)
Repo.get!(Post, id)
|> Repo.preload([:user, posts_bookmarks: bookmarks_query, likes: likes_query])
end
def get_post_feed!(id) do
query =
from c in Comment,
select: %{id: c.id, row_number: over(row_number(), :posts_partition)},
windows: [posts_partition: [partition_by: :post_id, order_by: [desc: :id]]]
comments_query =
from c in Comment,
join: r in subquery(query),
on: c.id == r.id and r.row_number <= 2
likes_query = Like |> select([l], l.user_id)
bookmarks_query = Bookmarks |> select([b], b.user_id)
Post
|> preload([:user, posts_bookmarks: ^bookmarks_query, likes: ^likes_query, comments: ^{comments_query, [:user, likes: likes_query]}])
|> Repo.get!(id)
end
def get_post_by_url!(id) do
likes_query = Like |> select([l], l.user_id)
bookmarks_query = Bookmarks |> select([b], b.user_id)
Repo.get_by!(Post, url_id: id)
|> Repo.preload([:user, posts_bookmarks: bookmarks_query, likes: likes_query])
end
@doc """
Creates a post.
## Examples
iex> create_post(%{field: value})
{:ok, %Post{}}
iex> create_post(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
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
@doc """
Updates a post.
## Examples
iex> update_post(post, %{field: new_value})
{:ok, %Post{}}
iex> update_post(post, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a post.
## Examples
iex> delete_post(post)
{:ok, %Post{}}
iex> delete_post(post)
{:error, %Ecto.Changeset{}}
"""
def delete_post(%Post{} = post) do
Repo.delete(post)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking post changes.
## Examples
iex> change_post(post)
%Ecto.Changeset{data: %Post{}}
"""
def change_post(%Post{} = post, attrs \\ %{}) do
Post.changeset(post, attrs)
end
# Returns nil if not found
def bookmarked?(user_id, post_id) do
Repo.get_by(Bookmarks, [user_id: user_id, post_id: post_id])
end
def create_bookmark(user, post) do
user = Ecto.build_assoc(user, :posts_bookmarks)
post = Ecto.build_assoc(post, :posts_bookmarks, user)
Repo.insert(post)
end
def unbookmark(bookmarked?) do
Repo.delete(bookmarked?)
end
def count_user_saved(user) do
Bookmarks
|> where(user_id: ^user.id)
|> select([b], count(b.id))
|> Repo.one
end
end
The following functions were added:
list_saved_profile_posts/3
to get all the paginated saved posts.bookmarked?/2
to check if a bookmark exists.create_bookmark/2
to create a bookmark.unbookmark/1
to delete a bookmark.count_user_saved/1
to get total saved posts for given user.
Also to all the get posts functions, we are preloading posts bookmarks list, so we can send that list to the bookmarks component to set the button that we are going to use for the functionality.
Inside lib/instagram_clone_web/live/post_live/
create a file named bookmark_component.ex
and add the following:
defmodule InstagramCloneWeb.PostLive.BookmarkComponent do
use InstagramCloneWeb, :live_component
alias InstagramClone.Posts
@impl true
def update(assigns, socket) do
get_btn_status(socket, assigns)
end
@impl true
def render(assigns) do
~L"""
<button
phx-target="<%= @myself %>"
phx-click="toggle-status"
class="h-8 w-8 ml-auto focus:outline-none">
<%= @icon %>
</button>
"""
end
@impl true
def handle_event("toggle-status", _params, socket) do
current_user = socket.assigns.current_user
post = socket.assigns.post
bookmarked? = Posts.bookmarked?(current_user.id, post.id)
if bookmarked? do
unbookmark(socket, bookmarked?)
else
bookmark(socket, current_user, post)
end
end
defp unbookmark(socket, bookmarked?) do
Posts.unbookmark(bookmarked?)
{:noreply,
socket
|> assign(icon: bookmark_icon(socket.assigns))}
end
defp bookmark(socket, current_user, post) do
Posts.create_bookmark(current_user, post)
{:noreply,
socket
|> assign(icon: bookmarked_icon(socket.assigns))}
end
defp get_btn_status(socket, assigns) do
if assigns.current_user.id in assigns.post.posts_bookmarks do
get_socket_assigns(socket, assigns, bookmarked_icon(assigns))
else
get_socket_assigns(socket, assigns, bookmark_icon(assigns))
end
end
defp get_socket_assigns(socket, assigns, icon) do
{:ok,
socket
|> assign(assigns)
|> assign(icon: icon)}
end
defp bookmark_icon(assigns) do
~L"""
<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="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
"""
end
defp bookmarked_icon(assigns) do
~L"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
</svg>
"""
end
end
Inside lib/instagram_clone_web/live/post_live/show.html.leex
on line 94 change the div with the bookmark icon to the following:
<%= if @current_user do %>
<%= live_component @socket,
InstagramCloneWeb.PostLive.BookmarkComponent,
id: @post.id,
post: @post,
current_user: @current_user %>
<% else %>
<%= link to: Routes.user_session_path(@socket, :new), class: "w-8 h-8 ml-auto focus:outline-none" 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="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
<% end %>
<% end %>
Inside lib/instagram_clone_web/live/page_post_feed_component.html.leex
on line 41, change the div containing the bookmark icon to the following:
<%= live_component @socket,
InstagramCloneWeb.PostLive.BookmarkComponent,
id: @post.id,
post: @post,
current_user: @current_user %>
Inside lib/instagram_clone_web/router.ex
on line 72 add the following route:
live "/:username/saved", UserLive.Profile, :saved
Inside lib/instagram_clone_web/live/header_nav_component.html.leex
on line 102:
<%= live_redirect to: Routes.user_profile_path(@socket, :saved, @current_user.username) do %>
<li class="py-2 px-4 hover:bg-gray-50">Saved</li>
<% end %>
Update lib/instagram_clone_web/live/user_live/profile.ex
to the following:
defmodule InstagramCloneWeb.UserLive.Profile do
use InstagramCloneWeb, :live_view
alias InstagramClone.Accounts
alias InstagramCloneWeb.UserLive.FollowComponent
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})"),
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
defp assign_saved_posts(socket) do
socket
|> assign(posts:
Posts.list_saved_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 = get_total_posts_count(socket)
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))
|> get_posts()
end
end
defp get_total_posts_count(socket) do
if socket.assigns.saved_page? do
Posts.count_user_saved(socket.assigns.user)
else
socket.assigns.user.posts_count
end
end
defp get_posts(socket) do
if socket.assigns.saved_page? do
assign_saved_posts(socket)
else
assign_posts(socket)
end
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action)}
end
@impl true
def handle_info({FollowComponent, :update_totals, updated_user}, socket) do
{:noreply, apply_msg_action(socket, socket.assigns.live_action, updated_user)}
end
defp apply_msg_action(socket, :follow_component, updated_user) do
socket |> assign(user: updated_user)
end
defp apply_msg_action(socket, _, _updated_user) do
socket
end
defp apply_action(socket, :index) do
selected_link_styles = "text-gray-600 border-t-2 border-black -mt-0.5"
live_action = get_live_action(socket.assigns.user, socket.assigns.current_user)
socket
|> assign(selected_index: selected_link_styles)
|> assign(selected_saved: "text-gray-400")
|> assign(saved_page?: false)
|> assign(live_action: live_action)
|> show_saved_profile_link?()
|> assign_posts()
end
defp apply_action(socket, :saved) do
selected_link_styles = "text-gray-600 border-t-2 border-black -mt-0.5"
socket
|> assign(selected_index: "text-gray-400")
|> assign(selected_saved: selected_link_styles)
|> assign(live_action: :edit_profile)
|> assign(saved_page?: true)
|> show_saved_profile_link?()
|> redirect_when_not_my_saved()
|> assign_saved_posts()
end
defp apply_action(socket, :following) do
following = Accounts.list_following(socket.assigns.user)
socket |> assign(following: following)
end
defp apply_action(socket, :followers) do
followers = Accounts.list_followers(socket.assigns.user)
socket |> assign(followers: followers)
end
defp redirect_when_not_my_saved(socket) do
username = socket.assigns.current_user.username
if socket.assigns.my_saved? do
socket
else
socket
|> push_redirect(to: Routes.user_profile_path(socket, :index, username))
end
end
defp show_saved_profile_link?(socket) do
user = socket.assigns.user
current_user = socket.assigns.current_user
if current_user && current_user.id == user.id do
socket |> assign(my_saved?: true)
else
socket |> assign(my_saved?: false)
end
end
defp get_live_action(user, current_user) do
cond do
current_user && current_user.id == user.id -> :edit_profile
current_user -> :follow_component
true -> :login_btn
end
end
end
The following functions were added:
assign_posts/1
to get and assigned profile saved posts.apply_action(socket, :saved)
to assign saved posts when saved route page, andlive_action
is assigned to:edit_profile
to display the edit profile button.redirect_when_not_my_saved/1
redirects when trying to go directly to a profile saved that doesn't belong to current user.show_saved_profile_link?/1
assignsmy_saved?
if current user owns profile.get_total_posts_count/1
to determine which total posts count we have to get.get_posts/1
to determine which posts to get.
We are no longer assigning posts in our mount function, it is done in our index and saved actions. Also, in those functions we are assigning the links styles and saved_page?
to determine which posts we have to load more when the hook in our footer gets triggered.
Update lib/instagram_clone_web/live/user_live/profile.html.leex
to the following:
<%= if @live_action == :following do %>
<%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowingComponent,
width: "w-1/4",
current_user: @current_user,
following: @following,
return_to: Routes.user_profile_path(@socket, :index, @user.username) %>
<% end %>
<%= if @live_action == :followers do %>
<%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowersComponent,
width: "w-1/4",
current_user: @current_user,
followers: @followers,
return_to: Routes.user_profile_path(@socket, :index, @user.username) %>
<% end %>
<header class="flex justify-center px-10">
<!-- Profile Picture Section -->
<section class="w-1/4">
<%= img_tag @user.avatar_url,
class: "w-40 h-40 rounded-full object-cover object-center" %>
</section>
<!-- END Profile Picture Section -->
<!-- Profile Details Section -->
<section class="w-3/4">
<div class="flex px-3 pt-3">
<h1 class="truncate md:overflow-clip text-2xl md:text-2xl text-gray-500 mb-3"><%= @user.username %></h1>
<span class="ml-11">
<%= if @live_action == :edit_profile do %>
<%= live_patch "Edit Profile",
to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Settings),
class: "py-1 px-2 border-2 rounded font-semibold hover:bg-gray-50" %>
<% end %>
<%= if @live_action == :follow_component do %>
<%= live_component @socket,
InstagramCloneWeb.UserLive.FollowComponent,
id: @user.id,
user: @user,
current_user: @current_user %>
<% end %>
<%= if @live_action == :login_btn do %>
<%= link "Follow", to: Routes.user_session_path(@socket, :new), class: "user-profile-follow-btn" %>
<% end %>
</span>
</div>
<div>
<ul class="flex p-3">
<li><b><%= @user.posts_count %></b> Posts</li>
<%= live_patch to: Routes.user_profile_path(@socket, :followers, @user.username) do %>
<li class="ml-11"><b><%= @user.followers_count %></b> Followers</li>
<% end %>
<%= live_patch to: Routes.user_profile_path(@socket, :following, @user.username) do %>
<li class="ml-11"><b><%= @user.following_count %></b> Following</li>
<% end %>
</ul>
</div>
<div class="p-3">
<h2 class="text-md text-gray-600 font-bold"><%= @user.full_name %></h2>
<%= if @user.bio do %>
<p class="max-w-full break-words"><%= @user.bio %></p>
<% end %>
<%= if @user.website do %>
<%= link display_website_uri(@user.website),
to: @user.website,
target: "_blank", rel: "noreferrer",
class: "text-blue-700" %>
<% end %>
</div>
</section>
<!-- END Profile Details Section -->
</header>
<section class="border-t-2 mt-5">
<ul class="flex justify-center text-center space-x-20">
<%= live_redirect to: Routes.user_profile_path(@socket, :index, @user.username) do %>
<li class="pt-4 px-1 text-sm <%= @selected_index %>">
POSTS
</li>
<% end %>
<li class="pt-4 px-1 text-sm text-gray-400">
IGTV
</li>
<%= if @my_saved? do %>
<%= live_redirect to: Routes.user_profile_path(@socket, :saved, @user.username) do %>
<li class="pt-4 px-1 text-sm <%= @selected_saved %>">
SAVED
</li>
<% end %>
<% end %>
<li class="pt-4 px-1 text-sm text-gray-400">
TAGGED
</li>
</ul>
</section>
<!-- 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">
<svg class="animate-spin mr-3 h-8 w-8 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>
Loading...
</div>
Posts and saved links were added, saved will only be displayed if the current user owns the profile, and we added a loading icon to our load more footer.
I really appreciate your time, thank you so much for reading.