Replace the landing page's ad grid with a dashboard for signed-in users

8a2b95a · Gabriel Morell · 2026-09-09 21:38

11 files +919 -240
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/issues.ex
+22 −0
@@ -47,6 +47,28 @@ defmodule GitGud.Issues do
47 47 Enum.map(issues, &Map.put(&1, :labels, Map.get(labels_by_id, &1.id, [])))
48 48 end
49 49
50 + @doc """
51 + Open issues authored by `user_id`, across every repo they can still
52 + see: public and internal repos, plus anything in
53 + `accessible_repo_ids` (pass
54 + `Repositories.relevant_repo_ids_for_user/1`). Newest first. Powers
55 + the signed-in dashboard.
56 + """
57 + def list_open_authored_by(user_id, accessible_repo_ids, opts \\ [])
58 + when is_integer(user_id) and is_list(accessible_repo_ids) do
59 + limit = Keyword.get(opts, :limit, 10)
60 +
61 + from(i in Issue,
62 + join: r in assoc(i, :repository),
63 + where: i.author_id == ^user_id and i.state == "open",
64 + where: r.visibility in ["public", "internal"] or r.id in ^accessible_repo_ids,
65 + order_by: [desc: i.inserted_at],
66 + limit: ^limit,
67 + preload: [repository: {r, [:owner, :organization]}]
68 + )
69 + |> Repo.all()
70 + end
71 +
50 72 defp filter_state(q, "all"), do: q
51 73
52 74 defp filter_state(q, state) when state in ["open", "closed"],
modified lib/git_gud/pull_requests.ex
+42 −0
@@ -51,6 +51,48 @@ defmodule GitGud.PullRequests do
51 51 Enum.map(prs, &Map.put(&1, :labels, Map.get(labels_by_id, &1.id, [])))
52 52 end
53 53
54 + @doc """
55 + Open PRs authored by `user_id`, across every repo they can still see:
56 + public and internal repos, plus anything in `accessible_repo_ids`
57 + (pass `Repositories.relevant_repo_ids_for_user/1`). Ordered newest
58 + first. Powers the signed-in dashboard.
59 + """
60 + def list_open_authored_by(user_id, accessible_repo_ids, opts \\ [])
61 + when is_integer(user_id) and is_list(accessible_repo_ids) do
62 + limit = Keyword.get(opts, :limit, 10)
63 +
64 + from(p in PullRequest,
65 + join: r in assoc(p, :repository),
66 + where: p.author_id == ^user_id and p.state == "open",
67 + where: r.visibility in ["public", "internal"] or r.id in ^accessible_repo_ids,
68 + order_by: [desc: p.inserted_at],
69 + limit: ^limit,
70 + preload: [repository: {r, [:owner, :organization]}]
71 + )
72 + |> Repo.all()
73 + end
74 +
75 + @doc """
76 + Open PRs sitting in repos the user maintains that someone else
77 + openedthe closest thing to a review queue, since PRs carry an
78 + author but no review requests. Pass
79 + `Repositories.maintained_repo_ids_for_user/1`.
80 + """
81 + def list_open_awaiting_review(user_id, maintained_repo_ids, opts \\ [])
82 + when is_integer(user_id) and is_list(maintained_repo_ids) do
83 + limit = Keyword.get(opts, :limit, 10)
84 +
85 + from(p in PullRequest,
86 + join: r in assoc(p, :repository),
87 + where: p.repository_id in ^maintained_repo_ids and p.state == "open",
88 + where: is_nil(p.author_id) or p.author_id != ^user_id,
89 + order_by: [desc: p.inserted_at],
90 + limit: ^limit,
91 + preload: [:author, repository: {r, [:owner, :organization]}]
92 + )
93 + |> Repo.all()
94 + end
95 +
54 96 @doc "How many pull requests are open against this repo."
55 97 def count_open(%Repository{id: rid}) do
56 98 PullRequest
modified lib/git_gud/repositories.ex
+35 −0
@@ -154,6 +154,41 @@ defmodule GitGud.Repositories do
154 154 (owned_and_member ++ team_granted) |> Enum.uniq()
155 155 end
156 156
157 + @doc """
158 + The most recently pushed repos out of `repo_ids`, newest first.
159 + Repos that have never been pushed to are left out entirely rather
160 + than sorted to the endthis feeds a "recently pushed" panel, and an
161 + empty repo has nothing to show there.
162 + """
163 + def list_recently_pushed(repo_ids, opts \\ []) when is_list(repo_ids) do
164 + limit = Keyword.get(opts, :limit, 10)
165 +
166 + from(r in Repository,
167 + where: r.id in ^repo_ids and not is_nil(r.pushed_at),
168 + order_by: [desc: r.pushed_at],
169 + limit: ^limit,
170 + preload: [:owner, :organization]
171 + )
172 + |> Repo.all()
173 + end
174 +
175 + @doc """
176 + Ids of repos the user maintains: ones they own outright, plus every
177 + repo owned by an org where they're an admin. Narrower than
178 + `relevant_repo_ids_for_user/1`, which also counts plain org
179 + membership and team grantsthis is the set whose pull requests are
180 + the user's to review.
181 + """
182 + def maintained_repo_ids_for_user(%User{id: uid}) do
183 + from(r in Repository,
184 + left_join: m in GitGud.Organizations.Membership,
185 + on: m.organization_id == r.organization_id and m.user_id == ^uid and m.role == "admin",
186 + where: (r.owner_id == ^uid and is_nil(r.organization_id)) or not is_nil(m.id),
187 + select: r.id
188 + )
189 + |> Repo.all()
190 + end
191 +
157 192 @doc """
158 193 Repos a user can pin to their personal profile: their own + any repo
159 194 in an organization they're a member of. Returns rows with `:owner` +
added lib/git_gud_web/components/landing_components.ex
+208 −0
@@ -0,0 +1,208 @@
1 +defmodule GitGudWeb.LandingComponents do
2 + @moduledoc """
3 + The logged-out face of `/`: hero, feature grid, quick links, footer.
4 +
5 + Lifted out of the old `PageHTML` when `/` became a LiveView so the
6 + anonymous landing could stay exactly as it was while signed-in
7 + visitors get `GitGudWeb.PageLive.Home`'s dashboard instead.
8 + """
9 + use GitGudWeb, :html
10 +
11 + @doc "Hero, calls to action, and the feature grid — the logged-out pitch."
12 + attr :current_scope, :map, default: nil
13 +
14 + def landing(assigns) do
15 + ~H"""
16 + <section class="space-y-3 pt-6 pb-10">
17 + <p class="font-mono text-xs uppercase tracking-[0.18em] text-accent">
18 + Self-hosted code forge
19 + </p>
20 + <h1 class="text-5xl sm:text-6xl font-bold tracking-tight">
21 + git gud<span class="text-primary">.</span>
22 + </h1>
23 + <p class="text-lg opacity-80 max-w-2xl">
24 + Repos, issues, pull requests, CI, wiki, federation. Phoenix on the front,
25 + <span class="font-mono">gitoxide</span>
26 + on the back, ForgeFed by default. Built so you can host the small
27 + project that becomes a movement.
28 + </p>
29 +
30 + <div class="flex flex-wrap gap-2 pt-2">
31 + <.link navigate={~p"/repositories"} class="btn btn-primary" id="cta-browse">
32 + Browse repositories <.icon name="hero-arrow-right-micro" class="size-4" />
33 + </.link>
34 + <.link
35 + navigate={~p"/users/register"}
36 + class="btn btn-ghost border border-base-300"
37 + id="cta-register"
38 + >
39 + Get an account
40 + </.link>
41 + </div>
42 + </section>
43 +
44 + <section class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-2">
45 + <.feature
46 + icon="hero-folder"
47 + title="Repos, smart-HTTP + SSH"
48 + body="Bare git on disk with a Postgres cache in front. Embedded SSH daemon — DB-backed key auth, no system git user. LFS too."
49 + />
50 + <.feature
51 + icon="hero-arrow-trending-up"
52 + title="Forks, PRs, line-level diffs"
53 + body="3-way merge, fork sync, compare any two refs. Diffs render hunk-by-hunk with syntax-highlighted lines; large diffs keep their hunks out of the DOM until you open the file."
54 + />
55 + <.feature
56 + icon="hero-bolt"
57 + title="Forgejo Actions CI"
58 + body="Talk protobuf to forgejo-runner. Per-task JWTs, encrypted secrets, artifacts + cache with hit matching, re-run + cancel from the UI."
59 + />
60 + <.feature
61 + icon="hero-globe-alt"
62 + title="Federation that works"
63 + body="ForgeFed over ActivityPub: federated PRs, federated reviews (Like/Dislike), federated comments. HTTP-signed; allowlist by default."
64 + />
65 + <.feature
66 + icon="hero-shield-check"
67 + title="Moderation up front"
68 + body="Per-repo interaction policy, quarantine queue, rate limits, peer-block suggestions, and a reversible 'replace with mod message' that keeps the original visible to admins + author."
69 + />
70 + <.feature
71 + icon="hero-finger-print"
72 + title="MFA + WebAuthn"
73 + body="TOTP authenticator apps, FIDO2 security keys (YubiKey, passkeys, Touch ID), recovery codes. Optional instance-wide enforcement with a grace window."
74 + />
75 + <.feature
76 + icon="hero-paint-brush"
77 + title="Markdown that links itself"
78 + body="GFM with cross-references — `#42` links to whichever issue or PR that number actually is, `#x2a` works too, `owner/repo#42` crosses repos. Quote-reply on every comment."
79 + />
80 + <.feature
81 + icon="hero-tag"
82 + title="Org-level labels"
83 + body="Define labels once at the org; every repo inherits unless it opts out. Color picker with a 16-swatch palette and a live preview badge."
84 + />
85 + <.feature
86 + icon="hero-book-open"
87 + title="Wiki + container registry"
88 + body="Sister .wiki.git you edit through the UI. Container packages mirrored from zot via webhook, browsable per-repo without leaving the forge."
89 + />
90 + <.feature
91 + icon="hero-user-circle"
92 + title="Profiles + identicon avatars"
93 + body="User + org profile pages at /u/:handle and /orgs/:handle. Bio, location, socials, pinned repos (drag-to-reorder, up to 7), and an auto-created alice/alice README repo that renders inline."
94 + />
95 + <.feature
96 + icon="hero-chart-bar"
97 + title="Language + contributor stats"
98 + body="0.3em GitHub-style bar above the file tree, click for /stats/languages or /stats/committers. Computed lazily by Oban from cached blob + commit metadata — no extra git walks on the request path."
99 + />
100 + <.feature
101 + icon="hero-ticket"
102 + title="Invite-gated signups"
103 + body="Three registration modes — open, invite-only (default), closed. Single-use invite links generated from /users/settings/invites, revocable anytime. Org members manageable from settings with a last-admin guard."
104 + />
105 + </section>
106 +
107 + <section class="mt-12 grid sm:grid-cols-2 gap-3 text-sm">
108 + <.quick_link
109 + icon="hero-list-bullet"
110 + label="All visible repositories"
111 + navigate={~p"/repositories"}
112 + />
113 + </section>
114 + """
115 + end
116 +
117 + @doc "Settings and admin shortcuts for a signed-in visitor."
118 + attr :user, :map, required: true
119 +
120 + def quick_links(assigns) do
121 + ~H"""
122 + <section class="grid sm:grid-cols-2 gap-3 text-sm">
123 + <.quick_link
124 + icon="hero-user-circle"
125 + label="Your profile"
126 + navigate={~p"/u/#{@user.handle}"}
127 + />
128 + <.quick_link icon="hero-key" label="SSH keys" navigate={~p"/users/settings/ssh-keys"} />
129 + <.quick_link
130 + icon="hero-finger-print"
131 + label="Two-factor auth"
132 + navigate={~p"/users/settings/two-factor"}
133 + />
134 + <.quick_link icon="hero-ticket" label="Invites" navigate={~p"/users/settings/invites"} />
135 + <.quick_link
136 + icon="hero-cog-6-tooth"
137 + label="Account settings"
138 + navigate={~p"/users/settings"}
139 + />
140 + <.quick_link
141 + :if={@user.is_admin}
142 + icon="hero-wrench-screwdriver"
143 + label="Federation admin"
144 + navigate={~p"/admin/federation"}
145 + />
146 + </section>
147 + """
148 + end
149 +
150 + @doc "Instance footer, shown to everyone."
151 + def site_footer(assigns) do
152 + ~H"""
153 + <footer class="py-10 sm:py-12 mt-8 border-t border-base-300 text-center">
154 + <p class="font-mono text-xs uppercase tracking-[0.25em] text-base-content/70 flex items-center justify-center gap-2">
155 + <span class="font-bold tracking-[0.16em]">GIT GUD</span>
156 + <span class="opacity-50">·</span>
157 + <span>a</span>
158 + <%!--
159 + Hardcoded oklch from the neiam-co accent (the value every
160 + ported theme assigns to --color-accent). Inline so the
161 + NEIAM mark stays yellow regardless of which theme the
162 + viewer has picked.
163 + --%>
164 + <span class="font-bold tracking-[0.16em]" style="color: oklch(96% 0.058 96);">
165 + <a href="https://neiam.org"> NEIAM </a>
166 + </span>
167 + <span>production</span>
168 + </p>
169 + </footer>
170 + """
171 + end
172 +
173 + @doc "Card used in the landing page feature grid."
174 + attr :icon, :string, required: true
175 + attr :title, :string, required: true
176 + attr :body, :string, required: true
177 +
178 + def feature(assigns) do
179 + ~H"""
180 + <div class="rounded-box border border-base-300 bg-base-200 p-4 space-y-2">
181 + <div class="flex items-center gap-2">
182 + <.icon name={@icon} class="size-5 text-primary" />
183 + <h3 class="font-semibold">{@title}</h3>
184 + </div>
185 + <p class="text-sm opacity-75">{@body}</p>
186 + </div>
187 + """
188 + end
189 +
190 + @doc "Inline link tile with leading icon and trailing chevron."
191 + attr :icon, :string, required: true
192 + attr :label, :string, required: true
193 + attr :navigate, :string, required: true
194 +
195 + def quick_link(assigns) do
196 + ~H"""
197 + <.link
198 + navigate={@navigate}
199 + class="flex items-center justify-between rounded-box px-4 py-3 bg-base-200 hover:bg-base-300"
200 + >
201 + <span class="flex items-center gap-2">
202 + <.icon name={@icon} class="size-4 text-primary" /> {@label}
203 + </span>
204 + <.icon name="hero-arrow-right-micro" class="size-4 opacity-60" />
205 + </.link>
206 + """
207 + end
208 +end
deleted lib/git_gud_web/controllers/page_controller.ex
+0 −7
@@ -1,7 +0,0 @@
1 defmodule GitGudWeb.PageController do
2 use GitGudWeb, :controller
3
4 def home(conn, _params) do
5 render(conn, :home)
6 end
7 end
deleted lib/git_gud_web/controllers/page_html.ex
+0 −45

Click to load diff…

deleted lib/git_gud_web/controllers/page_html/home.html.heex
+0 −170

Click to load diff…

added lib/git_gud_web/live/page_live/home.ex
+371 −0

Click to load diff…

modified lib/git_gud_web/router.ex
+1 −2

Click to load diff…

deleted test/git_gud_web/controllers/page_controller_test.exs
+0 −16

Click to load diff…

added test/git_gud_web/live/page_live/home_test.exs
+240 −0

Click to load diff…

Parents: bff9e3f