feat: pg_trgm search, sort options, populated total count
Backend:
- Migration 0005_search.sql enables pg_trgm and adds GIN indexes
(gin_trgm_ops) on mangas.title and on mangas.author (partial, WHERE
author IS NOT NULL).
- repo::manga::list keeps the existing substring (ILIKE) clause and
adds the `%` operator on title + author so the search tolerates typos
('narto' → 'Naruto'). Both branches share the trgm index. A second
count(*) query (same WHERE clause, indexed) yields the total without
scanning twice in any meaningful sense.
- New ListSort enum (Recent / Title) interpolated into ORDER BY from a
hard-coded match — never from request input, so the format!() is not
a SQL-injection seam. Default stays Recent (created_at DESC).
- api::mangas accepts `?sort=recent|title` (snake_case) via serde and
returns `page.total` as a number instead of null.
- api::pagination::PagedResponse gains a `with_total` constructor.
Backend coverage in tests/api_mangas.rs (4 new cases plus the existing
list_is_empty_initially updated to assert total: 0):
- list_returns_total_count_independent_of_pagination — limit=2 with 3
rows returns 2 items and total=3.
- search_via_trigram_tolerates_typos — `?search=narto` finds Naruto.
- list_sort_title_orders_alphabetically — three out-of-order inserts
come back A→Z.
- search_reflects_filtered_total — search narrows total to 1.
Frontend:
- lib/api/mangas.ts gains a `MangaSort` type and threads `sort` through
listMangas's query-string builder.
- Home page renders a "Sort" select (Recent / Title A→Z) that re-runs
the list query, and shows "Showing N of M" when total is present.
Lockstep version bump to 0.8.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listMangas, type Manga } from '$lib/api/mangas';
|
||||
import { listMangas, type Manga, type MangaSort } from '$lib/api/mangas';
|
||||
|
||||
let mangas: Manga[] = $state([]);
|
||||
let search = $state('');
|
||||
let sort: MangaSort = $state('recent');
|
||||
let total: number | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
@@ -11,7 +13,12 @@
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
mangas = (await listMangas({ search: search.trim() || undefined })).items;
|
||||
const result = await listMangas({
|
||||
search: search.trim() || undefined,
|
||||
sort
|
||||
});
|
||||
mangas = result.items;
|
||||
total = result.page.total;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
@@ -19,6 +26,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onSortChange() {
|
||||
load();
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
@@ -30,6 +41,7 @@
|
||||
load();
|
||||
}}
|
||||
action="javascript:void(0)"
|
||||
class="controls"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
@@ -37,6 +49,13 @@
|
||||
placeholder="Search by title or author"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
<label>
|
||||
Sort
|
||||
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
|
||||
<option value="recent">Recent</option>
|
||||
<option value="title">Title (A→Z)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
@@ -47,6 +66,11 @@
|
||||
{:else if mangas.length === 0}
|
||||
<p data-testid="empty">No mangas yet. <a href="/upload">Upload one</a>.</p>
|
||||
{:else}
|
||||
{#if total !== null}
|
||||
<p class="count" data-testid="manga-total">
|
||||
Showing {mangas.length} of {total}
|
||||
</p>
|
||||
{/if}
|
||||
<ul data-testid="manga-list">
|
||||
{#each mangas as m (m.id)}
|
||||
<li>
|
||||
@@ -56,3 +80,21 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.count {
|
||||
color: #777;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user