15.6 KiB · text History 6280797
defmodule GitGudWeb.PageLive.Home do
@moduledoc """
`/` — two pages behind one route.
Signed out: the marketing landing, unchanged, from
`GitGudWeb.LandingComponents`.
Signed in: a dashboard of the viewer's own work — PRs and issues they
opened, PRs waiting on them as a maintainer, CI, and their repos and
orgs. Issues and PRs carry an author but no assignee or review
request, so "waiting on you" means "open in a repo you maintain,
opened by someone else" rather than an explicit request.
"""
use GitGudWeb, :live_view
import GitGudWeb.LandingComponents
alias GitGud.Issues
alias GitGud.Organizations
alias GitGud.Organizations.Organization
alias GitGud.Profiles
alias GitGud.PullRequests
alias GitGud.Repositories
alias GitGud.Repositories.Storage
alias GitGud.Workflows
@panel_limit 6
@impl true
def mount(_params, _session, socket) do
case socket.assigns.current_scope do
%{user: %{} = user} ->
if connected?(socket),
do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
{:ok,
socket
|> assign(:page_title, "Dashboard")
|> assign(:user, user)
|> assign(:accessible_ids, Repositories.relevant_repo_ids_for_user(user))
|> assign(:maintained_ids, Repositories.maintained_repo_ids_for_user(user))
|> assign(:orgs, Organizations.list_organizations_for(user))
|> assign(:org_invitations, Organizations.list_pending_invitations_for(user))
|> assign(:pins, Profiles.list_pins(user))
|> load_work()
|> load_repos()
|> load_ci()}
_ ->
{:ok, assign(socket, :page_title, "git gud")}
end
end
defp load_work(socket) do
%{user: user, accessible_ids: accessible, maintained_ids: maintained} = socket.assigns
socket
|> assign(
:my_prs,
PullRequests.list_open_authored_by(user.id, accessible, limit: @panel_limit)
)
|> assign(
:my_issues,
Issues.list_open_authored_by(user.id, accessible, limit: @panel_limit)
)
|> assign(
:requested_reviews,
PullRequests.list_open_review_requests(user.id, limit: @panel_limit)
)
|> assign(
:review_queue,
PullRequests.list_open_awaiting_review(user.id, maintained, limit: @panel_limit)
)
end
defp load_repos(socket) do
recent =
socket.assigns.accessible_ids
|> Repositories.list_recently_pushed(limit: @panel_limit)
assign(socket, :recent_repos, recent)
end
defp load_ci(socket) do
%{user: user, accessible_ids: ids} = socket.assigns
active =
Workflows.list_runs_for_user(user.id, ids, statuses: Workflows.active_statuses())
socket
|> assign(:active_runs, active)
|> assign(
:recent_runs,
Workflows.list_runs_for_user(user.id, ids, limit: @panel_limit)
)
end
@impl true
def handle_event("accept_org_invitation", %{"id" => id}, socket) do
respond(socket, id, &Organizations.accept_invitation/2, "Joined the organization.")
end
def handle_event("decline_org_invitation", %{"id" => id}, socket) do
respond(socket, id, &Organizations.decline_invitation/2, "Invitation declined.")
end
# Both answers do the same bookkeeping; only the context call and the
# confirmation differ. The invitation is looked up in the assigns, so
# a viewer can only answer one that was actually addressed to them.
defp respond(socket, id, fun, message) do
user = socket.assigns.user
case Enum.find(socket.assigns.org_invitations, &(&1.id == String.to_integer(id))) do
nil ->
{:noreply, socket}
invitation ->
case fun.(invitation, user) do
{:ok, _} ->
{:noreply,
socket
|> assign(:org_invitations, Organizations.list_pending_invitations_for(user))
|> assign(:orgs, Organizations.list_organizations_for(user))
|> put_flash(:info, message)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Could not answer that invitation.")}
end
end
end
@impl true
def handle_info({:ci_run_changed, _run_id}, socket) do
{:noreply, load_ci(socket)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
# ── view helpers ─────────────────────────────────────────────────────
# Once someone has actually been asked, say so; otherwise the panel is
# still inferring from maintainer status and should admit it.
defp review_hint([]), do: "Open in repos you maintain, opened by someone else"
defp review_hint(_requested), do: "Requested of you, plus repos you maintain"
defp inviter_name(%{invited_by: %{handle: h}}) when is_binary(h), do: h
defp inviter_name(_), do: "An admin"
defp signed_in?(%{user: %{}}), do: true
defp signed_in?(_), do: false
defp repo_handle(%{organization: %{handle: h}}) when is_binary(h), do: h
defp repo_handle(%{owner: user}), do: Storage.owner_handle(user)
defp repo_path(repo), do: "#{repo_handle(repo)}/#{repo.name}"
defp ci_badge("success"), do: "badge-success"
defp ci_badge("failure"), do: "badge-error"
defp ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning"
defp ci_badge(_), do: "badge-ghost"
defp author_name(%{author: %{handle: h}}) when is_binary(h), do: h
defp author_name(_), do: "someone"
defp ago(nil), do: ""
defp ago(%DateTime{} = dt) do
case DateTime.diff(DateTime.utc_now(), dt) do
s when s < 60 -> "just now"
s when s < 3600 -> "#{div(s, 60)}m ago"
s when s < 86_400 -> "#{div(s, 3600)}h ago"
s when s < 2_592_000 -> "#{div(s, 86_400)}d ago"
s when s < 31_536_000 -> "#{div(s, 2_592_000)}mo ago"
s -> "#{div(s, 31_536_000)}y ago"
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<%= if signed_in?(@current_scope) do %>
<.dashboard
user={@user}
my_prs={@my_prs}
my_issues={@my_issues}
requested_reviews={@requested_reviews}
review_queue={@review_queue}
recent_repos={@recent_repos}
active_runs={@active_runs}
recent_runs={@recent_runs}
pins={@pins}
orgs={@orgs}
org_invitations={@org_invitations}
/>
<% else %>
<.landing current_scope={@current_scope} />
<% end %>
<.site_footer />
</Layouts.app>
"""
end
attr :user, :map, required: true
attr :my_prs, :list, required: true
attr :my_issues, :list, required: true
attr :requested_reviews, :list, required: true
attr :review_queue, :list, required: true
attr :recent_repos, :list, required: true
attr :active_runs, :list, required: true
attr :recent_runs, :list, required: true
attr :pins, :list, required: true
attr :orgs, :list, required: true
attr :org_invitations, :list, required: true
defp dashboard(assigns) do
~H"""
<div class="space-y-6 pt-4">
<header class="flex items-baseline justify-between gap-4 flex-wrap">
<div>
<h1 class="text-2xl font-semibold">
Welcome back, <span class="font-mono">{@user.handle}</span>
</h1>
<p class="text-sm opacity-70 mt-1">What's open, and what's waiting on you.</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<.link navigate={~p"/repositories/new"} class="btn btn-sm btn-primary">
<.icon name="hero-plus-micro" class="size-4" /> New repository
</.link>
<.link navigate={~p"/repositories"} class="btn btn-sm btn-ghost border border-base-300">
Browse
</.link>
</div>
</header>
<section
:if={@org_invitations != []}
class="rounded-lg border border-primary/40 bg-primary/5 p-4"
>
<h2 class="font-semibold mb-2">Organization invitations</h2>
<ul class="space-y-2">
<li
:for={inv <- @org_invitations}
class="flex items-center gap-3 flex-wrap text-sm"
>
<.avatar name={inv.organization.handle} size="sm" alt={inv.organization.handle} />
<span class="flex-1 min-w-0">
<span class="font-medium">{inviter_name(inv)}</span>
has invited you to join
<.link navigate={~p"/orgs/#{inv.organization.handle}"} class="link link-hover">
{Organization.display(inv.organization)}
</.link>
as <span class="font-mono">{inv.role}</span>.
</span>
<button
type="button"
phx-click="accept_org_invitation"
phx-value-id={inv.id}
class="btn btn-xs btn-primary"
>
Accept
</button>
<button
type="button"
phx-click="decline_org_invitation"
phx-value-id={inv.id}
class="btn btn-xs btn-ghost"
>
Decline
</button>
</li>
</ul>
</section>
<div class="grid lg:grid-cols-2 gap-4">
<.panel
title="Needs your review"
hint={review_hint(@requested_reviews)}
empty="Nothing waiting on you."
count={length(@requested_reviews) + length(@review_queue)}
>
<.work_row
:for={pr <- @requested_reviews}
path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
repo={repo_path(pr.repository)}
number={pr.number}
title={pr.title}
meta={"you were asked to review · #{ago(pr.inserted_at)}"}
/>
<.work_row
:for={pr <- @review_queue}
path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
repo={repo_path(pr.repository)}
number={pr.number}
title={pr.title}
meta={"by #{author_name(pr)} · #{ago(pr.inserted_at)}"}
/>
</.panel>
<.panel
title="Your pull requests"
hint="Open PRs you opened"
empty="No open pull requests."
count={length(@my_prs)}
>
<.work_row
:for={pr <- @my_prs}
path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
repo={repo_path(pr.repository)}
number={pr.number}
title={pr.title}
meta={ago(pr.inserted_at)}
/>
</.panel>
<.panel
title="Your issues"
hint="Open issues you opened"
empty="No open issues."
count={length(@my_issues)}
>
<.work_row
:for={issue <- @my_issues}
path={
~p"/r/#{repo_handle(issue.repository)}/#{issue.repository.name}/issues/#{issue.number}"
}
repo={repo_path(issue.repository)}
number={issue.number}
title={issue.title}
meta={ago(issue.inserted_at)}
/>
</.panel>
<.panel
title="CI"
hint={
if @active_runs == [],
do: "Latest runs across your repos",
else: "#{length(@active_runs)} running now"
}
empty="No runs yet."
count={length(@recent_runs)}
more_label="My CI"
more_path={~p"/users/ci"}
>
<li :for={run <- @recent_runs} class="py-2 flex items-baseline gap-2 min-w-0">
<span class={["badge badge-xs shrink-0", ci_badge(run.status)]}>{run.status}</span>
<.link
navigate={
~p"/r/#{repo_handle(run.repository)}/#{run.repository.name}/actions/#{run.number}"
}
class="text-sm link link-hover truncate"
>
{run.workflow_name || "run ##{run.number}"}
</.link>
<span class="text-xs opacity-60 font-mono truncate">{repo_path(run.repository)}</span>
<span class="text-xs opacity-50 ml-auto shrink-0">{ago(run.inserted_at)}</span>
</li>
</.panel>
</div>
<section :if={@pins != []}>
<h2 class="text-lg font-semibold mb-2">Pinned</h2>
<GitGudWeb.PinnedReposGrid.grid pins={@pins} />
</section>
<section :if={@recent_repos != []}>
<header class="flex items-baseline justify-between mb-2">
<h2 class="text-lg font-semibold">Recently pushed</h2>
<.link navigate={~p"/repositories"} class="text-xs link link-hover opacity-60">
All repositories →
</.link>
</header>
<ul class="divide-y divide-base-300">
<li :for={repo <- @recent_repos} class="py-2 flex items-baseline gap-2 min-w-0">
<.link
navigate={~p"/r/#{repo_handle(repo)}/#{repo.name}"}
class="font-mono text-sm link link-hover truncate"
>
{repo_path(repo)}
</.link>
<span :if={repo.description} class="text-xs opacity-60 truncate">
{repo.description}
</span>
<span class="text-xs opacity-50 ml-auto shrink-0">{ago(repo.pushed_at)}</span>
</li>
</ul>
</section>
<section :if={@orgs != []}>
<header class="flex items-baseline justify-between mb-2">
<h2 class="text-lg font-semibold">Your organizations</h2>
<.link navigate={~p"/orgs"} class="text-xs link link-hover opacity-60">
All organizations →
</.link>
</header>
<div class="flex flex-wrap gap-2">
<.link
:for={org <- @orgs}
navigate={~p"/orgs/#{org.handle}"}
class="flex items-center gap-2 rounded-lg border border-base-300 px-3 py-2 transition-colors hover:border-primary/50"
>
<.avatar name={org.handle} size="sm" alt={org.handle} />
<span class="text-sm">{Organization.display(org)}</span>
</.link>
</div>
</section>
<.quick_links user={@user} />
</div>
"""
end
attr :title, :string, required: true
attr :hint, :string, default: nil
attr :empty, :string, required: true
attr :count, :integer, required: true
attr :more_label, :string, default: nil
attr :more_path, :string, default: nil
slot :inner_block, required: true
defp panel(assigns) do
~H"""
<section class="rounded-lg border border-base-300 p-4">
<header class="flex items-baseline justify-between gap-2 mb-2">
<div class="min-w-0">
<h2 class="font-semibold">
{@title}
<span :if={@count > 0} class="badge badge-sm badge-ghost ml-1">{@count}</span>
</h2>
<p :if={@hint} class="text-xs opacity-60">{@hint}</p>
</div>
<.link
:if={@more_path}
navigate={@more_path}
class="text-xs link link-hover opacity-60 shrink-0"
>
{@more_label}
</.link>
</header>
<p :if={@count == 0} class="py-4 text-sm opacity-50">{@empty}</p>
<ul :if={@count > 0} class="divide-y divide-base-300">
{render_slot(@inner_block)}
</ul>
</section>
"""
end
attr :path, :string, required: true
attr :repo, :string, required: true
attr :number, :integer, required: true
attr :title, :string, required: true
attr :meta, :string, required: true
defp work_row(assigns) do
~H"""
<li class="py-2 min-w-0">
<.link navigate={@path} class="text-sm link link-hover line-clamp-1">{@title}</.link>
<p class="text-xs opacity-60 mt-0.5 truncate">
<span class="font-mono">{@repo}#{@number}</span> · {@meta}
</p>
</li>
"""
end
end