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
3 changes: 3 additions & 0 deletions internal/app/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ func registerRoutes(r *cais.Router, deps Deps, cfg cais.Config) {
home := handlers.NewHomeHandler(deps.Site, deps.Inertia, deps.Loader)
r.Get("/", home.Index)
r.Get("/u/{login}", cais.StringParam("login", home.Show))
r.Get("/u/{login}/charts", cais.StringParam("login", home.ShowCharts))
r.Get("/u/{login}/all", cais.StringParam("login", home.ShowAll))
r.Get("/u/{login}/all/charts", cais.StringParam("login", home.ShowAllCharts))
r.Get("/u/{login}/orgs/{org}", cais.StringParams("login", "org", home.ShowOrg))
r.Get("/u/{login}/orgs/{org}/charts", cais.StringParams("login", "org", home.ShowOrgCharts))
r.Get("/traffic/{owner}/{repo}", cais.StringParams("owner", "repo", home.RepoTraffic))
}
52 changes: 41 additions & 11 deletions internal/handlers/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,54 +25,83 @@ func NewHomeHandler(site meta.Site, i *inertia.Inertia, loader *catalog.Loader)
return &HomeHandler{site: site, inertia: i, loader: loader}
}

const (
viewCatalog = "catalog"
viewCharts = "charts"
)

func (h *HomeHandler) Index(w http.ResponseWriter, r *http.Request) {
h.render(w, r, catalog.Snapshot{}, "", "", false)
h.render(w, r, catalog.Snapshot{}, "", "", false, viewCatalog)
}

func (h *HomeHandler) Show(w http.ResponseWriter, r *http.Request, login string) {
h.showUser(w, r, login, viewCatalog)
}

func (h *HomeHandler) ShowCharts(w http.ResponseWriter, r *http.Request, login string) {
h.showUser(w, r, login, viewCharts)
}

func (h *HomeHandler) showUser(w http.ResponseWriter, r *http.Request, login, view string) {
login = normalizeLogin(login)
if login == "" {
h.render(w, r, catalog.Snapshot{}, "", "Type a GitHub username to open their catalog.", false)
h.render(w, r, catalog.Snapshot{}, "", "Type a GitHub username to open their catalog.", false, view)
return
}
snap, err := h.loader.User(r.Context(), login)
if err != nil {
h.render(w, r, catalog.Snapshot{}, login, catalogError(err), false)
h.render(w, r, catalog.Snapshot{}, login, catalogError(err), false, view)
return
}
h.render(w, r, snap, login, "", true)
h.render(w, r, snap, login, "", true, view)
}

func (h *HomeHandler) ShowAll(w http.ResponseWriter, r *http.Request, login string) {
h.showAll(w, r, login, viewCatalog)
}

func (h *HomeHandler) ShowAllCharts(w http.ResponseWriter, r *http.Request, login string) {
h.showAll(w, r, login, viewCharts)
}

func (h *HomeHandler) showAll(w http.ResponseWriter, r *http.Request, login, view string) {
login = normalizeLogin(login)
if login == "" {
h.render(w, r, catalog.Snapshot{}, "", "Type a GitHub username to open their catalog.", false)
h.render(w, r, catalog.Snapshot{}, "", "Type a GitHub username to open their catalog.", false, view)
return
}
snap, err := h.loader.All(r.Context(), login)
if err != nil {
h.render(w, r, catalog.Snapshot{}, login, catalogError(err), false)
h.render(w, r, catalog.Snapshot{}, login, catalogError(err), false, view)
return
}
h.render(w, r, snap, login, "", true)
h.render(w, r, snap, login, "", true, view)
}

func (h *HomeHandler) ShowOrg(w http.ResponseWriter, r *http.Request, login, org string) {
h.showOrg(w, r, login, org, viewCatalog)
}

func (h *HomeHandler) ShowOrgCharts(w http.ResponseWriter, r *http.Request, login, org string) {
h.showOrg(w, r, login, org, viewCharts)
}

