mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
[www] better room/player search
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
getAccount,
|
getAccount,
|
||||||
getAccountByUsername,
|
getAccountByUsername,
|
||||||
getAccountsByIds,
|
getAccountsByIds,
|
||||||
|
SEARCH_LIMIT,
|
||||||
searchAccounts,
|
searchAccounts,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
@@ -337,17 +338,28 @@ const app = new Hono<App>()
|
|||||||
description: 'Username prefix; empty matches nothing meaningful',
|
description: 'Username prefix; empty matches nothing meaningful',
|
||||||
schema: { type: 'string' },
|
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') },
|
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const name = c.req.query('name') ?? ''
|
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))
|
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`;
|
// `@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
|
// 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
|
// 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,
|
roomRoles,
|
||||||
saveSubRoomData,
|
saveSubRoomData,
|
||||||
searchRooms,
|
searchRooms,
|
||||||
|
searchRoomsByName,
|
||||||
setPlayerData,
|
setPlayerData,
|
||||||
setRoomDescription,
|
setRoomDescription,
|
||||||
setRoomImage,
|
setRoomImage,
|
||||||
@@ -1208,6 +1209,9 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
|
|||||||
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
|
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
|
||||||
const DEFAULT_SUGGESTION_COUNT = 10
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.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
|
// Type-ahead for the search box (`?query=r&take=4&searchSessionId=…`). A bare array of
|
||||||
// plain STRINGS — suggestions, not rooms and not an envelope.
|
// 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)
|
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 () => {
|
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
|
||||||
const suggest = async (query: string, extra = '') =>
|
const suggest = async (query: string, extra = '') =>
|
||||||
(await (
|
(await (
|
||||||
@@ -6251,6 +6291,7 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /rooms/ownedby/{accountId}',
|
'GET /rooms/ownedby/{accountId}',
|
||||||
'GET /rooms/recommendations',
|
'GET /rooms/recommendations',
|
||||||
'GET /rooms/search',
|
'GET /rooms/search',
|
||||||
|
'GET /rooms/search/name',
|
||||||
'GET /rooms/visitedby/me',
|
'GET /rooms/visitedby/me',
|
||||||
'GET /rooms/visitedby/{playerId}',
|
'GET /rooms/visitedby/{playerId}',
|
||||||
'GET /rooms/{roomId}',
|
'GET /rooms/{roomId}',
|
||||||
|
|||||||
+173
-65
@@ -1129,11 +1129,11 @@ interface PublicRoom {
|
|||||||
Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number }
|
Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefix-search accounts by username — backs the header search bar. */
|
/** Prefix-search accounts by username — backs the header search. */
|
||||||
async function searchPlayers(query: string): Promise<PublicAccount[]> {
|
async function searchPlayers(query: string, take: number): Promise<PublicAccount[]> {
|
||||||
if (query.trim() === '') return []
|
if (query.trim() === '') return []
|
||||||
return call<PublicAccount[]>(
|
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`
|
* 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
|
* 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
|
* 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.
|
* here decides who the viewer is; the worker does, from the token.
|
||||||
*/
|
*/
|
||||||
const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> =>
|
const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> =>
|
||||||
@@ -1218,91 +1218,199 @@ async function setRoomExcluded(roomId: number, excluded: boolean): Promise<Owned
|
|||||||
return result.value
|
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 {
|
function usernameFromPath(path: string): string | null {
|
||||||
const match = /^\/u\/([^/]+)$/.exec(path)
|
const match = /^\/(?:user|u)\/([^/]+)$/.exec(path)
|
||||||
return match ? decodeURIComponent(match[1]) : null
|
return match ? decodeURIComponent(match[1]) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only
|
// The nav's search. `/user/:name` and `/rooms/:id` stay reachable by URL either way; this
|
||||||
// decides whether the search bar that leads to them is shown.
|
// only decides whether the search button that leads to them is shown.
|
||||||
const SHOW_PLAYER_SEARCH: boolean = true
|
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
|
* Rooms whose name starts with `query`. The prefix endpoint, not the game's `/rooms/search`:
|
||||||
* type. Debounced so it doesn't fire a search per keystroke; closes on selecting a
|
* that one's substring match reads every public room per call, which a search fired on
|
||||||
* result, on Escape, or on clicking outside it.
|
* every pause in typing can't afford; this one is an index seek billed per row returned.
|
||||||
*/
|
*/
|
||||||
function PlayerSearch({ navigate }: { navigate: Navigate }) {
|
const searchRooms = (query: string, take: number): Promise<PublicRoom[]> =>
|
||||||
const [query, setQuery] = useState('')
|
query.trim() === ''
|
||||||
const [results, setResults] = useState<PublicAccount[]>([])
|
? 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 [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 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(() => {
|
useEffect(() => {
|
||||||
if (query.trim() === '') {
|
const key = query.trim().toLowerCase()
|
||||||
setResults([])
|
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
|
return
|
||||||
}
|
}
|
||||||
|
const known = cache.current.get(key)
|
||||||
|
if (known) {
|
||||||
|
show(known)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let stale = false
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
void searchPlayers(query)
|
void Promise.all([
|
||||||
.then((r) => setResults(r.slice(0, 6)))
|
searchPlayers(key, SEARCH_TAKE).catch(() => null),
|
||||||
.catch(() => setResults([]))
|
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)
|
}, 250)
|
||||||
return () => clearTimeout(timeout)
|
return () => {
|
||||||
|
stale = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
useEffect(() => {
|
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)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onClickOutside)
|
document.addEventListener('pointerdown', onPointerDown)
|
||||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
return () => document.removeEventListener('pointerdown', onPointerDown)
|
||||||
}, [])
|
}, [open])
|
||||||
|
|
||||||
const go = (username: string) => {
|
const go = (to: string) => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setQuery('')
|
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 (
|
return (
|
||||||
<div className="player-search" ref={containerRef}>
|
<div className="site-search" ref={containerRef}>
|
||||||
<input
|
<button
|
||||||
type="text"
|
type="button"
|
||||||
placeholder="Search players…"
|
className={`site-search-toggle${open ? ' active' : ''}`}
|
||||||
value={query}
|
aria-label="Search players and rooms"
|
||||||
onChange={(e) => {
|
aria-expanded={open}
|
||||||
setQuery(e.target.value)
|
onClick={() => setOpen((o) => !o)}
|
||||||
setOpen(true)
|
>
|
||||||
}}
|
<SearchIcon />
|
||||||
onFocus={() => setOpen(true)}
|
</button>
|
||||||
onKeyDown={(e) => {
|
{open && (
|
||||||
if (e.key === 'Escape') setOpen(false)
|
<div className="site-search-panel">
|
||||||
if (e.key === 'Enter' && results[0]) go(results[0].username)
|
<input
|
||||||
}}
|
ref={inputRef}
|
||||||
/>
|
type="search"
|
||||||
{open && results.length > 0 && (
|
placeholder="Search players and rooms…"
|
||||||
<div className="player-search-bubble">
|
aria-label="Search players and rooms"
|
||||||
{results.map((r) => (
|
value={query}
|
||||||
<button
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
key={r.accountId}
|
onKeyDown={(e) => {
|
||||||
className="player-search-result"
|
if (e.key === 'Escape') setOpen(false)
|
||||||
onClick={() => go(r.username)}
|
if (e.key === 'Enter') {
|
||||||
>
|
if (players[0]) go(playerPath(players[0]))
|
||||||
<img
|
else if (rooms[0]) go(roomPath(rooms[0]))
|
||||||
className="player-search-avatar"
|
}
|
||||||
src={`${where().img}/${r.profileImage}?width=64`}
|
}}
|
||||||
alt=""
|
/>
|
||||||
/>
|
{players.length > 0 && (
|
||||||
<span>
|
<div className="site-search-group">
|
||||||
<span className="player-search-name">{r.displayName || r.username}</span>
|
<p className="site-search-heading">Players</p>
|
||||||
<span className="player-search-handle">@{r.username}</span>
|
{players.map((r) => (
|
||||||
</span>
|
<button
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</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
|
* 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
|
* "rec.net-style profile" other players browse to, not the owner's own dashboard
|
||||||
* (that stays on `/account`).
|
* (that stays on `/account`).
|
||||||
@@ -1451,7 +1559,7 @@ function PlayerPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Waits for the config, not the session: this page is public, but every fetch on it
|
// 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
|
// 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".
|
// stuck on "Still starting up".
|
||||||
if (config === undefined) return
|
if (config === undefined) return
|
||||||
setAccount(undefined)
|
setAccount(undefined)
|
||||||
@@ -2460,7 +2568,6 @@ function NavBar({
|
|||||||
RecFlare
|
RecFlare
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="nav-links">
|
<nav className="nav-links">
|
||||||
{SHOW_PLAYER_SEARCH && <PlayerSearch navigate={navigate} />}
|
|
||||||
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
||||||
Discord
|
Discord
|
||||||
</a>
|
</a>
|
||||||
@@ -2505,6 +2612,7 @@ function NavBar({
|
|||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{SHOW_SEARCH && <SiteSearch navigate={navigate} />}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
@@ -3199,7 +3307,7 @@ function PublicRoomView({
|
|||||||
{creator && (
|
{creator && (
|
||||||
<p className="room-hero-by">
|
<p className="room-hero-by">
|
||||||
by{' '}
|
by{' '}
|
||||||
<Link to={`/u/${encodeURIComponent(creator.username)}`} navigate={navigate}>
|
<Link to={`/user/${encodeURIComponent(creator.username)}`} navigate={navigate}>
|
||||||
{creator.displayName || creator.username}
|
{creator.displayName || creator.username}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -216,10 +216,10 @@ export function ItemPage({
|
|||||||
<p className="room-hero-by">
|
<p className="room-hero-by">
|
||||||
by{' '}
|
by{' '}
|
||||||
<a
|
<a
|
||||||
href={`/u/${encodeURIComponent(creator.username)}`}
|
href={`/user/${encodeURIComponent(creator.username)}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
navigate(`/u/${encodeURIComponent(creator.username)}`)
|
navigate(`/user/${encodeURIComponent(creator.username)}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{creator.displayName || creator.username}
|
{creator.displayName || creator.username}
|
||||||
@@ -379,10 +379,10 @@ export function InventionPage({
|
|||||||
<p className="room-hero-by">
|
<p className="room-hero-by">
|
||||||
by{' '}
|
by{' '}
|
||||||
<a
|
<a
|
||||||
href={`/u/${encodeURIComponent(creator.username)}`}
|
href={`/user/${encodeURIComponent(creator.username)}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
navigate(`/u/${encodeURIComponent(creator.username)}`)
|
navigate(`/user/${encodeURIComponent(creator.username)}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{creator.displayName || 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.
|
* now — their own or a linked account's — and a link to their full history here.
|
||||||
*/
|
*/
|
||||||
export function PlayerBanStatus({
|
export function PlayerBanStatus({
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ body {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
/* A link is one or two words; with the search bar in the row it has to give up space
|
/* A link is one or two words; short of room the ROW wraps (see the nav rule under
|
||||||
by wrapping the ROW (see the nav rule under player search), not by breaking mid-link. */
|
site search), never a link mid-word. */
|
||||||
white-space: nowrap;
|
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;
|
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 {
|
.site-search-toggle {
|
||||||
margin: 0;
|
display: inline-flex;
|
||||||
height: 40px;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-color: var(--line);
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
border-color 0.15s ease,
|
border-color 0.15s ease,
|
||||||
background 0.15s ease,
|
background 0.15s ease;
|
||||||
box-shadow 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-search input:focus {
|
.site-search-toggle:hover,
|
||||||
|
.site-search-toggle.active {
|
||||||
|
color: var(--text);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--surface-hi);
|
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;
|
position: absolute;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% - 8px);
|
||||||
left: 0;
|
right: 20px;
|
||||||
right: 0;
|
width: min(380px, calc(100% - 40px));
|
||||||
padding: 6px;
|
max-height: min(70vh, 560px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -1852,22 +1903,35 @@ button[type='submit']:disabled {
|
|||||||
font-family: var(--body);
|
font-family: var(--body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-search-result:hover,
|
.site-search-result > span {
|
||||||
.player-search-result:focus-visible {
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-result:hover,
|
||||||
|
.site-search-result:focus-visible {
|
||||||
background: var(--surface-hi);
|
background: var(--surface-hi);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-search-avatar {
|
.site-search-avatar,
|
||||||
|
.site-search-thumb {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
flex: none;
|
flex: none;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface-hi);
|
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;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1876,7 +1940,7 @@ button[type='submit']:disabled {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-search-handle {
|
.site-search-sub {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1887,13 +1951,6 @@ button[type='submit']:disabled {
|
|||||||
white-space: nowrap;
|
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
|
/* 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. */
|
looks as it will across the top of the profile. */
|
||||||
.banner-upload {
|
.banner-upload {
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export async function getAccountByUsername(
|
|||||||
export const SEARCH_LIMIT = 20
|
export const SEARCH_LIMIT = 20
|
||||||
|
|
||||||
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
/** 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
|
* 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
|
* 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.
|
* `>= 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
|
* 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.
|
* 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 { chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
|
||||||
import { Accessibility, Role } from './enums'
|
import { Accessibility, Role } from './enums'
|
||||||
import { countPlayersByRoom } from './presence-db'
|
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.
|
// 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)
|
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
|
||||||
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
|
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
|
// 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
|
// `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
|
// `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
|
* Search suggestions for the box the player is typing in
|
||||||
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
|
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
|
||||||
|
|||||||
Reference in New Issue
Block a user