Skip to content

Commit 73f945c

Browse files
ralyodioclaude
andauthored
feat(pit): filter the namespace as you type (#168)
Finding an ending meant paging through them. Now there is a filter box: `eggs` matches anywhere in the name, `def*` is a glob anchored at both ends, and the leading dot people naturally type is ignored. Debounced at 200ms on keyup, and the in-flight request is aborted when the next keystroke lands -- otherwise a slow answer for `de` arrives after the fast one for `def*` and the list flickers back to a query nobody is typing any more. `?q=` is also read server-side, so the filter is bookmarkable, shareable, and still works when the script does not run. The form is a plain GET; the script only upgrades it to answer without a page load. The script is the only one this page carries, and that is the bar it had to clear: /pit locked browsers up a fix ago with no JavaScript on it at all, so anything added here has to make the DOM smaller. A dozen matching rows instead of a page load does. Wildcards that mean something to LIKE and nothing in a TLD (% and _) are stripped when the query is parsed rather than escaped on the way to SQL, so no input can reach the database still carrying a wildcard we did not put there. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3d14a60 commit 73f945c

4 files changed

Lines changed: 478 additions & 18 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Turning what somebody typed into a filter over the namespace.
2+
//
3+
// Pure on purpose: this decides what `.def*` means, and that answer has to be
4+
// the same for the live filter on /pit, the JSON API behind it, and the plain
5+
// `?q=` page load that happens when the script never runs. A helper with no
6+
// database in it is a helper all three can share.
7+
8+
/** A TLD is one label, so nothing longer than one can be a useful query. */
9+
export const MAX_QUERY = 63;
10+
11+
/**
12+
* Read a filter out of raw input.
13+
*
14+
* Returns null for "no filter" — empty, or nothing but wildcards, which asks
15+
* for everything and is what the unfiltered page already shows.
16+
*
17+
* Two behaviours, and the difference is the `*`:
18+
*
19+
* `eggs` substring — matches eggs, bigeggs, eggsalad. This is what typing
20+
* into a filter box means; anchoring it would show nothing until the
21+
* last character landed.
22+
* `def*` glob, anchored at both ends — def, default, defer, but not undef.
23+
*
24+
* The leading dot people naturally type (`.eggs`) is not part of the name, so
25+
* it goes. Everything that cannot appear in a TLD goes with it, which is also
26+
* what makes the result safe to hand to LIKE: `%` and `_` are stripped here, so
27+
* no input can reach SQL still carrying a wildcard we did not put there.
28+
*/
29+
export function tldQuery(raw) {
30+
const cleaned = String(raw ?? "")
31+
.trim()
32+
.toLowerCase()
33+
.replace(/[^a-z0-9*-]/g, "")
34+
.slice(0, MAX_QUERY);
35+
36+
if (!cleaned || /^\*+$/.test(cleaned)) return null;
37+
38+
const glob = cleaned.includes("*");
39+
return {
40+
// What to echo back into the box: what they meant, minus the noise.
41+
query: cleaned,
42+
// A run of stars is one wildcard; `de**f` and `de*f` ask the same thing.
43+
like: glob ? cleaned.replace(/\*+/g, "%") : `%${cleaned}%`,
44+
glob,
45+
// An exact hit sorts first, so `.eggs` finds `.eggs` and not `.eggsalad`.
46+
exact: glob ? "" : cleaned,
47+
};
48+
}

apps/pwa/src/moshpit.mjs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,52 @@ export async function countTldsNotOwnedBy(userId, { forSale = false } = {}) {
372372
return Number(row?.n ?? 0);
373373
}
374374

375+
/**
376+
* Which half of the namespace a search is looking at.
377+
*
378+
* The filter sits inside a tab, so it searches what that tab shows: Yours means
379+
* yours, Theirs means everybody else's. A filter that returned rows the panel
380+
* underneath it cannot display would be worse than no filter.
381+
*/
382+
const searchScope = (scope, userId) =>
383+
scope === "mine" ? { where: " AND user_id = ?", args: [userId ?? ""] }
384+
: scope === "theirs" ? { where: " AND user_id IS NOT ?", args: [userId ?? ""] }
385+
: { where: "", args: [] };
386+
387+
/**
388+
* Endings matching a LIKE pattern from tldQuery().
389+
*
390+
* `exact` sorts a dead-on hit to the top and shorter names above longer ones,
391+
* so typing `eggs` puts `.eggs` above `.eggsalad` instead of burying it in
392+
* alphabetical order.
393+
*
394+
* The name count comes back on the same row rather than one query per result:
395+
* this runs on every keystroke, and N+1 on a keyup handler is how a filter box
396+
* becomes the next thing that makes the page unusable.
397+
*/
398+
export async function searchTlds(like, { scope = "all", userId = null, exact = "", limit = 20, offset = 0 } = {}) {
399+
const s = searchScope(scope, userId);
400+
return all(
401+
`SELECT t.tld, t.user_id, t.owner_email, t.alias_of, t.price_usd, t.created_at,
402+
(SELECT COUNT(*) FROM moshpit_names n WHERE n.tld = t.tld) AS name_count
403+
FROM moshpit_tlds t
404+
WHERE t.tld LIKE ?${s.where.replace(/user_id/g, "t.user_id")}
405+
ORDER BY t.tld = ? DESC, length(t.tld), t.tld
406+
LIMIT ? OFFSET ?`,
407+
[like, ...s.args, exact, limit, offset],
408+
);
409+
}
410+
411+
/** How many endings match — the pager needs a total the window cannot give it. */
412+
export async function countSearchTlds(like, { scope = "all", userId = null } = {}) {
413+
const s = searchScope(scope, userId);
414+
const row = await get(
415+
`SELECT COUNT(*) AS n FROM moshpit_tlds WHERE tld LIKE ?${s.where}`,
416+
[like, ...s.args],
417+
);
418+
return Number(row?.n ?? 0);
419+
}
420+
375421
export async function getTldWithPrice(tld) {
376422
return get(`SELECT tld, user_id, owner_email, alias_of, price_usd, created_at FROM moshpit_tlds WHERE tld = ?`, [tld]);
377423
}

apps/pwa/src/routes/moshpit.mjs

Lines changed: 193 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { requireAuth, csrfInput } from "../lib/session.mjs";
1818
import { balance } from "../lib/credits.mjs";
1919
import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
2020
import { landingFor } from "../lib/moshpit-landing.mjs";
21+
import { tldQuery } from "../lib/moshpit-search.mjs";
2122
import {
2223
MAX_BODY_BYTES, ORIGIN_TIMEOUT_MS, checkTarget, forwardableHeaders,
2324
} from "../lib/moshpit-gateway.mjs";
@@ -27,6 +28,7 @@ import {
2728
clearExempt,
2829
countNames,
2930
countTldsForUser,
31+
countSearchTlds,
3032
countTldsNotOwnedBy,
3133
DEFAULT_TLD_PRICE_USD,
3234
getName,
@@ -56,6 +58,7 @@ import {
5658
removePin,
5759
resolutionPreference,
5860
resolveMoshpitName,
61+
searchTlds,
5962
setAlias,
6063
setExempt,
6164
setNameTarget,
@@ -73,11 +76,44 @@ const unauthorized = (res) => res.status(401).json({ error: "sign in first" });
7376

7477
/* ---------- API ---------- */
7578

79+
/**
80+
* The registry, optionally filtered.
81+
*
82+
* `?q=` is what the filter box on /pit calls on every (debounced) keystroke:
83+
* `eggs` is a substring, `def*` is a glob, and tldQuery() decides which. It
84+
* answers with a name count per ending so the results can say how big each one
85+
* is without a second round trip per row.
86+
*
87+
* Unauthenticated and cheap on purpose -- the registry is public, and a filter
88+
* that only worked signed in would not help anyone deciding whether to sign up.
89+
* `?scope=` narrows to yours or everybody else's; yours needs a session, the
90+
* rest does not.
91+
*/
7692
moshpitRouter.get("/api/moshpit/tlds", async (req, res) => {
77-
if (req.query.mine) {
78-
if (!req.user) return unauthorized(res);
79-
return res.json({ tlds: await listTldsForUser(req.user.id) });
93+
const mine = Boolean(req.query.mine) || req.query.scope === "mine";
94+
if (mine && !req.user) return unauthorized(res);
95+
96+
const filter = tldQuery(req.query.q);
97+
if (filter) {
98+
const scope = mine ? "mine" : req.query.scope === "theirs" ? "theirs" : "all";
99+
const limit = Math.min(50, Math.max(1, Number.parseInt(req.query.limit, 10) || 20));
100+
const tlds = await searchTlds(filter.like, {
101+
scope, userId: req.user?.id ?? null, exact: filter.exact, limit,
102+
});
103+
return res.json({
104+
query: filter.query,
105+
total: await countSearchTlds(filter.like, { scope, userId: req.user?.id ?? null }),
106+
tlds: tlds.map((t) => ({
107+
tld: t.tld,
108+
alias_of: t.alias_of,
109+
price_usd: t.price_usd,
110+
name_count: Number(t.name_count ?? 0),
111+
mine: Boolean(req.user && t.user_id === req.user.id),
112+
})),
113+
});
80114
}
115+
116+
if (mine) return res.json({ tlds: await listTldsForUser(req.user.id) });
81117
res.json({ tlds: await listTlds() });
82118
});
83119

@@ -777,6 +813,11 @@ const PIT_CSS = `
777813
font-size:.8rem;line-height:1.55;resize:vertical;min-height:9em}
778814
.pit-bulk textarea:focus{outline:none;border-color:var(--acid)}
779815
.pit-tabs{display:flex;gap:4px;margin:22px 0 26px;border-bottom:1px solid var(--line)}
816+
.pit-filter{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 16px}
817+
.pit-filter input[name=q]{flex:1;min-width:220px}
818+
.pit-hits{display:flex;flex-direction:column;gap:2px;margin:0 0 18px}
819+
.pit-hit{display:flex;justify-content:space-between;gap:12px;padding:7px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none;font-size:.78rem}
820+
.pit-hit:hover{border-color:var(--acid)}
780821
.pit-pager{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:22px 0 4px;font-size:.72rem}
781822
.pit-pager .btn[aria-disabled]{opacity:.4;pointer-events:none}
782823
.pit-tab{font-family:var(--mono);font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
@@ -808,14 +849,136 @@ const PIT_CSS = `
808849
*
809850
* `counts` is omitted on /pit/dns, which does not load the registry.
810851
*/
811-
const pitTabs = (active, counts = null) => `
852+
const pitTabs = (active, counts = null, query = "") => {
853+
// Switching tabs keeps the filter: having typed `def*` once, being handed the
854+
// unfiltered other half is the surprising outcome, not the helpful one.
855+
const q = query ? `&q=${encodeURIComponent(query)}` : "";
856+
return `
812857
<nav class="pit-tabs">
813-
<a class="pit-tab${active === "yours" ? " on" : ""}" href="/pit?tab=yours">Yours${
858+
<a class="pit-tab${active === "yours" ? " on" : ""}" href="/pit?tab=yours${q}">Yours${
814859
counts ? `<span class="count">${counts.yours}</span>` : ""}</a>
815-
<a class="pit-tab${active === "theirs" ? " on" : ""}" href="/pit?tab=theirs">Theirs${
860+
<a class="pit-tab${active === "theirs" ? " on" : ""}" href="/pit?tab=theirs${q}">Theirs${
816861
counts ? `<span class="count">${counts.theirs}${counts.forSale ? ` · ${counts.forSale} for sale` : ""}</span>` : ""}</a>
817862
<a class="pit-tab${active === "dns" ? " on" : ""}" href="/pit/dns">Use it (DNS)</a>
818863
</nav>`;
864+
};
865+
866+
/**
867+
* The filter box.
868+
*
869+
* A real GET form, so it works with the script blocked, on a browser that never
870+
* ran it, and in a bookmark. The script below upgrades it to filter as you
871+
* type; everything it does, submitting the form also does, just with a page
872+
* load in the middle.
873+
*/
874+
const filterBox = (tab, query, scope) => `
875+
<form class="pit-filter" method="get" action="/pit" role="search" data-pit-filter data-scope="${esc(scope)}">
876+
<input type="hidden" name="tab" value="${esc(tab)}">
877+
<input name="q" value="${esc(query)}" autocomplete="off" spellcheck="false"
878+
placeholder="filter endings — eggs, .def*" aria-label="Filter endings"
879+
data-pit-filter-input>
880+
<button class="btn" type="submit">Filter</button>
881+
${query ? `<a class="btn" href="/pit?tab=${esc(tab)}">Clear</a>` : ""}
882+
<span class="mono faint" style="font-size:.7rem">
883+
<code>eggs</code> anywhere in the name · <code>def*</code> starts with
884+
</span>
885+
</form>
886+
<div class="pit-hits" data-pit-hits hidden></div>`;
887+
888+
/**
889+
* The live half of the filter.
890+
*
891+
* Deliberately small, and the only script this page carries -- /pit locked
892+
* browsers up once already and it managed that with no JavaScript at all, so
893+
* the bar for adding some is that it makes the DOM smaller rather than larger.
894+
* This does: it answers "which endings match" in a dozen rows instead of a page
895+
* load.
896+
*
897+
* Debounced at 200ms, and the in-flight request is aborted when the next
898+
* keystroke lands. Without the abort a slow answer for `de` can arrive after
899+
* the fast one for `def*` and overwrite it, so the list flickers back to a
900+
* query nobody is typing any more.
901+
*
902+
* Plain ES5-ish JS with no template literals: it is embedded in a template
903+
* literal, and a backtick in here would end the string it lives in.
904+
*/
905+
const PIT_FILTER_JS = String.raw`
906+
(function () {
907+
var form = document.querySelector('[data-pit-filter]');
908+
var input = document.querySelector('[data-pit-filter-input]');
909+
var out = document.querySelector('[data-pit-hits]');
910+
if (!form || !input || !out) return;
911+
912+
var scope = form.getAttribute('data-scope') || 'all';
913+
var DEBOUNCE_MS = 200;
914+
var timer = null, inflight = null, rendered = null;
915+
916+
function esc(s) {
917+
return String(s).replace(/[&<>"]/g, function (c) {
918+
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
919+
});
920+
}
921+
922+
function hide() { out.hidden = true; out.innerHTML = ''; rendered = null; }
923+
924+
function row(t) {
925+
// Yours opens on its own; anybody else's filters Theirs down to it, which
926+
// is the panel that carries the buy form.
927+
var href = t.mine
928+
? '/pit?tld=' + encodeURIComponent(t.tld)
929+
: '/pit?tab=theirs&q=' + encodeURIComponent(t.tld);
930+
var note = t.mine
931+
? t.name_count + (t.name_count === 1 ? ' name' : ' names')
932+
: (t.price_usd === null || t.price_usd === undefined ? 'not for sale' : '$' + t.price_usd + ' a name');
933+
var alias = t.alias_of ? '<span class="faint"> to .' + esc(t.alias_of) + '</span>' : '';
934+
return '<a class="pit-hit" href="' + href + '">' +
935+
'<span class="mono acid">.' + esc(t.tld) + alias + '</span>' +
936+
'<span class="mono faint">' + esc(note) + '</span></a>';
937+
}
938+
939+
function render(data) {
940+
var tlds = data.tlds || [];
941+
if (!tlds.length) {
942+
out.innerHTML = '<p class="mono faint" style="margin:0;font-size:.72rem">nothing here matches ' +
943+
esc(data.query) + '</p>';
944+
out.hidden = false;
945+
return;
946+
}
947+
var more = data.total > tlds.length
948+
? '<p class="mono faint" style="margin:8px 0 0;font-size:.72rem">' + tlds.length + ' of ' +
949+
data.total + ' shown - press Enter for all of them</p>'
950+
: '';
951+
out.innerHTML = tlds.map(row).join('') + more;
952+
out.hidden = false;
953+
}
954+
955+
function run() {
956+
var q = input.value.trim();
957+
if (!q) { hide(); return; }
958+
if (q === rendered) return;
959+
if (inflight) inflight.abort();
960+
var ctl = new AbortController();
961+
inflight = ctl;
962+
fetch('/api/moshpit/tlds?limit=12&scope=' + encodeURIComponent(scope) + '&q=' + encodeURIComponent(q),
963+
{ signal: ctl.signal, headers: { accept: 'application/json' } })
964+
.then(function (r) { return r.ok ? r.json() : null; })
965+
.then(function (data) {
966+
if (ctl.signal.aborted || !data) return;
967+
rendered = q;
968+
render(data);
969+
})
970+
.catch(function () { /* aborted, or offline: leave the last answer up */ });
971+
}
972+
973+
function schedule() { clearTimeout(timer); timer = setTimeout(run, DEBOUNCE_MS); }
974+
975+
input.addEventListener('keyup', schedule);
976+
input.addEventListener('input', schedule); // paste and IME never fire keyup
977+
input.addEventListener('keydown', function (e) {
978+
if (e.key === 'Escape') { input.value = ''; hide(); }
979+
});
980+
})();
981+
`;
819982

820983
const forSale = (t) => t.price_usd !== null && t.price_usd !== undefined;
821984

@@ -868,18 +1031,28 @@ moshpitRouter.get("/pit", async (req, res) => {
8681031
? await getTld(wanted).then((t) => (t && t.user_id === req.user.id ? t : null))
8691032
: null;
8701033

1034+
// `?q=` filters the panel. The live filter is a script talking to the JSON
1035+
// API, but the query still belongs in the URL: without it the filter would be
1036+
// unbookmarkable, unshareable, and gone the moment the script failed to load.
1037+
const filter = tldQuery(req.query.q);
1038+
const offset = (pageNo - 1) * TLDS_PER_PAGE;
1039+
const window = { limit: TLDS_PER_PAGE, offset };
1040+
const search = (scope) => searchTlds(filter.like, { scope, userId: req.user?.id ?? null, exact: filter.exact, ...window });
1041+
const searchTotal = (scope) => countSearchTlds(filter.like, { scope, userId: req.user?.id ?? null });
1042+
8711043
const [theirs, theirsTotal, mine, mineTotal, bal] = await Promise.all([
872-
tab === "theirs"
873-
? listTldsNotOwnedBy(req.user?.id ?? null, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE })
874-
: [],
875-
countTldsNotOwnedBy(req.user?.id ?? null),
876-
req.user && !focused
877-
? listTldsForUser(req.user.id, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE })
878-
: [],
879-
req.user ? countTldsForUser(req.user.id) : 0,
1044+
tab !== "theirs" ? []
1045+
: filter ? search("theirs")
1046+
: listTldsNotOwnedBy(req.user?.id ?? null, window),
1047+
filter ? searchTotal("theirs") : countTldsNotOwnedBy(req.user?.id ?? null),
1048+
!req.user || focused ? []
1049+
: filter ? search("mine")
1050+
: listTldsForUser(req.user.id, window),
1051+
req.user ? (filter ? searchTotal("mine") : countTldsForUser(req.user.id)) : 0,
8801052
req.user ? balance(req.user.id) : 0,
8811053
]);
8821054
const shown = focused ? [focused] : mine;
1055+
const qs = filter ? `&q=${encodeURIComponent(filter.query)}` : "";
8831056

8841057
// `?name=mosh.whatever` — somebody typed a Moshpit name and ended up here
8851058
// instead of at a site. Work out what they can actually do about it.
@@ -1026,7 +1199,8 @@ moshpitRouter.get("/pit", async (req, res) => {
10261199
${landingCard(req, landing)}
10271200
${msg}
10281201
${req.user ? claimForm(req) + bulkClaimForm(req) : ""}
1029-
${pitTabs(tab, { yours: mineTotal, theirs: theirsTotal, forSale: forSaleCount })}
1202+
${pitTabs(tab, { yours: mineTotal, theirs: theirsTotal, forSale: filter ? 0 : forSaleCount }, filter?.query ?? "")}
1203+
${filterBox(tab, filter?.query ?? "", req.user ? (tab === "theirs" ? "theirs" : "mine") : "all")}
10301204
10311205
<section class="pit-panel">
10321206
${tab === "yours" ? `
@@ -1042,7 +1216,7 @@ moshpitRouter.get("/pit", async (req, res) => {
10421216
${mineHtml}
10431217
${focused ? "" : pager({
10441218
page: pageNo, total: mineTotal, perPage: TLDS_PER_PAGE,
1045-
href: (n) => `/pit?tab=yours&page=${n}`,
1219+
href: (n) => `/pit?tab=yours&page=${n}${qs}`,
10461220
})}
10471221
` : `
10481222
<p class="dim" style="max-width:62ch;margin:0 0 14px">
@@ -1053,11 +1227,12 @@ moshpitRouter.get("/pit", async (req, res) => {
10531227
${theirsHtml}
10541228
${pager({
10551229
page: pageNo, total: theirsTotal, perPage: TLDS_PER_PAGE,
1056-
href: (n) => `/pit?tab=theirs&page=${n}`,
1230+
href: (n) => `/pit?tab=theirs&page=${n}${qs}`,
10571231
})}
10581232
`}
10591233
</section>
1060-
</main>${footer}`,
1234+
</main>${footer}
1235+
<script>${PIT_FILTER_JS}</script>`,
10611236
}));
10621237
});
10631238

0 commit comments

Comments
 (0)