mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] better room/player search
This commit is contained in:
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function getAccountByUsername(
|
||||
export const SEARCH_LIMIT = 20
|
||||
|
||||
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
||||
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||
export const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* The smallest string that sorts after every string beginning with `prefix` under
|
||||
@@ -316,7 +316,7 @@ const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||
* UTF-8 encoding (F4 8F BF BF) is the highest valid sequence. The pair
|
||||
* `>= prefix AND < prefixUpperBound(prefix)` is the prefix match as a range.
|
||||
*/
|
||||
const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
|
||||
export const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
|
||||
|
||||
/**
|
||||
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* database read-only and uses `getRoomById`. Each imports the subset it needs.
|
||||
*/
|
||||
|
||||
import { escapeLike, prefixUpperBound } from './accounts-db'
|
||||
import { chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
|
||||
import { Accessibility, Role } from './enums'
|
||||
import { countPlayersByRoom } from './presence-db'
|
||||
@@ -72,6 +73,13 @@ export const ROOM_SCHEMA_DDL: string[] = [
|
||||
// feeds all order by it eventually; the WHERE clause is the point, not the key.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
|
||||
// The searchable rooms by lowercased name (migrations/0032_room_search_name.sql) — what
|
||||
// {@link searchRoomsByName} seeks. PARTIAL over exactly {@link SEARCHABLE_WHERE}, so the
|
||||
// walk never steps over a dorm or a private room: the rows it reads are the rows it
|
||||
// serves. `idx_rooms_name_lower` can't do that — it holds every dorm, and a prefix seek
|
||||
// on it checks each private room it passes against the blob.
|
||||
`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`,
|
||||
// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
|
||||
// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
|
||||
// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
|
||||
@@ -3691,6 +3699,38 @@ export async function searchRooms(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable rooms whose name BEGINS with `query` (case-insensitive), alphabetically, at
|
||||
* most `take` — the website's header search, which asks on every pause in typing.
|
||||
*
|
||||
* Not {@link searchRooms}: that is the game's search, a SUBSTRING match no index can
|
||||
* serve, so it reads every searchable room per call. This is a range seek on
|
||||
* `idx_room_search_name` (`>= q AND < q+U+10FFFF`, as {@link searchAccounts} does for
|
||||
* usernames), walked in `ORDER BY` order so the LIMIT stops it: D1 bills `take` rows
|
||||
* however many rooms exist. The WHERE repeats {@link SEARCHABLE_WHERE} verbatim because
|
||||
* SQLite only uses a partial index whose condition the query states; the LIKE is a
|
||||
* residual so `%`/`_` match literally. Rooms come back un-hydrated (no subrooms, tags or
|
||||
* badges) — the caller draws a name, a picture and the counters.
|
||||
*/
|
||||
export async function searchRoomsByName(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
take: number
|
||||
): Promise<Room[]> {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q === '' || take <= 0) return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT ${ROOM_COLUMNS} FROM room
|
||||
WHERE ${SEARCHABLE_WHERE}
|
||||
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
|
||||
ORDER BY name_lower LIMIT ?4`
|
||||
)
|
||||
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, take)
|
||||
.all<RoomRow>()
|
||||
return parseAll(results).filter(isSearchable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suggestions for the box the player is typing in
|
||||
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
|
||||
|
||||
Reference in New Issue
Block a user