Skip to content

Filterable /apps browse page and header navigation - #58

Merged
hipstersmoothie merged 4 commits into
mainfrom
sesh/improve-site-navigation
Oct 17, 2026
Merged

hipstersmoothie merged 4 commits into
mainfrom
sesh/improve-site-navigation

Conversation

@hipstersmoothie

@hipstersmoothie hipstersmoothie commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Feedback from a user: "instead of having different app categories making it hard to find things outside of a few… like the list with filters on the side!" (à la modrinth.com).

Discovery was split across /apps/all, /apps/tags, /apps/$tag, /categories/…, /ecosystems/…, /apps/lexicons and /search, and the header had no navigation links, only a search icon. /apps/all also hid every ecosystem tool (Bluesky clients, feeds, …).

What

/apps: one list with a filter sidebar

  • Filters: Tags (searchable, with show-all), Rating, Works with data from (shared lexicons, grouped by the site that defines them), Extras (open source, accepts donations, official account) and Category (standalone apps plus each app ecosystem and its tool categories). Tags and Rating start expanded; the other groups start collapsed and open automatically when they have a selection.
  • Every option shows a live count. Values within a group are OR'd; groups are AND'd.
  • Search (q), sort (trending, top rated, most reviewed, most favorited, newest, recently updated, A–Z), page and every filter are in the URL, with readable comma-joined params (/apps?tag=social,games&in=apps/bluesky).
  • Rows show icon, handle, tagline, category and tag badges (clicking a badge adds it as a filter), rating, favorites and last updated. Results are paginated 24 per page.
  • On mobile, the filters open in a drawer with a "Show N apps" button.
  • Protocol and developer listings stay out of this page, the same rule /search used.

Header

  • Browse · Tags · Shared data links, plus an inline search box that opens /apps?q=.
  • The mobile menu button is turned back on.
  • "Developer API" stays in the footer.

Redirects (301) to /apps presets

Old New
/apps/all, /search /apps (keeps sort and q)
/apps/<tag> /apps?tag=<tag> (keeps the tag OG image and title)
/categories/<id> /apps?in=<id>
/ecosystems/<app>/all /apps?in=apps/<app>

Internal links (home "See all", footer, tag cards, ecosystem pages, product-page badges) now point straight at /apps. /apps/tags, /apps/lexicons and /ecosystems/<app> remain as index pages.

Data

  • New getBrowsePage server fn: takes the URL search params and returns one page of 24 trimmed cards plus facet counts. Filtering, facet counts, sorting and pagination run on the server through pure, unit-tested logic in src/lib/browse-listings.ts.
  • The catalog (every verified non-protocol listing, plus favorites, dates, popular rank, repo lexicon NSIDs and link types) is cached in memory for 60s, so filter clicks don't each hit the database. Newly approved listings can take up to a minute to appear.
  • The whole /apps HTML is about 30 KB gzipped on the dev server; data embedded in the page is about 23 KB. Text search covers the full description.
  • Removed getAllListings, getAllApps, getAppsByTagPage, getDirectoryBrowsePath (it returned a dead /categories/all link), and other helpers only the old pages used.

Public API: fyi.atstore.directory.searchListings

  • New optional params that mirror the page's filters: tag, category, data (all repeatable), minRating (3, 4 or 4.5) and has (open-source, donations, official).
  • New sorts: rating, reviews, favorites and updated.
  • The endpoint now runs on the same cached filtering logic as /apps and loads full card rows only for the requested page.
  • Existing calls keep working. Protocol listings stay in API results (use category=protocol), and only listings with a published listing.detail record are returned, as before.
  • Text search now uses the /apps matching rules: it also covers the developer handle and category names, and search terms can match category labels rather than only the raw category paths.
  • Tag params, on both the page and the API, accept tag text (developer tool) or slugs (developer), and the web URL is rewritten to the slug.
  • The lexicon JSON and generated bundle are updated, and the developer docs mention the new params. The lexicon still needs publishing with pnpm atproto:publish-lexicons; this PR doesn't do it.

Notes / follow-ups

  • Facets are computed in memory on the server, which takes about a millisecond at today's catalog size. If the catalog grows into the thousands, consider moving them to SQL.
  • Old protocol category links (/categories/protocol%2F…) now land on an empty /apps view.

Testing

  • tsc --noEmit passes. vitest: 126 tests pass, 18 of them new for the browse logic. pnpm lex:lint passes.
  • oxlint is clean on changed files. The remaining errors are in files this PR doesn't touch.
  • Checked by hand in the dev server against real data: desktop and mobile layouts, filter toggling and URL sync, the mobile drawer, every redirect, and the mobile header menu. Also exercised searchListings with every new param, repeated and comma-joined values, cursor paging and an invalid cursor.

🤖 Generated with Claude Code

hipstersmoothie and others added 4 commits October 17, 2026 12:35
Discovery was split across tags, categories, ecosystems, lexicons and
search pages, none reachable from the header. Replace them with one
/apps list with a filter sidebar (tags, shared data, rating, extras,
category) whose state lives in the URL.

- Header gets Browse / Tags / Shared data links and an inline search
  box; mobile gets the hamburger menu back.
- /apps/all, /search, /apps/$tag, /categories/$categoryId and
  /ecosystems/$app/all 301 to the equivalent /apps preset; internal
  links point there directly.
- New getBrowseListings server fn returns every non-protocol listing
  with the fields the facets need; filtering/sorting is client-side.
- Remove the server fns and helpers only the old pages used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page used to ship every listing (~170 KB gzipped) and filter in the
browser. Now getBrowsePage runs the same pure logic server-side and
returns one page of trimmed cards plus facet counts (~30 KB gzipped for
the whole HTML). The catalog is cached in memory for 60s so filter
clicks don't each hit the database, and text search covers the full
description again since it no longer has to be shipped to the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
searchListings now accepts the same filters as the browse page (tag,
category, data, minRating, has) and sorts (rating, reviews, favorites,
updated), and runs on the shared, cached browse logic instead of a
separate ilike query. Only the requested page's rows are loaded for the
full card shape. All new params are optional, so existing callers keep
working.

Tag params (web and API) now accept raw tag text or slugs and are
canonicalized to slugs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's format and lint jobs were already failing on main. Run oxfmt and
oxlint --fix, then fix the rest by hand:

- Replace non-null assertions in generate-listing-copy with nullable
  "good copy" values (same behavior: not-weak implies non-empty).
- Use Boolean-free null filters, named Anthropic import, replaceAll
  with a string.
- site-brand-icon: oxfmt lowercases hex literals while
  unicorn/number-literal-case wants uppercase; disable the rule for
  that file, matching the existing og.* override.
- lex:gen now formats the generated bundle so regenerating it doesn't
  break format:check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hipstersmoothie
hipstersmoothie merged commit 049107e into main Oct 17, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant