FFFF feat(groups): personal sections + search on /groups by mwoods79 · Pull Request #157 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/huddlz/communities.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ defmodule Huddlz.Communities do

define :search_groups, action: :search, args: [:query]
define :get_by_owner, action: :get_by_owner
define :get_joined_groups, action: :get_joined
define :get_by_slug, action: :get_by_slug, args: [:slug]

define :update_details,
Expand Down
11 changes: 11 additions & 0 deletions lib/huddlz/communities/group.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule Huddlz.Communities.Group do
list :list_groups, :read
list :search_groups, :search
list :my_groups, :get_by_owner
list :joined_groups, :get_joined
end

mutations do
Expand All @@ -37,6 +38,7 @@ defmodule Huddlz.Communities.Group do
index :read
index :search, route: "/search"
index :get_by_owner, route: "/mine"
index :get_joined, route: "/joined"
get :get_by_slug, route: "/by_slug/:slug"
post :create_group
patch :update_details
Expand Down Expand Up @@ -110,6 +112,15 @@ defmodule Huddlz.Communities.Group do
filter expr(owner_id == ^actor(:id))
end

read :get_joined do
description "Get groups the current actor has joined but does not own"

filter expr(
exists(group_members, user_id == ^actor(:id)) and
owner_id != ^actor(:id)
)
end

read :get_by_slug do
description "Get a group by its slug"

Expand Down
307 changes: 280 additions & 27 deletions lib/huddlz_web/live/group_live/index.ex
< 8000 tr data-hunk="e878faabe6d6a4a79b94e87a79c62db6b1e045db2a692ab4f78cf1fa056289bb" class="show-top-border">
Original file line number Diff line number Diff line change
@@ -1,54 +1,255 @@
defmodule HuddlzWeb.GroupLive.Index do
@moduledoc """
LiveView for listing and searching groups.
LiveView for listing and searching groups, with personal sections for
authenticated users (Hosting, Joined) and a public directory.
"""
use HuddlzWeb, :live_view

alias Huddlz.Communities.Group
alias HuddlzWeb.Layouts
require Ash.Query
require Logger

@section_limit 6

on_mount {HuddlzWeb.LiveUserAuth, :live_user_optional}

@impl true
def mount(_params, _session, socket) do
groups = list_groups()

{:ok,
socket
|> assign(:groups, groups)
|> assign(:can_create_group, Ash.can?({Group, :create_group}, socket.assigns.current_user))}
|> assign(:can_create_group, Ash.can?({Group, :create_group}, socket.assigns.current_user))
|> assign(:query, nil)
|> assign(:scope, :all)
|> assign(:section_limit, @section_limit)
|> assign(:hosting, [])
|> assign(:joined, [])
|> assign(:hosting_total, 0)
|> assign(:joined_total, 0)
|> assign(:groups, [])}
end

@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
scope = parse_scope(params["yours"])
query = params["q"] |> normalize_query()

if scope != :all and is_nil(socket.assigns.current_user) do
{:noreply,
socket
|> put_flash(:error, "Sign in to view #{sign_in_prompt(scope)}.")
|> push_navigate(to: ~p"/sign-in")}
else
socket =
socket
|> assign(:page_title, page_title(scope))
|> assign(:scope, scope)
|> assign(:query, query)
|> load_groups()

{:noreply, socket}
end
end

defp sign_in_prompt(:hosting), do: "groups you host"
defp sign_in_prompt(:joined), do: "groups you've joined"

@impl true
def handle_event("filter_change", %{"query" => query}, socket) do
{:noreply, push_patch(socket, to: scoped_path(socket.assigns.scope, query))}
end

def handle_event("search", %{"query" => query}, socket) do
{:noreply, push_patch(socket, to: scoped_path(socket.assigns.scope, query))}
end

def handle_event("clear_search", _params, socket) do
{:noreply, push_patch(socket, to: scoped_path(socket.assigns.scope, nil))}
end

defp parse_scope("hosting"), do: :hosting
defp parse_scope("joined"), do: :joined
defp parse_scope(_), do: :all

