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
17 changes: 17 additions & 0 deletions web/src/components/CatalogPager.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script>
export let page = 1;
export let pages = 1;

function go(next) {
page = Math.min(pages, Math.max(1, next));
document.getElementById("projects")?.scrollIntoView?.({ behavior: "smooth", block: "start" });
}
</script>

{#if pages > 1}
<nav class="catalog-pager" aria-label="Catalog pages">
<button type="button" class="pager-btn" disabled={page <= 1} aria-label="Previous page" on:click={() => go(page - 1)}>Previous</button>
<p class="pager-status">Page {page} of {pages}</p>
<button type="button" class="pager-btn" disabled={page >= pages} aria-label="Next page" on:click={() => go(page + 1)}>Next</button>
</nav>
{/if}
15 changes: 15 additions & 0 deletions web/src/lib/catalogPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const CATALOG_PAGE_SIZE = 20;

export function sliceCatalogPage(items, page, size = CATALOG_PAGE_SIZE) {
const list = items || [];
const total = list.length;
const pages = Math.max(1, Math.ceil(total / size) || 1);
const current = Math.min(Math.max(1, Number(page) || 1), pages);
const start = (current - 1) * size;
return {
items: list.slice(start, start + size),
page: current,
pages,
total,
};
}
26 changes: 26 additions & 0 deletions web/src/lib/catalogPage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { CATALOG_PAGE_SIZE, sliceCatalogPage } from "./catalogPage.js";

const items = Array.from({ length: 45 }, (_, i) => ({ name: `repo-${i + 1}` }));

describe("sliceCatalogPage", () => {
test("keeps twenty repositories on the first page", () => {
expect(CATALOG_PAGE_SIZE).toBe(20);
const page = sliceCatalogPage(items, 1);
expect(page.items.map((repo) => repo.name)).toEqual(items.slice(0, 20).map((repo) => repo.name));
expect(page.page).toBe(1);
expect(page.pages).toBe(3);
expect(page.total).toBe(45);
});

test("returns the remainder on the last page", () => {
const page = sliceCatalogPage(items, 3);
expect(page.items.map((repo) => repo.name)).toEqual(["repo-41", "repo-42", "repo-43", "repo-44", "repo-45"]);
expect(page.page).toBe(3);
});

test("clamps a page past the end back onto the last page", () => {
expect(sliceCatalogPage(items, 99).page).toBe(3);
expect(sliceCatalogPage(items, 0).page).toBe(1);
});
});
3 changes: 3 additions & 0 deletions web/src/lib/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function applyFilters(repos, filters) {
const query = (filters.query || "").toLowerCase();
const includeForks = !!filters.includeForks;
const hasHomepage = !!filters.hasHomepage;
const visibility = filters.visibility || "public";
const sortBy = filters.sortBy || "stars";
const sortOrder = filters.sortOrder || "desc";
const sortThen = (filters.sortThen || "").trim();
Expand All @@ -66,6 +67,8 @@ export function applyFilters(repos, filters) {
const text = `${repo.name || ""} ${repo.description || ""}`.toLowerCase();
if (query && !text.includes(query)) return false;
if (!includeForks && repo.fork) return false;
if (visibility === "public" && repo.private) return false;
if (visibility === "private" && !repo.private) return false;
const homepage = (repo.homepage || "").trim();
if (hasHomepage && !homepage) return false;
return true;
Expand Down
15 changes: 15 additions & 0 deletions web/src/lib/filter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ describe("applyFilters", () => {
expect(got).toHaveLength(0);
});

test("lists public repositories by default and can show private or all", () => {
const mixed = [
{ name: "open", private: false, stargazers_count: 1 },
{ name: "secret", private: true, stargazers_count: 1 },
];
expect(applyFilters(mixed, { includeForks: true, sortBy: "name", sortOrder: "asc" }).map((r) => r.name)).toEqual(["open"]);
expect(applyFilters(mixed, { includeForks: true, visibility: "private", sortBy: "name", sortOrder: "asc" }).map((r) => r.name)).toEqual([
"secret",
]);
expect(applyFilters(mixed, { includeForks: true, visibility: "all", sortBy: "name", sortOrder: "asc" }).map((r) => r.name)).toEqual([
"open",
"secret",
]);
});

test("sorts by traffic views and clones", () => {
const withTraffic = [
{ name: "quiet", traffic: { views: 2, clones: 80, view_uniques: 2, clone_uniques: 10 } },
Expand Down
50 changes: 37 additions & 13 deletions web/src/pages/Home.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import ThemeToggle from "../components/ThemeToggle.svelte";
import RepoCard from "../components/RepoCard.svelte";
import CatalogCharts from "../components/CatalogCharts.svelte";
import CatalogPager from "../components/CatalogPager.svelte";
import SortModal from "../components/SortModal.svelte";
import { applyFilters, languageOptions } from "../lib/filter.js";
import { sliceCatalogPage } from "../lib/catalogPage.js";
import { absoluteUrl, formatCount } from "../lib/format.js";

export let profile = {};
Expand All @@ -21,6 +23,7 @@
let query = "";
let includeForks = true;
let hasHomepage = false;
let visibility = "public";
let sortBy = "stars";
let sortOrder = "desc";
let sortThen = "";
Expand All @@ -29,13 +32,21 @@
let loading = false;
let preferTraffic = false;
let view = "catalog";
let page = 1;
let lastFilterKey = "";
$: if (!preferTraffic && (repos || []).some((repo) => repo.traffic?.available)) {
sortBy = "traffic";
preferTraffic = true;
}

$: langs = languageOptions(repos);
$: visible = applyFilters(repos, { language, minStars, query, includeForks, hasHomepage, sortBy, sortOrder, sortThen, sortThenOrder });
$: visible = applyFilters(repos, { language, minStars, query, includeForks, hasHomepage, visibility, sortBy, sortOrder, sortThen, sortThenOrder });
$: filterKey = [language, minStars, query, includeForks, hasHomepage, visibility, sortBy, sortOrder, sortThen, sortThenOrder].join("|");
$: if (filterKey !== lastFilterKey) {
lastFilterKey = filterKey;
page = 1;
}
$: catalogPage = sliceCatalogPage(visible, page);
$: if (typeof document !== "undefined") {
document.body.classList.toggle("is-populated", !!populated);
}
Expand Down Expand Up @@ -195,6 +206,14 @@
{/each}
</select>
</label>
<label class="field field-visibility">
<span>Visibility</span>
<select id="filter-visibility" bind:value={visibility}>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="all">All</option>
</select>
</label>
<label class="field field-stars">
<span>Min stars</span>
<input id="filter-stars-min" type="number" min="0" placeholder="Any" inputmode="numeric" bind:value={minStars}>
Expand Down Expand Up @@ -242,21 +261,26 @@
<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>
</div>
{#if view === "catalog"}
<div id="projects" class="projects" role="tabpanel" aria-labelledby="tab-catalog">
<div id="projects" role="tabpanel" aria-labelledby="tab-catalog">
{#if visible.length === 0}
<div class="empty">
{#if source?.type === "org"}
<strong>Nothing in {source.login} matches.</strong>Loosen language, stars, or the homepage toggle.
{:else if source?.type === "all"}
<strong>Nothing across personal and orgs matches.</strong>Loosen language, stars, or the homepage toggle.
{:else}
<strong>Nothing matches these filters.</strong>Loosen language, stars, or the homepage toggle.
{/if}
<div class="projects">
<div class="empty">
{#if source?.type === "org"}
<strong>Nothing in {source.login} matches.</strong>Loosen language, stars, or the homepage toggle.
{:else if source?.type === "all"}
<strong>Nothing across personal and orgs matches.</strong>Loosen language, stars, or the homepage toggle.
{:else}
<strong>Nothing matches these filters.</strong>Loosen language, stars, or the homepage toggle.
{/if}
</div>
</div>
{:else}
{#each visible as repo, index}
<RepoCard {repo} {index} />
{/each}
<div class="projects">
{#each catalogPage.items as repo, index}
<RepoCard {repo} {index} />
{/each}
</div>
<CatalogPager bind:page pages={catalogPage.pages} />
{/if}
</div>
{:else}
Expand Down
56 changes: 56 additions & 0 deletions web/src/pages/Home.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,60 @@ describe("Home", () => {
await fireEvent.click(screen.getByRole("tab", { name: "Catalog" }));
expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument();
});

test("paginates the catalog twenty repositories at a time", async () => {
const repos = Array.from({ length: 21 }, (_, i) => ({
name: `repo-${i + 1}`,
html_url: `https://github.com/octocat/repo-${i + 1}`,
stargazers_count: 21 - i,
forks_count: 0,
language: "Go",
description: "demo",
updated_at: "2026-08-01T00:00:00Z",
}));
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: 21, followers: 1, following: 1 },
orgs: [],
source: { type: "user", login: "octocat" },
repos,
error: "",
},
});
expect(screen.getByRole("link", { name: "repo-1" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "repo-20" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "repo-21" })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole("button", { name: "Next page" }));
expect(screen.getByRole("link", { name: "repo-21" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "repo-1" })).not.toBeInTheDocument();
});

test("lists public repositories by default and offers visibility options", 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: 2, 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: 0, language: "Go", description: "public", updated_at: "2026-08-01T00:00:00Z", private: false },
{ name: "secret", html_url: "https://github.com/octocat/secret", stargazers_count: 1, forks_count: 0, language: "Go", description: "private", updated_at: "2026-08-01T00:00:00Z", private: true },
],
error: "",
},
});
expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "secret" })).not.toBeInTheDocument();
const visibility = screen.getByLabelText("Visibility");
expect(within(visibility).getByRole("option", { name: "Public" })).toBeInTheDocument();
expect(within(visibility).getByRole("option", { name: "Private" })).toBeInTheDocument();
expect(within(visibility).getByRole("option", { name: "All" })).toBeInTheDocument();
await fireEvent.change(visibility, { target: { value: "all" } });
expect(screen.getByRole("link", { name: "secret" })).toBeInTheDocument();
});
});


44 changes: 43 additions & 1 deletion web/static/css/atlas.css
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,8 @@ a.stat:hover {
grid-column: span 3;
}

.field-stars {
.field-stars,
.field-visibility {
grid-column: span 2;
}

Expand Down Expand Up @@ -1356,6 +1357,45 @@ a.stat:hover {
margin-left: 0.45rem;
}

.catalog-pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.85rem;
margin: 1.35rem 0 0.4rem;
}

.pager-btn {
border: 1px solid var(--line-strong);
background: var(--bg-elev);
color: var(--ink);
border-radius: 999px;
padding: 0.45rem 1.05rem;
font: 600 0.88rem/1 var(--font-ui);
cursor: pointer;
}

.pager-btn:hover:not(:disabled),
.pager-btn:focus-visible {
border-color: var(--copper);
color: var(--copper-hot);
outline: none;
}

.pager-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}

.pager-status {
margin: 0;
font-family: var(--font-mono);
font-size: 0.76rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-mute);
}

@keyframes rise {
from {
opacity: 0;
Expand All @@ -1380,6 +1420,7 @@ a.stat:hover {

.field,
.field-stars,
.field-visibility,
.field-grow,
.field-sort {
grid-column: span 6;
Expand Down Expand Up @@ -1431,6 +1472,7 @@ a.stat:hover {

.field,
.field-stars,
.field-visibility,
.field-grow,
.field-sort {
grid-column: span 12;
Expand Down
Loading