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:
2026-03-07 10:43:26 +01:00
parent c90c09da9a
commit 9c5dba5c90
17 changed files with 1159 additions and 496 deletions

View File

@@ -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 } })}
&nbsp;·&nbsp;
{$_("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>