defp normalize_query(nil), do: nil
defp normalize_query(""), do: nil
defp normalize_query(q) when is_binary(q), do: q |> String.trim() |> nilify_blank()

defp nilify_blank(""), do: nil
defp nilify_blank(q), do: q

defp page_title(:hosting), do: "Groups You Host"
defp page_title(:joined), do: "Groups You've Joined"
defp page_title(:all), do: "Groups"

defp scoped_path(scope, query) do
params =
[yours: scope_param(scope), q: query]
|> Enum.reject(fn {_, v} -> is_nil(v) end)

case params do
[] -> ~p"/groups"
params -> ~p"/groups?#{params}"
end
end

defp scope_param(:hosting), do: "hosting"
defp scope_param(:joined), do: "joined"
defp scope_param(:all), do: nil

defp load_groups(socket) do
user = socket.assigns.current_user
query = socket.assigns.query

case socket.assigns.scope do
:hosting ->
assign(socket, :groups, list_hosting(user, query))

:joined ->
assign(socket, :groups, list_joined(user, query))

:all ->
hosting = if user, do: list_hosting(user, query), else: []
joined = if user, do: list_joined(user, query), else: []

socket
|> assign(:hosting, Enum.take(hosting, @section_limit))
|> assign(:hosting_total, length(hosting))
|> assign(:joined, Enum.take(joined, @section_limit))
|> assign(:joined_total, length(joined))
|> assign(:groups, list_all(query))
end
end

defp list_hosting(user, query) do
Group
|> Ash.Query.for_read(:get_by_owner, %{}, actor: user)
|> apply_search(query)
|> Ash.Query.load(:current_image_url)
|> read_groups(actor: user)
end

defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Groups")
|> assign(:group, nil)
defp list_joined(user, query) do
Group
|> Ash.Query.for_read(:get_joined, %{}, actor: user)
|> apply_search(query)
|> Ash.Query.load(:current_image_url)
|> read_groups(actor: user)
end

# The main directory stays public-only on purpose: a user's private groups
# already surface in the // JOINED section above, so re-listing them here
# would just duplicate. Anonymous users can only ever see public groups.
defp list_all(query) do
Group
|> Ash.Query.for_read(:read, %{}, actor: nil)
|> Ash.Query.filter(is_public == true)
|> apply_search(query)
|> Ash.Query.load(:current_image_url)
|> read_groups(actor: nil)
end

defp apply_search(ash_query, nil) do
Ash.Query.sort(ash_query, name: :asc)
end

defp apply_search(ash_query, search_text) do
ash_query
|> Ash.Query.filter(
trigram_similarity(name, ^search_text) > 0.1 or
trigram_similarity(description, ^search_text) > 0.1
)
|> Ash.Query.load(search_relevance: [query: search_text])
|> Ash.Query.sort(
search_relevance: {%{query: search_text}, :desc},
name: :asc
)
end

defp read_groups(ash_query, opts) do
case Ash.read(ash_query, opts) do
{:ok, groups} ->
groups

{:error, reason} ->
Logger.warning("GroupLive.Index group read failed: #{inspect(reason)}")
[]
end
end

@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Groups
{page_title(@scope)}
<:actions :if={@can_create_group}>
<.link navigate={~p"/groups/new"}>
<.button>New Group</.button>
</.link>
<.button navigate={~p"/groups/new"}>New Group</.button>
</:actions>
</.header>

<div class="mt-8">
<form phx-change="filter_change" phx-submit="search" class="mt-6">
<div class="flex items-end gap-2">
<div class="flex-grow">
<label for="group-search" class="sr-only">Search groups</label>
<input
id="group-search"
type="text"
name="query"
value={@query}
placeholder="Search groups"
phx-debounce="300"
class="w-full h-12 pl-0 pr-4 border-0 border-b border-base-300 bg-transparent text-base focus:outline-none focus:ring-0 focus:border-primary transition-colors placeholder:text-base-content/30"
/>
</div>
<%= if @query do %>
<button
type="button"
phx-click="clear_search"
class="text-xs text-primary hover:underline font-medium"
>
Clear
</button>
<% end %>
</div>
</form>

