feat: add server-side pagination, search, and filtering to all collection and admin pages
- Public pages (videos, magazine, models): URL-driven search, sort, category/duration
filters, and Prev/Next pagination (page size 24)
- Admin tables (videos, articles): search input, toggle filters, and pagination (page size 50)
- Tags page: tag filtering now done server-side via DB arrayContains query instead of
fetching all items and filtering client-side
- Backend resolvers updated for videos, articles, models with paginated { items, total }
responses and filter/sort/tag args
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { SvelteURLSearchParams } from "svelte/reactivity";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
@@ -7,33 +10,38 @@
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state("popular");
|
||||
let categoryFilter = $state("all");
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const filteredModels = $derived(() => {
|
||||
return data.models
|
||||
.filter((model) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
model.artist_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
model.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesCategory = categoryFilter === "all";
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// if (sortBy === "popular") {
|
||||
// const aNum = parseInt(a.subscribers.replace(/[^\d]/g, ""));
|
||||
// const bNum = parseInt(b.subscribers.replace(/[^\d]/g, ""));
|
||||
// return bNum - aNum;
|
||||
// }
|
||||
// if (sortBy === "rating") return b.rating - a.rating;
|
||||
// if (sortBy === "videos") return b.videos - a.videos;
|
||||
return (a.artist_name ?? "").localeCompare(b.artist_name ?? "");
|
||||
});
|
||||
});
|
||||
let searchValue = $state(data.search ?? "");
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function debounceSearch(value: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
if (value) params.set("search", value);
|
||||
else params.delete("search");
|
||||
params.delete("page");
|
||||
goto(`?${params.toString()}`, { keepFocus: true });
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function setParam(key: string, value: string) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
if (value && value !== "name") params.set(key, value);
|
||||
else params.delete(key);
|
||||
params.delete("page");
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
if (p > 1) params.set("page", String(p));
|
||||
else params.delete("page");
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||
</script>
|
||||
|
||||
<Meta title={$_("models.title")} description={$_("models.description")} />
|
||||
@@ -76,51 +84,25 @@
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_("models.search_placeholder")}
|
||||
bind:value={searchQuery}
|
||||
value={searchValue}
|
||||
oninput={(e) => {
|
||||
searchValue = (e.target as HTMLInputElement).value;
|
||||
debounceSearch(searchValue);
|
||||
}}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<Select type="single" bind:value={categoryFilter}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{categoryFilter === "all"
|
||||
? $_("models.categories.all")
|
||||
: categoryFilter === "romantic"
|
||||
? $_("models.categories.romantic")
|
||||
: categoryFilter === "artistic"
|
||||
? $_("models.categories.artistic")
|
||||
: $_("models.categories.intimate")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_("models.categories.all")}</SelectItem>
|
||||
<SelectItem value="romantic">{$_("models.categories.romantic")}</SelectItem>
|
||||
<SelectItem value="artistic">{$_("models.categories.artistic")}</SelectItem>
|
||||
<SelectItem value="intimate">{$_("models.categories.intimate")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Sort -->
|
||||
<Select type="single" bind:value={sortBy}>
|
||||
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
{sortBy === "popular"
|
||||
? $_("models.sort.popular")
|
||||
: sortBy === "rating"
|
||||
? $_("models.sort.rating")
|
||||
: sortBy === "videos"
|
||||
? $_("models.sort.videos")
|
||||
: $_("models.sort.name")}
|
||||
{data.sort === "recent" ? $_("models.sort.recent") : $_("models.sort.name")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="popular">{$_("models.sort.popular")}</SelectItem>
|
||||
<SelectItem value="rating">{$_("models.sort.rating")}</SelectItem>
|
||||
<SelectItem value="videos">{$_("models.sort.videos")}</SelectItem>
|
||||
<SelectItem value="name">{$_("models.sort.name")}</SelectItem>
|
||||
<SelectItem value="recent">{$_("models.sort.recent")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -130,7 +112,7 @@
|
||||
<!-- Models Grid -->
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{#each filteredModels() as model (model.slug)}
|
||||
{#each data.items as model (model.slug)}
|
||||
<Card
|
||||
class="py-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-500 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
|
||||
>
|
||||
@@ -227,20 +209,44 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredModels().length === 0}
|
||||
{#if data.items.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground text-lg">{$_("models.no_results")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
searchQuery = "";
|
||||
categoryFilter = "all";
|
||||
}}
|
||||
class="mt-4"
|
||||
>
|
||||
<Button variant="outline" href="/models" class="mt-4">
|
||||
{$_("models.clear_filters")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-10">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
|
||||
·
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => goToPage(data.page - 1)}
|
||||
class="border-primary/20 hover:bg-primary/10"
|
||||
>
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= totalPages}
|
||||
onclick={() => goToPage(data.page + 1)}
|
||||
class="border-primary/20 hover:bg-primary/10"
|
||||
>
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user