func (h *HomeHandler) showOrg(w http.ResponseWriter, r *http.Request, login, org, view string) {
login = normalizeLogin(login)
org = normalizeLogin(org)
if login == "" || org == "" {
h.render(w, r, catalog.Snapshot{}, login, "Type a GitHub username to open their catalog.", false)
h.render(w, r, catalog.Snapshot{}, login, "Type a GitHub username to open their catalog.", false, view)
return
}
snap, err := h.loader.Org(r.Context(), login, org)
if err != nil {
h.render(w, r, catalog.Snapshot{}, login, catalogError(err), false)
h.render(w, r, catalog.Snapshot{}, login, catalogError(err), false, view)
return
}
h.render(w, r, snap, login, "", true)
h.render(w, r, snap, login, "", true, view)
}

func (h *HomeHandler) render(w http.ResponseWriter, r *http.Request, snap catalog.Snapshot, lookup, errMsg string, populated bool) {
func (h *HomeHandler) render(w http.ResponseWriter, r *http.Request, snap catalog.Snapshot, lookup, errMsg string, populated bool, view string) {
props := inertia.Props{
"title": "Projects Viewer",
"site": meta.ForRequest(h.site, r),
Expand All @@ -83,6 +112,7 @@ func (h *HomeHandler) render(w http.ResponseWriter, r *http.Request, snap catalo
"error": errMsg,
"populated": populated,
"lookup": lookup,
"view": view,
}
if snap.Orgs == nil {
props["orgs"] = []catalog.Org{}
Expand Down
68 changes: 68 additions & 0 deletions internal/handlers/home_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,74 @@ func TestHomeHandler_ShowUserCatalog(t *testing.T) {
}
}

func TestHomeHandler_ShowUserCatalogDefaultsToCatalogView(t *testing.T) {
h := newHomeHandler(t, homeGitHub{
user: githubapi.User{Login: "octocat", Name: "The Octocat", PublicRepos: 8},
repos: []githubapi.Repo{{Name: "hello-world", Stars: 10, OwnerLogin: "octocat"}},
})
rr := httptest.NewRecorder()
h.Show(rr, inertiaRequest(http.MethodGet, "/u/octocat", nil), "octocat")
if assertInertiaProp(t, rr, "view") != "catalog" {
t.Fatalf("view = %v, want catalog", assertInertiaProp(t, rr, "view"))
}
}

func TestHomeHandler_ShowUserCharts(t *testing.T) {
h := newHomeHandler(t, homeGitHub{
user: githubapi.User{Login: "octocat", Name: "The Octocat", PublicRepos: 8},
repos: []githubapi.Repo{{Name: "hello-world", Stars: 10, OwnerLogin: "octocat"}},
})
rr := httptest.NewRecorder()
h.ShowCharts(rr, inertiaRequest(http.MethodGet, "/u/octocat/charts", nil), "octocat")
assertInertiaComponent(t, rr, "Home")
if assertInertiaProp(t, rr, "view") != "charts" {
t.Fatalf("view = %v, want charts", assertInertiaProp(t, rr, "view"))
}
if assertInertiaProp(t, rr, "populated") != true {
t.Fatal("expected populated")
}
}

func TestHomeHandler_ShowAllCharts(t *testing.T) {
h := newHomeHandler(t, homeGitHub{
token: true,
user: githubapi.User{Login: "puppe1990", Name: "Matheus"},
repos: []githubapi.Repo{{Name: "cais", FullName: "puppe1990/cais", OwnerLogin: "puppe1990"}},
orgs: []githubapi.Org{{Login: "hidden-org"}},
orgRepos: []githubapi.Repo{{Name: "private-app", FullName: "hidden-org/private-app", OwnerLogin: "hidden-org", Private: true}},
})
rr := httptest.NewRecorder()
h.ShowAllCharts(rr, inertiaRequest(http.MethodGet, "/u/puppe1990/all/charts", nil), "puppe1990")
if assertInertiaProp(t, rr, "view") != "charts" {
t.Fatalf("view = %v, want charts", assertInertiaProp(t, rr, "view"))
}
source := assertInertiaProp(t, rr, "source").(map[string]any)
if source["type"] != catalog.SourceAll {
t.Fatalf("source = %#v", source)
}
repos := assertInertiaProp(t, rr, "repos").([]any)
if len(repos) != 2 {
t.Fatalf("repos = %#v", repos)
}
}

func TestHomeHandler_ShowOrgCharts(t *testing.T) {
h := newHomeHandler(t, homeGitHub{
user: githubapi.User{Login: "octocat", Name: "The Octocat"},
orgs: []githubapi.Org{{Login: "github"}},
orgRepos: []githubapi.Repo{{Name: "linguist", OwnerLogin: "github", Stars: 4}},
})
rr := httptest.NewRecorder()
h.ShowOrgCharts(rr, inertiaRequest(http.MethodGet, "/u/octocat/orgs/github/charts", nil), "octocat", "github")
if assertInertiaProp(t, rr, "view") != "charts" {
t.Fatalf("view = %v, want charts", assertInertiaProp(t, rr, "view"))
}
source := assertInertiaProp(t, rr, "source").(map[string]any)
if source["type"] != catalog.SourceOrg || source["login"] != "github" {
t.Fatalf("source = %#v", source)
}
}

func TestHomeHandler_ShowNotFound(t *testing.T) {
h := newHomeHandler(t, homeGitHub{userErr: githubapi.ErrNotFound})
rr := httptest.NewRecorder()
Expand Down
18 changes: 13 additions & 5 deletions web/src/pages/Home.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script>
import { router } from "@inertiajs/svelte";
import { inertia, router } from "@inertiajs/svelte";
import ThemeToggle from "../components/ThemeToggle.svelte";
import RepoCard from "../components/RepoCard.svelte";
import CatalogCharts from "../components/CatalogCharts.svelte";
Expand All @@ -17,6 +17,7 @@
export let error = "";
export let populated = false;
export let lookup = "";
export let view = "catalog";

let username = lookup || "";
let language = "";
Expand All @@ -32,7 +33,6 @@
let sortOpen = false;
let loading = false;
let preferTraffic = false;
let view = "catalog";
let page = 1;
let lastFilterKey = "";
$: hasTraffic = (repos || []).some((repo) => repo.traffic?.available);
Expand All @@ -45,7 +45,6 @@
if (sortBy === "traffic" || sortBy === "views" || sortBy === "view_uniques" || sortBy === "clones" || sortBy === "clone_uniques") {
sortBy = "stars";
}
if (view === "charts") view = "catalog";
}

$: langs = languageOptions(repos);
Expand Down Expand Up @@ -104,6 +103,15 @@
return `${shown} of ${loaded} repositories`;
})();

$: basePath = (() => {
const login = profile?.login || "";
if (!login) return "/";
const root = `/u/${encodeURIComponent(login)}`;
if (source?.type === "org" && source?.login) return `${root}/orgs/${encodeURIComponent(source.login)}`;
if (source?.type === "all") return `${root}/all`;
return root;
})();

$: blogUrl = absoluteUrl(profile?.blog);
</script>

Expand Down Expand Up @@ -276,8 +284,8 @@
{#if populated}
{#if hasTraffic}
<div class="view-tabs" role="tablist" aria-label="Catalog view">
<button type="button" class="view-tab" role="tab" id="tab-catalog" aria-controls="projects" aria-selected={view === "catalog"} on:click={() => (view = "catalog")}>Catalog</button>
<button type="button" class="view-tab" role="tab" id="tab-charts" aria-controls="panel-charts" aria-selected={view === "charts"} on:click={() => (view = "charts")}>Charts</button>
<a class="view-tab" role="tab" id="tab-catalog" aria-controls="projects" aria-selected={view === "catalog"} href={basePath} use:inertia>Catalog</a>
<a class="view-tab" role="tab" id="tab-charts" aria-controls="panel-charts" aria-selected={view === "charts"} href={`${basePath}/charts`} use:inertia>Charts</a>
</div>
{/if}
{#if view === "catalog" || !hasTraffic}
Expand Down
77 changes: 49 additions & 28 deletions web/src/pages/Home.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Home from "./Home.svelte";

vi.mock("@inertiajs/svelte", () => ({
router: { get: vi.fn() },
inertia: () => {},
}));

describe("Home", () => {
Expand Down Expand Up @@ -103,38 +104,58 @@ describe("Home", () => {
expect(router.get).toHaveBeenCalledWith("/u/puppe1990/all", {}, expect.any(Object));
});

test("switches from catalog cards to a charts tab", async () => {
render(Home, {
props: {
populated: true,
lookup: "octocat",
profile: { login: "octocat", name: "The Octocat", html_url: "https://github.com/octocat", avatar_url: "https://example.com/a.png", public_repos: 1, followers: 1, following: 1 },
orgs: [],
source: { type: "user", login: "octocat" },
repos: [
{
name: "hello-world",
html_url: "https://github.com/octocat/hello-world",
stargazers_count: 10,
forks_count: 2,
language: "Go",
description: "demo",
updated_at: "2026-08-01T00:00:00Z",
traffic: { available: true, views: 40, view_uniques: 22, clones: 9, clone_uniques: 6 },
},
],
error: "",
const trafficHomeProps = {
populated: true,
lookup: "octocat",
profile: { login: "octocat", name: "The Octocat", html_url: "https://github.com/octocat", avatar_url: "https://example.com/a.png", public_repos: 1, followers: 1, following: 1 },
orgs: [],
source: { type: "user", login: "octocat" },
repos: [
{
name: "hello-world",
html_url: "https://github.com/octocat/hello-world",
stargazers_count: 10,
forks_count: 2,
language: "Go",
description: "demo",
updated_at: "2026-08-01T00:00:00Z",
traffic: { available: true, views: 40, view_uniques: 22, clones: 9, clone_uniques: 6 },
},
});
expect(screen.getByRole("tab", { name: "Charts" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument();
await fireEvent.click(screen.getByRole("tab", { name: "Charts" }));
],
error: "",
};

test("links catalog and charts to their own routes", () => {
render(Home, { props: trafficHomeProps });
const catalog = screen.getByRole("tab", { name: "Catalog" });
const charts = screen.getByRole("tab", { name: "Charts" });
expect(catalog).toHaveAttribute("href", "/u/octocat");
expect(charts).toHaveAttribute("href", "/u/octocat/charts");
expect(catalog).toHaveAttribute("aria-selected", "true");
expect(charts).toHaveAttribute("aria-selected", "false");
expect(screen.getByRole("tabpanel", { name: "Catalog" })).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Top unique visitors" })).not.toBeInTheDocument();
});

test("renders the charts panel on its own route", () => {
render(Home, { props: { ...trafficHomeProps, view: "charts" } });
expect(screen.getByRole("tab", { name: "Charts" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "Top unique visitors" })).toBeInTheDocument();
expect(screen.queryByRole("tabpanel", { name: "Catalog" })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole("tab", { name: "Catalog" }));
expect(screen.getByRole("tabpanel", { name: "Catalog" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument();
});

test("links the charts route for the combined catalog", () => {
render(Home, {
props: { ...trafficHomeProps, profile: { ...trafficHomeProps.profile, login: "puppe1990" }, source: { type: "all", login: "puppe1990" } },
});
expect(screen.getByRole("tab", { name: "Charts" })).toHaveAttribute("href", "/u/puppe1990/all/charts");
});

test("links the charts route for an org catalog", () => {
render(Home, {
props: { ...trafficHomeProps, source: { type: "org", login: "github" } },
});
expect(screen.getByRole("tab", { name: "Charts" })).toHaveAttribute("href", "/u/octocat/orgs/github/charts");
});

test("paginates the catalog twenty repositories at a time", async () => {
Expand Down
3 changes: 3 additions & 0 deletions web/static/css/atlas.css
Original file line number Diff line number Diff line change
Expand Up @@ -1266,12 +1266,15 @@ a.stat:hover {
}

.view-tab {
display: inline-flex;
align-items: center;
border: 0;
background: transparent;
color: var(--ink-dim);
border-radius: 999px;
padding: 0.42rem 1rem;
font: 600 0.88rem/1 var(--font-ui);
text-decoration: none;
cursor: pointer;
}

Expand Down
Loading