@@ -18,6 +18,7 @@ import { requireAuth, csrfInput } from "../lib/session.mjs";
1818import { balance } from "../lib/credits.mjs" ;
1919import { resolverConfig } from "../lib/moshpit-resolvers.mjs" ;
2020import { landingFor } from "../lib/moshpit-landing.mjs" ;
21+ import { tldQuery } from "../lib/moshpit-search.mjs" ;
2122import {
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+ */
7692moshpitRouter . 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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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
820983const 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