<%= if @scope == :all and @current_user do %>
<.personal_section
:if={@hosting_total > 0}
title="Hosting"
count={@hosting_total}
groups={@hosting}
limit={@section_limit}
query={@query}
yours="hosting"
/>
<.personal_section
:if={@joined_total > 0}
title="Joined"
count={@joined_total}
groups={@joined}
limit={@section_limit}
query={@query}
yours="joined"
/>
<% end %>

<div class="mt-10">
<h2
:if={show_all_heading?(assigns)}
class="font-display text-lg tracking-tight text-glow flex items-baseline gap-3"
>
<span class="mono-label text-primary/70">// {scope_heading(@scope)}</span>
<span class="text-sm font-body font-normal text-base-content/40">
({length(@groups)})
</span>
</h2>

<%= if @groups == [] do %>
<div class="border border-dashed border-base-300 p-12 text-center">
<p class="text-lg text-base-content/40">No groups found.</p>
<%= if @can_create_group do %>
<div class="border border-dashed border-base-300 p-12 text-center mt-4">
<p class="text-lg text-base-content/40">{empty_message(@scope, @query)}</p>
<%= if @scope == :all and @can_create_group and is_nil(@query) do %>
<p class="mt-4">
<.link navigate={~p"/groups/new"} class="text-primary hover:underline font-medium">
Create the first group
Expand All @@ -57,24 +258,76 @@ defmodule HuddlzWeb.GroupLive.Index do
<% end %>
</div>
<% else %>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div class={[
"grid gap-6 sm:grid-cols-2 lg:grid-cols-3",
show_all_heading?(assigns) && "mt-4"
]}>
<%= for group <- @groups do %>
<.group_card group={group} />
<% end %>
</div>
<% end %>

<div :if={@scope != :all} class="mt-6">
<.link navigate={~p"/groups"} class="text-sm text-primary hover:underline font-medium">
← All groups
</.link>
</div>
</div>
</Layouts.app>
"""
end

defp list_groups do
case Group
|> Ash.Query.filter(is_public: true)
|> Ash.Query.load(:current_image_url)
|> Ash.read() do
{:ok, groups} -> groups
{:error, _} -> []
end
attr :title, :string, required: true
attr :count, :integer, required: true
attr :groups, :list, required: true
attr :limit, :integer, required: true
attr :query, :string, default: nil
attr :yours, :string, required: true

defp personal_section(assigns) do
~H"""
<div class="mt-10">
<div class="flex items-baseline justify-between gap-2">
<h2 class="font-display text-lg tracking-tight text-glow flex items-baseline gap-3">
<span class="mono-label text-primary/70">// {@title}</span>
<span class="text-sm font-body font-normal text-base-content/40">
({@count})
</span>
</h2>
<.link
:if={@count > @limit}
navigate={view_all_path(@yours, @query)}
class="text-xs text-primary hover:underline font-medium tracking-wide uppercase"
>
View all →
</.link>
</div>

<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 mt-4">
<%= for group <- @groups do %>
<.group_card group={group} />
<% end %>
</div>
</div>
"""
end

defp view_all_path(yours, nil), do: ~p"/groups?#{[yours: yours]}"
defp view_all_path(yours, query), do: ~p"/groups?#{[yours: yours, q: query]}"

defp scope_heading(:hosting), do: "Hosting"
defp scope_heading(:joined), do: "Joined"
defp scope_heading(:all), do: "All Groups"

defp show_all_heading?(%{scope: :all, current_user: user, hosting_total: h, joined_total: j})
when not is_nil(user) and (h > 0 or j > 0),
do: true

defp show_all_heading?(_), do: false

defp empty_message(_scope, query) when not is_nil(query), do: "No groups match your search."
defp empty_message(:hosting, _), do: "You aren't hosting any groups yet."
defp empty_message(:joined, _), do: "You haven't joined any groups yet."
defp empty_message(:all, _), do: "No groups found."
end
Loading
Loading
0