Repository navigation
fix: implement instant real-time and server-side search filtering on storefront #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -113,6 +113,27 @@ export async function listPlugins(db: D1Database): Promise<Plugin[]> { | |
| return (results ?? []).map(toPlugin); | ||
| } | ||
|
|
||
| /** | ||
| * Case-insensitive search across the fields a visitor can see on a card: name, | ||
| * description and category. The term is bound, never interpolated, so the `%` | ||
| * wildcards are the only pattern syntax that reaches SQL. `COALESCE` keeps rows | ||
| * with a NULL description matchable. | ||
| */ | ||
| export async function searchPlugins(db: D1Database, query: string): Promise<Plugin[]> { | ||
| const term = `%${query.toLowerCase()}%`; | ||
| const { results } = await db | ||
| .prepare( | ||
| `SELECT ${PLUGIN_COLUMNS} ${PLUGIN_SOURCE} | ||
| WHERE LOWER(p.name) LIKE ?1 | ||
| OR LOWER(COALESCE(p.description, '')) LIKE ?1 | ||
| OR LOWER(p.category) LIKE ?1 | ||
|
Comment on lines
+127
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Match the fields searched by the catalog input. The client-side card search includes publisher name, routes, and platforms, but
|
||
| ORDER BY p.downloads DESC, p.name ASC`, | ||
| ) | ||
| .bind(term) | ||
| .all<PluginRow>(); | ||
| return (results ?? []).map(toPlugin); | ||
| } | ||
|
|
||
| export async function getPlugin(db: D1Database, id: string): Promise<Plugin | null> { | ||
| const row = await db | ||
| .prepare(`SELECT ${PLUGIN_COLUMNS} ${PLUGIN_SOURCE} WHERE p.id = ?`) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ import Layout from '../layouts/Layout.astro'; | |
| import CategoryFilter from '../components/CategoryFilter.astro'; | ||
| import Card from '../components/Card.astro'; | ||
| import Icon from '../components/Icon.astro'; | ||
| import { getDB, listPlugins, type Plugin } from '../lib/db'; | ||
| import { getDB, listPlugins, searchPlugins, type Plugin } from '../lib/db'; | ||
| import { EMPTY_STATE, SITE, orderCategories } from '../lib/site'; | ||
|
|
||
| export const prerender = false; | ||
|
|
@@ -16,10 +16,17 @@ if (legacyId) { | |
| return Astro.redirect(`/plugin/${encodeURIComponent(legacyId)}`, 301); | ||
| } | ||
|
|
||
| // A ?q= search is answered by D1 so the results are real at first paint (and the | ||
| // grid is populated before the client script runs). The navbar pre-fills the input | ||
| // with the same value, so the box and the results never disagree on load. | ||
| const searchParam = Astro.url.searchParams.get('q')?.trim() ?? ''; | ||
| const isSearching = searchParam.length > 0; | ||
|
|
||
| let plugins: Plugin[] = []; | ||
| let dbError: string | null = null; | ||
| try { | ||
| plugins = await listPlugins(getDB(Astro.locals)); | ||
| const db = getDB(Astro.locals); | ||
| plugins = isSearching ? await searchPlugins(db, searchParam) : await listPlugins(db); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep instant filtering valid after a server search. When
|
||
| } catch (error) { | ||
| dbError = error instanceof Error ? error.message : String(error); | ||
| } | ||
|
|
@@ -99,7 +106,7 @@ const total = plugins.length; | |
| </p> | ||
| <p class="mt-3 break-all font-mono text-xs text-zinc-600">{dbError}</p> | ||
| </div> | ||
| ) : total === 0 ? ( | ||
| ) : total === 0 && !isSearching ? ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Show zero-result feedback in the server response. This branch renders the search-results section for a zero-result
|
||
| <div | ||
| class="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-900/30 px-6 py-16 text-center" | ||
| data-empty-state | ||
|
|
@@ -138,10 +145,11 @@ const total = plugins.length; | |
| /> | ||
|
|
||
| <section> | ||
| <div class="mb-5 flex items-baseline justify-between"> | ||
| <div class="mb-5 flex items-baseline justify-between gap-3"> | ||
| <h2 class="text-sm font-semibold text-zinc-300"> | ||
| <span data-result-count>{total}</span> | ||
| {total === 1 ? ' extension' : ' extensions'} | ||
| {isSearching && <span class="font-normal text-zinc-500">for “{searchParam}”</span>} | ||
| </h2> | ||
| <p class="text-xs text-zinc-600">Sorted live from D1</p> | ||
| </div> | ||
|
|
@@ -309,12 +317,12 @@ const total = plugins.length; | |
| } | ||
| }); | ||
|
|
||
| // --- Deep links: ?q= pre-filters the grid --- | ||
| const params = new URLSearchParams(location.search); | ||
| const q = params.get('q'); | ||
| if (q && searchInput) { | ||
| searchInput.value = q; | ||
| state.query = q.trim().toLowerCase(); | ||
| // --- Initial query --- | ||
| // The navbar input is pre-filled server-side from ?q=, so adopt whatever it | ||
| // holds (trimmed, lowercased) as the starting query and reconcile the grid with | ||
| // it. This keeps the box and the server-rendered results in agreement on load. | ||
| if (searchInput && searchInput.value.trim()) { | ||
| state.query = searchInput.value.trim().toLowerCase(); | ||
| applyFilters(); | ||
| } | ||
| </script> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
|
|
||
| import { searchPlugins, type PluginRow } from '../src/lib/db.ts'; | ||
|
|
||
| // --- Minimal D1 stand-in ---------------------------------------------------- | ||
| // D1 is an external boundary, so it is faked here; the code under test | ||
| // (`searchPlugins`) is real. The fake records the SQL and the bound values so the | ||
| // test can assert on how the query is built — most importantly that the search | ||
| // term is *bound*, never spliced into the statement. | ||
|
|
||
| interface Capture { | ||
| sql: string; | ||
| bindings: unknown[]; | ||
| } | ||
|
|
||
| function fakeDb(rows: PluginRow[], capture: Capture): D1Database { | ||
| const statement = (sql: string) => ({ | ||
| bind: (...values: unknown[]) => { | ||
| capture.sql = sql; | ||
| capture.bindings = values; | ||
| return { | ||
| all: async () => ({ results: rows }), | ||
| first: async () => rows[0] ?? null, | ||
| }; | ||
| }, | ||
| all: async () => ({ results: rows }), | ||
| first: async () => rows[0] ?? null, | ||
| }); | ||
| return { prepare: (sql: string) => statement(sql) } as unknown as D1Database; | ||
| } | ||
|
|
||
| function row(overrides: Partial<PluginRow> = {}): PluginRow { | ||
| return { | ||
| id: 'nkit', | ||
| name: 'NKit', | ||
| version: '3.2.0', | ||
| min_core_version: '3.9.0', | ||
| publisher_id: 'm5devs', | ||
| publisher_name: 'M5 Dev', | ||
| publisher_verified: 1, | ||
| category: 'Nintendo', | ||
| description: 'Disc Restoration Suite', | ||
| readme_markdown: null, | ||
| routes_json: null, | ||
| platforms_json: null, | ||
| download_url: 'https://example.test/nkit.orplug', | ||
| sha256: 'abc', | ||
| icon: null, | ||
| downloads: 0, | ||
| created_at: '2026-01-01T00:00:00Z', | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| test('searchPlugins binds the lowercased term wrapped in wildcards', async () => { | ||
| const capture: Capture = { sql: '', bindings: [] }; | ||
| await searchPlugins(fakeDb([row()], capture), 'NKit'); | ||
|
|
||
| assert.deepEqual(capture.bindings, ['%nkit%']); | ||
| }); | ||
|
|
||
| test('searchPlugins matches name, description and category case-insensitively', async () => { | ||
| const capture: Capture = { sql: '', bindings: [] }; | ||
| await searchPlugins(fakeDb([], capture), 'x'); | ||
|
|
||
| const sql = capture.sql.toLowerCase(); | ||
| assert.match(sql, /lower\(p\.name\) like \?1/); | ||
| assert.match(sql, /lower\(coalesce\(p\.description, ''\)\) like \?1/); | ||
| assert.match(sql, /lower\(p\.category\) like \?1/); | ||
| }); | ||
|
|
||
| test('searchPlugins reuses the shared plugin source so publisher name/verified resolve', async () => { | ||
| const capture: Capture = { sql: '', bindings: [] }; | ||
| await searchPlugins(fakeDb([], capture), 'x'); | ||
|
|
||
| assert.match(capture.sql, /LEFT JOIN publishers pub ON pub\.id = p\.publisher_id/i); | ||
| }); | ||
|
|
||
| test('searchPlugins never splices the raw term into the SQL text', async () => { | ||
| const capture: Capture = { sql: '', bindings: [] }; | ||
| const payload = "'; DROP TABLE plugins; --"; | ||
| await searchPlugins(fakeDb([], capture), payload); | ||
|
|
||
| assert.equal(capture.sql.includes('DROP TABLE'), false); | ||
| assert.deepEqual(capture.bindings, [`%${payload.toLowerCase()}%`]); | ||
| }); | ||
|
|
||
| test('searchPlugins maps rows through toPlugin (publisher name + verified flag)', async () => { | ||
| const capture: Capture = { sql: '', bindings: [] }; | ||
| const [plugin] = await searchPlugins(fakeDb([row()], capture), 'nkit'); | ||
|
|
||
| assert.equal(plugin.id, 'nkit'); | ||
| assert.equal(plugin.name, 'NKit'); | ||
| assert.equal(plugin.publisherName, 'M5 Dev'); | ||
| assert.equal(plugin.publisherVerified, true); | ||
| }); | ||
|
|
||
| test('searchPlugins returns an empty array when nothing matches', async () => { | ||
| const capture: Capture = { sql: '', bindings: [] }; | ||
| const plugins = await searchPlugins(fakeDb([], capture), 'zzzznomatch'); | ||
|
|
||
| assert.deepEqual(plugins, []); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Escape literal LIKE metacharacters.
The bound term still treats user-supplied
%and_as wildcards. For example, searching for%matches every database row, while the client-side filter treats%as literal text. Escape those characters and the escape character before adding the surrounding%wildcards, and specify the matching SQLESCAPEclause. Binding already protects SQL syntax; this fix makes the search results correct. (sqlite.org)