diff --git a/apps/pwa/src/lib/moshpit-search.mjs b/apps/pwa/src/lib/moshpit-search.mjs new file mode 100644 index 0000000..3c98c01 --- /dev/null +++ b/apps/pwa/src/lib/moshpit-search.mjs @@ -0,0 +1,48 @@ +// Turning what somebody typed into a filter over the namespace. +// +// Pure on purpose: this decides what `.def*` means, and that answer has to be +// the same for the live filter on /pit, the JSON API behind it, and the plain +// `?q=` page load that happens when the script never runs. A helper with no +// database in it is a helper all three can share. + +/** A TLD is one label, so nothing longer than one can be a useful query. */ +export const MAX_QUERY = 63; + +/** + * Read a filter out of raw input. + * + * Returns null for "no filter" — empty, or nothing but wildcards, which asks + * for everything and is what the unfiltered page already shows. + * + * Two behaviours, and the difference is the `*`: + * + * `eggs` substring — matches eggs, bigeggs, eggsalad. This is what typing + * into a filter box means; anchoring it would show nothing until the + * last character landed. + * `def*` glob, anchored at both ends — def, default, defer, but not undef. + * + * The leading dot people naturally type (`.eggs`) is not part of the name, so + * it goes. Everything that cannot appear in a TLD goes with it, which is also + * what makes the result safe to hand to LIKE: `%` and `_` are stripped here, so + * no input can reach SQL still carrying a wildcard we did not put there. + */ +export function tldQuery(raw) { + const cleaned = String(raw ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9*-]/g, "") + .slice(0, MAX_QUERY); + + if (!cleaned || /^\*+$/.test(cleaned)) return null; + + const glob = cleaned.includes("*"); + return { + // What to echo back into the box: what they meant, minus the noise. + query: cleaned, + // A run of stars is one wildcard; `de**f` and `de*f` ask the same thing. + like: glob ? cleaned.replace(/\*+/g, "%") : `%${cleaned}%`, + glob, + // An exact hit sorts first, so `.eggs` finds `.eggs` and not `.eggsalad`. + exact: glob ? "" : cleaned, + }; +} diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index d562c46..af5e3d8 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -372,6 +372,52 @@ export async function countTldsNotOwnedBy(userId, { forSale = false } = {}) { return Number(row?.n ?? 0); } +/** + * Which half of the namespace a search is looking at. + * + * The filter sits inside a tab, so it searches what that tab shows: Yours means + * yours, Theirs means everybody else's. A filter that returned rows the panel + * underneath it cannot display would be worse than no filter. + */ +const searchScope = (scope, userId) => + scope === "mine" ? { where: " AND user_id = ?", args: [userId ?? ""] } + : scope === "theirs" ? { where: " AND user_id IS NOT ?", args: [userId ?? ""] } + : { where: "", args: [] }; + +/** + * Endings matching a LIKE pattern from tldQuery(). + * + * `exact` sorts a dead-on hit to the top and shorter names above longer ones, + * so typing `eggs` puts `.eggs` above `.eggsalad` instead of burying it in + * alphabetical order. + * + * The name count comes back on the same row rather than one query per result: + * this runs on every keystroke, and N+1 on a keyup handler is how a filter box + * becomes the next thing that makes the page unusable. + */ +export async function searchTlds(like, { scope = "all", userId = null, exact = "", limit = 20, offset = 0 } = {}) { + const s = searchScope(scope, userId); + return all( + `SELECT t.tld, t.user_id, t.owner_email, t.alias_of, t.price_usd, t.created_at, + (SELECT COUNT(*) FROM moshpit_names n WHERE n.tld = t.tld) AS name_count + FROM moshpit_tlds t + WHERE t.tld LIKE ?${s.where.replace(/user_id/g, "t.user_id")} + ORDER BY t.tld = ? DESC, length(t.tld), t.tld + LIMIT ? OFFSET ?`, + [like, ...s.args, exact, limit, offset], + ); +} + +/** How many endings match — the pager needs a total the window cannot give it. */ +export async function countSearchTlds(like, { scope = "all", userId = null } = {}) { + const s = searchScope(scope, userId); + const row = await get( + `SELECT COUNT(*) AS n FROM moshpit_tlds WHERE tld LIKE ?${s.where}`, + [like, ...s.args], + ); + return Number(row?.n ?? 0); +} + export async function getTldWithPrice(tld) { return get(`SELECT tld, user_id, owner_email, alias_of, price_usd, created_at FROM moshpit_tlds WHERE tld = ?`, [tld]); } diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 2af7c3e..0bc76b6 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -18,6 +18,7 @@ import { requireAuth, csrfInput } from "../lib/session.mjs"; import { balance } from "../lib/credits.mjs"; import { resolverConfig } from "../lib/moshpit-resolvers.mjs"; import { landingFor } from "../lib/moshpit-landing.mjs"; +import { tldQuery } from "../lib/moshpit-search.mjs"; import { MAX_BODY_BYTES, ORIGIN_TIMEOUT_MS, checkTarget, forwardableHeaders, } from "../lib/moshpit-gateway.mjs"; @@ -27,6 +28,7 @@ import { clearExempt, countNames, countTldsForUser, + countSearchTlds, countTldsNotOwnedBy, DEFAULT_TLD_PRICE_USD, getName, @@ -56,6 +58,7 @@ import { removePin, resolutionPreference, resolveMoshpitName, + searchTlds, setAlias, setExempt, setNameTarget, @@ -73,11 +76,44 @@ const unauthorized = (res) => res.status(401).json({ error: "sign in first" }); /* ---------- API ---------- */ +/** + * The registry, optionally filtered. + * + * `?q=` is what the filter box on /pit calls on every (debounced) keystroke: + * `eggs` is a substring, `def*` is a glob, and tldQuery() decides which. It + * answers with a name count per ending so the results can say how big each one + * is without a second round trip per row. + * + * Unauthenticated and cheap on purpose -- the registry is public, and a filter + * that only worked signed in would not help anyone deciding whether to sign up. + * `?scope=` narrows to yours or everybody else's; yours needs a session, the + * rest does not. + */ moshpitRouter.get("/api/moshpit/tlds", async (req, res) => { - if (req.query.mine) { - if (!req.user) return unauthorized(res); - return res.json({ tlds: await listTldsForUser(req.user.id) }); + const mine = Boolean(req.query.mine) || req.query.scope === "mine"; + if (mine && !req.user) return unauthorized(res); + + const filter = tldQuery(req.query.q); + if (filter) { + const scope = mine ? "mine" : req.query.scope === "theirs" ? "theirs" : "all"; + const limit = Math.min(50, Math.max(1, Number.parseInt(req.query.limit, 10) || 20)); + const tlds = await searchTlds(filter.like, { + scope, userId: req.user?.id ?? null, exact: filter.exact, limit, + }); + return res.json({ + query: filter.query, + total: await countSearchTlds(filter.like, { scope, userId: req.user?.id ?? null }), + tlds: tlds.map((t) => ({ + tld: t.tld, + alias_of: t.alias_of, + price_usd: t.price_usd, + name_count: Number(t.name_count ?? 0), + mine: Boolean(req.user && t.user_id === req.user.id), + })), + }); } + + if (mine) return res.json({ tlds: await listTldsForUser(req.user.id) }); res.json({ tlds: await listTlds() }); }); @@ -777,6 +813,11 @@ const PIT_CSS = ` font-size:.8rem;line-height:1.55;resize:vertical;min-height:9em} .pit-bulk textarea:focus{outline:none;border-color:var(--acid)} .pit-tabs{display:flex;gap:4px;margin:22px 0 26px;border-bottom:1px solid var(--line)} +.pit-filter{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 16px} +.pit-filter input[name=q]{flex:1;min-width:220px} +.pit-hits{display:flex;flex-direction:column;gap:2px;margin:0 0 18px} +.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} +.pit-hit:hover{border-color:var(--acid)} .pit-pager{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:22px 0 4px;font-size:.72rem} .pit-pager .btn[aria-disabled]{opacity:.4;pointer-events:none} .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 = ` * * `counts` is omitted on /pit/dns, which does not load the registry. */ -const pitTabs = (active, counts = null) => ` +const pitTabs = (active, counts = null, query = "") => { + // Switching tabs keeps the filter: having typed `def*` once, being handed the + // unfiltered other half is the surprising outcome, not the helpful one. + const q = query ? `&q=${encodeURIComponent(query)}` : ""; + return ` `; +}; + +/** + * The filter box. + * + * A real GET form, so it works with the script blocked, on a browser that never + * ran it, and in a bookmark. The script below upgrades it to filter as you + * type; everything it does, submitting the form also does, just with a page + * load in the middle. + */ +const filterBox = (tab, query, scope) => ` +
+`; + +/** + * The live half of the filter. + * + * Deliberately small, and the only script this page carries -- /pit locked + * browsers up once already and it managed that with no JavaScript at all, so + * the bar for adding some is that it makes the DOM smaller rather than larger. + * This does: it answers "which endings match" in a dozen rows instead of a page + * load. + * + * Debounced at 200ms, and the in-flight request is aborted when the next + * keystroke lands. Without the abort a slow answer for `de` can arrive after + * the fast one for `def*` and overwrite it, so the list flickers back to a + * query nobody is typing any more. + * + * Plain ES5-ish JS with no template literals: it is embedded in a template + * literal, and a backtick in here would end the string it lives in. + */ +const PIT_FILTER_JS = String.raw` +(function () { + var form = document.querySelector('[data-pit-filter]'); + var input = document.querySelector('[data-pit-filter-input]'); + var out = document.querySelector('[data-pit-hits]'); + if (!form || !input || !out) return; + + var scope = form.getAttribute('data-scope') || 'all'; + var DEBOUNCE_MS = 200; + var timer = null, inflight = null, rendered = null; + + function esc(s) { + return String(s).replace(/[&<>"]/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + + function hide() { out.hidden = true; out.innerHTML = ''; rendered = null; } + + function row(t) { + // Yours opens on its own; anybody else's filters Theirs down to it, which + // is the panel that carries the buy form. + var href = t.mine + ? '/pit?tld=' + encodeURIComponent(t.tld) + : '/pit?tab=theirs&q=' + encodeURIComponent(t.tld); + var note = t.mine + ? t.name_count + (t.name_count === 1 ? ' name' : ' names') + : (t.price_usd === null || t.price_usd === undefined ? 'not for sale' : '$' + t.price_usd + ' a name'); + var alias = t.alias_of ? ' to .' + esc(t.alias_of) + '' : ''; + return '' + + '.' + esc(t.tld) + alias + '' + + '' + esc(note) + ''; + } + + function render(data) { + var tlds = data.tlds || []; + if (!tlds.length) { + out.innerHTML = 'nothing here matches ' + + esc(data.query) + '
'; + out.hidden = false; + return; + } + var more = data.total > tlds.length + ? '' + tlds.length + ' of ' + + data.total + ' shown - press Enter for all of them
' + : ''; + out.innerHTML = tlds.map(row).join('') + more; + out.hidden = false; + } + + function run() { + var q = input.value.trim(); + if (!q) { hide(); return; } + if (q === rendered) return; + if (inflight) inflight.abort(); + var ctl = new AbortController(); + inflight = ctl; + fetch('/api/moshpit/tlds?limit=12&scope=' + encodeURIComponent(scope) + '&q=' + encodeURIComponent(q), + { signal: ctl.signal, headers: { accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (data) { + if (ctl.signal.aborted || !data) return; + rendered = q; + render(data); + }) + .catch(function () { /* aborted, or offline: leave the last answer up */ }); + } + + function schedule() { clearTimeout(timer); timer = setTimeout(run, DEBOUNCE_MS); } + + input.addEventListener('keyup', schedule); + input.addEventListener('input', schedule); // paste and IME never fire keyup + input.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { input.value = ''; hide(); } + }); +})(); +`; const forSale = (t) => t.price_usd !== null && t.price_usd !== undefined; @@ -868,18 +1031,28 @@ moshpitRouter.get("/pit", async (req, res) => { ? await getTld(wanted).then((t) => (t && t.user_id === req.user.id ? t : null)) : null; + // `?q=` filters the panel. The live filter is a script talking to the JSON + // API, but the query still belongs in the URL: without it the filter would be + // unbookmarkable, unshareable, and gone the moment the script failed to load. + const filter = tldQuery(req.query.q); + const offset = (pageNo - 1) * TLDS_PER_PAGE; + const window = { limit: TLDS_PER_PAGE, offset }; + const search = (scope) => searchTlds(filter.like, { scope, userId: req.user?.id ?? null, exact: filter.exact, ...window }); + const searchTotal = (scope) => countSearchTlds(filter.like, { scope, userId: req.user?.id ?? null }); + const [theirs, theirsTotal, mine, mineTotal, bal] = await Promise.all([ - tab === "theirs" - ? listTldsNotOwnedBy(req.user?.id ?? null, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE }) - : [], - countTldsNotOwnedBy(req.user?.id ?? null), - req.user && !focused - ? listTldsForUser(req.user.id, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE }) - : [], - req.user ? countTldsForUser(req.user.id) : 0, + tab !== "theirs" ? [] + : filter ? search("theirs") + : listTldsNotOwnedBy(req.user?.id ?? null, window), + filter ? searchTotal("theirs") : countTldsNotOwnedBy(req.user?.id ?? null), + !req.user || focused ? [] + : filter ? search("mine") + : listTldsForUser(req.user.id, window), + req.user ? (filter ? searchTotal("mine") : countTldsForUser(req.user.id)) : 0, req.user ? balance(req.user.id) : 0, ]); const shown = focused ? [focused] : mine; + const qs = filter ? `&q=${encodeURIComponent(filter.query)}` : ""; // `?name=mosh.whatever` — somebody typed a Moshpit name and ended up here // instead of at a site. Work out what they can actually do about it. @@ -1026,7 +1199,8 @@ moshpitRouter.get("/pit", async (req, res) => { ${landingCard(req, landing)} ${msg} ${req.user ? claimForm(req) + bulkClaimForm(req) : ""} - ${pitTabs(tab, { yours: mineTotal, theirs: theirsTotal, forSale: forSaleCount })} + ${pitTabs(tab, { yours: mineTotal, theirs: theirsTotal, forSale: filter ? 0 : forSaleCount }, filter?.query ?? "")} + ${filterBox(tab, filter?.query ?? "", req.user ? (tab === "theirs" ? "theirs" : "mine") : "all")}@@ -1053,11 +1227,12 @@ moshpitRouter.get("/pit", async (req, res) => { ${theirsHtml} ${pager({ page: pageNo, total: theirsTotal, perPage: TLDS_PER_PAGE, - href: (n) => `/pit?tab=theirs&page=${n}`, + href: (n) => `/pit?tab=theirs&page=${n}${qs}`, })} `}