[www] better room/player search

This commit is contained in:
Devin Zuczek
2026-10-26 19:31:20 -04:00
parent fc6cf31591
commit 44704fec51
10 changed files with 413 additions and 108 deletions
+14 -2
View File
@@ -9,6 +9,7 @@ import {
getAccount,
getAccountByUsername,
getAccountsByIds,
SEARCH_LIMIT,
searchAccounts,
updateAccount,
} from '@repo/domain'
@@ -337,17 +338,28 @@ const app = new Hono<App>()
description: 'Username prefix; empty matches nothing meaningful',
schema: { type: 'string' },
},
{
name: 'take',
in: 'query',
required: false,
description: `How many to return (default and max ${SEARCH_LIMIT}); fewer reads fewer rows`,
schema: { type: 'integer' },
},
],
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
}),
async (c) => {
const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name)
// The seek stops at the LIMIT, so a caller that draws five (the site's header
// search) is billed five rows rather than the default twenty.
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const limit = Number.isNaN(take) ? SEARCH_LIMIT : Math.max(1, Math.min(take, SEARCH_LIMIT))
const accounts = await searchAccounts(c.env.DB, name, limit)
return c.json(accounts.map(toAccountDto))
}
)
// Exact lookup by username — what the site's `/u/<name>` profile pages and the staff
// Exact lookup by username — what the site's `/user/<name>` profile pages and the staff
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
// before this existed they went through the prefix search above and filtered the
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
@@ -0,0 +1,11 @@
-- The searchable rooms by lowercased name, for the website's header search
-- (`GET /rooms/search/name`, searchRoomsByName).
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- PARTIAL over exactly SEARCHABLE_WHERE, so a prefix seek visits only rooms it can serve
-- and the LIMIT stops it: D1 bills the rows returned, not the room count. The existing
-- `idx_rooms_name_lower` holds every dorm and private room, and each one the seek passed
-- would be read to test the filters. Built from the VIRTUAL columns, so no backfill.
CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1;
+36
View File
@@ -70,6 +70,7 @@ import {
roomRoles,
saveSubRoomData,
searchRooms,
searchRoomsByName,
setPlayerData,
setRoomDescription,
setRoomImage,
@@ -1208,6 +1209,9 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
const DEFAULT_SUGGESTION_COUNT = 10
/** Cap on `/rooms/search/name`'s `take`: a type-ahead list, so the seek stays a few rows. */
const NAME_SEARCH_MAX = 10
const app = new Hono<App>()
.use(
'*',
@@ -1435,6 +1439,38 @@ const app = new Hono<App>()
}
)
// The website's header search: searchable rooms whose name STARTS with `query`, A–Z,
// as a bare array. Not the game's search (`/rooms/search` is a substring match that
// reads every searchable room); this is an index seek that reads only the rows it
// returns, which is what lets the site ask on every pause in typing. Un-hydrated rooms.
.get(
'/rooms/search/name',
describeRoute({
tags: ['Discovery'],
summary: 'Prefix-search rooms by name',
description: [
'Searchable rooms (public, non-dorm, not `ExcludeFromSearch`) whose name begins',
'with `query`, case-insensitive, alphabetically — a bare array. An index seek, so',
'it reads only the rooms it returns. Rooms are served without subrooms, tags or',
'badges. The game’s substring search is `/rooms/search`.',
].join(' '),
parameters: [
stringQuery('query', 'Name prefix'),
intQuery('take', `How many rooms to return (default and max ${NAME_SEARCH_MAX})`),
],
responses: { 200: json(RoomDto.array(), 'The matching rooms, A–Z') },
}),
async (c) => {
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const rooms = await searchRoomsByName(
c.env.DB,
c.req.query('query') ?? '',
Number.isNaN(take) ? NAME_SEARCH_MAX : Math.min(take, NAME_SEARCH_MAX)
)
return c.json(serveRooms(rooms))
}
)
// Type-ahead for the search box (`?query=r&take=4&searchSessionId=…`). A bare array of
// plain STRINGS — suggestions, not rooms and not an envelope.
//
@@ -681,6 +681,46 @@ describe('rooms endpoints', () => {
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
})
it('GET /rooms/search/name prefix-matches searchable rooms A–Z through the partial index', async () => {
const search = async (query: string, extra = '') =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/search/name?query=${encodeURIComponent(query)}${extra}`)
).json()) as Array<{ Name: string; IsDorm?: boolean; Stats: { VisitCount: number } }>
// A bare array of whole rooms, case-insensitive, every name starting with the query, A–Z.
const rec = await search('REC', '&take=10')
expect(rec.some((r) => r.Name === 'RecCenter')).toBe(true)
for (const r of rec) expect(r.Name.toLowerCase().startsWith('rec')).toBe(true)
const names = rec.map((r) => r.Name.toLowerCase())
expect(names).toEqual([...names].sort())
expect(typeof rec[0]?.Stats.VisitCount).toBe('number')
// A prefix, not a substring: "center" is inside RecCenter but doesn't start it.
expect((await search('center')).some((r) => r.Name === 'RecCenter')).toBe(false)
// `take` caps it, and is itself capped at 10; empty and wildcard queries find nothing.
expect((await search('r', '&take=2')).length).toBeLessThanOrEqual(2)
expect(await search('', '&take=500')).toEqual([])
expect(await search('%')).toEqual([])
expect((await search('r', '&take=500')).length).toBeLessThanOrEqual(10)
// Dorms (named `@user's Dorm`) are never served.
expect((await search('@')).every((r) => r.IsDorm !== true)).toBe(true)
// The point of the endpoint: a SEEK on the partial index, so D1 reads only the rows it
// returns. Fall back to a scan (or the unfiltered name index) and every call is billed
// for every room again.
const plan = await env.DB.prepare(
`EXPLAIN QUERY PLAN SELECT data FROM room
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
ORDER BY name_lower LIMIT 5`
)
.bind('r', 'r\u{10FFFF}', 'r%')
.all<{ detail: string }>()
const detail = plan.results.map((r) => r.detail).join('\n')
expect(detail).toContain('SEARCH room USING INDEX idx_room_search_name')
expect(detail).not.toContain('TEMP B-TREE')
})
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
const suggest = async (query: string, extra = '') =>
(await (
@@ -6251,6 +6291,7 @@ describe('rooms endpoints', () => {
'GET /rooms/ownedby/{accountId}',
'GET /rooms/recommendations',
'GET /rooms/search',
'GET /rooms/search/name',
'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
+173 -65
View File
@@ -1129,11 +1129,11 @@ interface PublicRoom {
Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number }
}
/** Prefix-search accounts by username — backs the header search bar. */
async function searchPlayers(query: string): Promise<PublicAccount[]> {
/** Prefix-search accounts by username — backs the header search. */
async function searchPlayers(query: string, take: number): Promise<PublicAccount[]> {
if (query.trim() === '') return []
return call<PublicAccount[]>(
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}`
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}&take=${take}`
)
}
@@ -1145,7 +1145,7 @@ const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
* The photos a player has taken. Sent WITH the session token when there is one: `api`
* reads it and, only when the list is the caller's own, includes the photos they keep
* private — so a signed-in player sees all of theirs, on `/account` and on their own
* `/u/<name>`, while everyone else's profile is the public list either way. Nothing
* `/user/<name>`, while everyone else's profile is the public list either way. Nothing
* here decides who the viewer is; the worker does, from the token.
*/
const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> =>
@@ -1218,91 +1218,199 @@ async function setRoomExcluded(roomId: number, excluded: boolean): Promise<Owned
return result.value
}
/** The `/u/<username>` path, or null for any other path. */
/** The `/user/<username>` path (or the old `/u/<username>`), or null for any other path. */
function usernameFromPath(path: string): string | null {
const match = /^\/u\/([^/]+)$/.exec(path)
const match = /^\/(?:user|u)\/([^/]+)$/.exec(path)
return match ? decodeURIComponent(match[1]) : null
}
// The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only
// decides whether the search bar that leads to them is shown.
const SHOW_PLAYER_SEARCH: boolean = true
// The nav's search. `/user/:name` and `/rooms/:id` stay reachable by URL either way; this
// only decides whether the search button that leads to them is shown.
const SHOW_SEARCH: boolean = true
/** How many of each kind the search panel lists. */
const SEARCH_TAKE = 5
/**
* The header search bar — a rec.net-style bubble dropdown of matching players as you
* type. Debounced so it doesn't fire a search per keystroke; closes on selecting a
* result, on Escape, or on clicking outside it.
* Rooms whose name starts with `query`. The prefix endpoint, not the game's `/rooms/search`:
* that one's substring match reads every public room per call, which a search fired on
* every pause in typing can't afford; this one is an index seek billed per row returned.
*/
function PlayerSearch({ navigate }: { navigate: Navigate }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<PublicAccount[]>([])
const searchRooms = (query: string, take: number): Promise<PublicRoom[]> =>
query.trim() === ''
? Promise.resolve([])
: call<PublicRoom[]>(
`${where().rooms}/rooms/search/name?query=${encodeURIComponent(query.trim())}&take=${take}`
)
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2.2" />
<path
d="M16 16l4.5 4.5"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
</svg>
)
}
/**
* The header search: a magnifier button that opens a panel searching players and rooms
* together as you type. A button rather than an always-on bar so it fits a phone's nav.
* Debounced so it doesn't fire per keystroke; closes on picking a result, on Escape, or
* on a tap outside it. Enter takes the first player, else the first room.
*/
function SiteSearch({ navigate }: { navigate: Navigate }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [players, setPlayers] = useState<PublicAccount[]>([])
const [rooms, setRooms] = useState<PublicRoom[]>([])
// The query the results on screen answer, so "nothing found" waits for the answer.
const [answered, setAnswered] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Answers by normalized query, for the panel's lifetime on the page: backspacing to a
// query already asked redraws it without another request (and another D1 read).
const cache = useRef(new Map<string, [PublicAccount[], PublicRoom[]]>())
useEffect(() => {
if (query.trim() === '') {
setResults([])
const key = query.trim().toLowerCase()
const show = ([p, r]: [PublicAccount[], PublicRoom[]]) => {
setPlayers(p.slice(0, SEARCH_TAKE))
setRooms(r.slice(0, SEARCH_TAKE))
setAnswered(query)
}
if (key === '') {
setPlayers([])
setRooms([])
setAnswered('')
return
}
const known = cache.current.get(key)
if (known) {
show(known)
return
}
let stale = false
const timeout = setTimeout(() => {
void searchPlayers(query)
.then((r) => setResults(r.slice(0, 6)))
.catch(() => setResults([]))
void Promise.all([
searchPlayers(key, SEARCH_TAKE).catch(() => null),
searchRooms(key, SEARCH_TAKE).catch(() => null),
]).then(([p, r]) => {
const found: [PublicAccount[], PublicRoom[]] = [p ?? [], r ?? []]
// A failed half isn't cached, so the next time this is typed it asks again.
if (p !== null && r !== null) cache.current.set(key, found)
if (!stale) show(found)
})
}, 250)
return () => clearTimeout(timeout)
return () => {
stale = true
clearTimeout(timeout)
}
}, [query])
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (!open) return
inputRef.current?.focus()
const onPointerDown = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [])
document.addEventListener('pointerdown', onPointerDown)
return () => document.removeEventListener('pointerdown', onPointerDown)
}, [open])
const go = (username: string) => {
const go = (to: string) => {
setOpen(false)
setQuery('')
navigate(`/u/${encodeURIComponent(username)}`)
navigate(to)
}
const playerPath = (r: PublicAccount) => `/user/${encodeURIComponent(r.username)}`
const roomPath = (r: PublicRoom) => `/rooms/${r.RoomId}`
const nothing = query.trim() !== '' && answered === query && players.length + rooms.length === 0
return (
<div className="player-search" ref={containerRef}>
<input
type="text"
placeholder="Search players…"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false)
if (e.key === 'Enter' && results[0]) go(results[0].username)
}}
/>
{open && results.length > 0 && (
<div className="player-search-bubble">
{results.map((r) => (
<button
key={r.accountId}
className="player-search-result"
onClick={() => go(r.username)}
>
<img
className="player-search-avatar"
src={`${where().img}/${r.profileImage}?width=64`}
alt=""
/>
<span>
<span className="player-search-name">{r.displayName || r.username}</span>
<span className="player-search-handle">@{r.username}</span>
</span>
</button>
))}
<div className="site-search" ref={containerRef}>
<button
type="button"
className={`site-search-toggle${open ? ' active' : ''}`}
aria-label="Search players and rooms"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<SearchIcon />
</button>
{open && (
<div className="site-search-panel">
<input
ref={inputRef}
type="search"
placeholder="Search players and rooms…"
aria-label="Search players and rooms"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false)
if (e.key === 'Enter') {
if (players[0]) go(playerPath(players[0]))
else if (rooms[0]) go(roomPath(rooms[0]))
}
}}
/>
{players.length > 0 && (
<div className="site-search-group">
<p className="site-search-heading">Players</p>
{players.map((r) => (
<button
key={r.accountId}
type="button"
className="site-search-result"
onClick={() => go(playerPath(r))}
>
<img
className="site-search-avatar"
src={`${where().img}/${r.profileImage}?width=64`}
alt=""
/>
<span>
<span className="site-search-name">{r.displayName || r.username}</span>
<span className="site-search-sub">@{r.username}</span>
</span>
</button>
))}
</div>
)}
{rooms.length > 0 && (
<div className="site-search-group">
<p className="site-search-heading">Rooms</p>
{rooms.map((r) => (
<button
key={r.RoomId}
type="button"
className="site-search-result"
onClick={() => go(roomPath(r))}
>
<img
className="site-search-thumb"
src={`${where().img}/${r.ImageName}?width=128`}
alt=""
/>
<span>
<span className="site-search-name">^{r.Name}</span>
<span className="site-search-sub">
{r.Stats.VisitCount.toLocaleString()} visits
</span>
</span>
</button>
))}
</div>
)}
{nothing && <p className="site-search-empty">No players or rooms match.</p>}
</div>
)}
</div>
@@ -1429,7 +1537,7 @@ function ImagePage({ imageId, config }: { imageId: number; config: SiteConfig |
}
/**
* A player's public profile page (`/u/<username>`) — banner, avatar, bio-adjacent
* A player's public profile page (`/user/<username>`) — banner, avatar, bio-adjacent
* info, their public rooms, and their public photos. Read-only: this is the
* "rec.net-style profile" other players browse to, not the owner's own dashboard
* (that stays on `/account`).
@@ -1451,7 +1559,7 @@ function PlayerPage({
useEffect(() => {
// Waits for the config, not the session: this page is public, but every fetch on it
// goes through `where()`, which has no hostnames until `/api/config` lands. On a cold
// load of `/u/<name>` the effect would otherwise fire first and the page would be
// load of `/user/<name>` the effect would otherwise fire first and the page would be
// stuck on "Still starting up".
if (config === undefined) return
setAccount(undefined)
@@ -2460,7 +2568,6 @@ function NavBar({
RecFlare
</Link>
<nav className="nav-links">
{SHOW_PLAYER_SEARCH && <PlayerSearch navigate={navigate} />}
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Discord
</a>
@@ -2505,6 +2612,7 @@ function NavBar({
Sign in
</Link>
)}
{SHOW_SEARCH && <SiteSearch navigate={navigate} />}
</nav>
</header>
)
@@ -3199,7 +3307,7 @@ function PublicRoomView({
{creator && (
<p className="room-hero-by">
by{' '}
<Link to={`/u/${encodeURIComponent(creator.username)}`} navigate={navigate}>
<Link to={`/user/${encodeURIComponent(creator.username)}`} navigate={navigate}>
{creator.displayName || creator.username}
</Link>
</p>
+4 -4
View File
@@ -216,10 +216,10 @@ export function ItemPage({
<p className="room-hero-by">
by{' '}
<a
href={`/u/${encodeURIComponent(creator.username)}`}
href={`/user/${encodeURIComponent(creator.username)}`}
onClick={(e) => {
e.preventDefault()
navigate(`/u/${encodeURIComponent(creator.username)}`)
navigate(`/user/${encodeURIComponent(creator.username)}`)
}}
>
{creator.displayName || creator.username}
@@ -379,10 +379,10 @@ export function InventionPage({
<p className="room-hero-by">
by{' '}
<a
href={`/u/${encodeURIComponent(creator.username)}`}
href={`/user/${encodeURIComponent(creator.username)}`}
onClick={(e) => {
e.preventDefault()
navigate(`/u/${encodeURIComponent(creator.username)}`)
navigate(`/user/${encodeURIComponent(creator.username)}`)
}}
>
{creator.displayName || creator.username}
+1 -1
View File
@@ -369,7 +369,7 @@ function BanSummary({
}
/**
* One line for a player's profile page (`/u/<username>`): whether a ban blocks them right
* One line for a player's profile page (`/user/<username>`): whether a ban blocks them right
* now — their own or a linked account's — and a link to their full history here.
*/
export function PlayerBanStatus({
+91 -34
View File
@@ -135,8 +135,8 @@ body {
font-size: 0.95rem;
color: var(--muted);
text-decoration: none;
/* A link is one or two words; with the search bar in the row it has to give up space
by wrapping the ROW (see the nav rule under player search), not by breaking mid-link. */
/* A link is one or two words; short of room the ROW wraps (see the nav rule under
site search), never a link mid-word. */
white-space: nowrap;
}
@@ -1797,50 +1797,101 @@ button[type='submit']:disabled {
}
}
/* ---- Player search ------------------------------------------------------ */
/* ---- Site search ------------------------------------------------------- */
.player-search {
/* The panel hangs off the NAV, not the button: the nav can wrap on a phone, which moves
the button, but the nav's right edge stays put — so the panel always opens flush right
and never runs off the screen. */
.nav {
position: relative;
width: min(280px, 100%);
/* Wraps only when the row doesn't fit (a staff account's extra links on a phone). */
flex-wrap: wrap;
}
.player-search input {
margin: 0;
height: 40px;
padding: 8px 12px;
.site-search-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
border-color: var(--line);
color: var(--muted);
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
background 0.15s ease;
}
.player-search input:focus {
.site-search-toggle:hover,
.site-search-toggle.active {
color: var(--text);
border-color: var(--accent);
background: var(--surface-hi);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
outline: none;
}
.player-search-bubble {
.site-search-panel {
position: absolute;
z-index: 20;
top: calc(100% + 8px);
left: 0;
right: 0;
padding: 6px;
top: calc(100% - 8px);
right: 20px;
width: min(380px, calc(100% - 40px));
max-height: min(70vh, 560px);
overflow-y: auto;
padding: 8px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
}
.player-search-result {
.site-search-panel input {
margin: 0;
height: 42px;
padding: 8px 14px;
border-radius: 999px;
background: var(--surface-hi);
border-color: var(--line);
/* 16px keeps iOS from zooming the page when the box takes focus. */
font-size: 16px;
}
.site-search-panel input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
outline: none;
}
.site-search-group {
margin-top: 8px;
}
.site-search-heading,
.site-search-empty {
margin: 6px 8px 4px;
color: var(--muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.site-search-empty {
margin: 12px 8px 6px;
font-size: 0.9rem;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.site-search-result {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
gap: 10px;
padding: 8px;
border: 0;
@@ -1852,22 +1903,35 @@ button[type='submit']:disabled {
font-family: var(--body);
}
.player-search-result:hover,
.player-search-result:focus-visible {
.site-search-result > span {
min-width: 0;
}
.site-search-result:hover,
.site-search-result:focus-visible {
background: var(--surface-hi);
}
.player-search-avatar {
.site-search-avatar,
.site-search-thumb {
width: 36px;
height: 36px;
flex: none;
object-fit: cover;
border-radius: 50%;
border: 1px solid var(--line);
background: var(--surface-hi);
}
.player-search-name {
.site-search-avatar {
border-radius: 50%;
}
.site-search-thumb {
width: 54px;
border-radius: 6px;
}
.site-search-name {
display: block;
overflow: hidden;
font-weight: 600;
@@ -1876,7 +1940,7 @@ button[type='submit']:disabled {
white-space: nowrap;
}
.player-search-handle {
.site-search-sub {
display: block;
margin-top: 2px;
color: var(--muted);
@@ -1887,13 +1951,6 @@ button[type='submit']:disabled {
white-space: nowrap;
}
/* The search bar is a third thing in a row built for two, so the nav wraps when it runs
out of room rather than overflowing. No breakpoint: wrapping only happens when the row
doesn't fit, so wide screens are untouched. */
.nav {
flex-wrap: wrap;
}
/* The account page's banner upload, in the profile banner's shape so what's uploaded
looks as it will across the top of the profile. */
.banner-upload {