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

@@ -23,6 +23,8 @@ export default {
my_profile: "My Profile",
anonymous: "Anonymous",
load_more: "Load More",
page_of: "Page {page} of {total}",
total_results: "{total} results",
},
header: {
home: "Home",
@@ -251,6 +253,7 @@ export default {
rating: "Highest Rated",
videos: "Most Videos",
name: "A-Z",
recent: "Newest",
},
online: "Online",
followers: "followers",
@@ -913,6 +916,7 @@ export default {
saving: "Saving…",
creating: "Creating…",
deleting: "Deleting…",
all: "All",
featured: "Featured",
premium: "Premium",
write: "Write",
@@ -944,7 +948,8 @@ export default {
role_updated: "Role updated to {role}",
role_update_failed: "Failed to update role",
delete_title: "Delete user",
delete_description: "Are you sure you want to permanently delete {name}? This cannot be undone.",
delete_description:
"Are you sure you want to permanently delete {name}? This cannot be undone.",
delete_success: "User deleted",
delete_error: "Failed to delete user",
},
@@ -971,6 +976,7 @@ export default {
videos: {
title: "Videos",
new_video: "New video",
search_placeholder: "Search videos...",
col_video: "Video",
col_badges: "Badges",
col_plays: "Plays",
@@ -1005,6 +1011,8 @@ export default {
articles: {
title: "Articles",
new_article: "New article",
search_placeholder: "Search articles...",
filter_all_categories: "All categories",
col_article: "Article",
col_category: "Category",
col_published: "Published",

View File

@@ -216,31 +216,63 @@ export async function resetPassword(token: string, password: string) {
// ─── Articles ────────────────────────────────────────────────────────────────
const ARTICLES_QUERY = gql`
query GetArticles {
articles {
id
slug
title
excerpt
content
image
tags
publish_date
category
featured
author {
query GetArticles(
$search: String
$category: String
$sortBy: String
$offset: Int
$limit: Int
$featured: Boolean
$tag: String
) {
articles(
search: $search
category: $category
sortBy: $sortBy
offset: $offset
limit: $limit
featured: $featured
tag: $tag
) {
items {
id
artist_name
slug
avatar
title
excerpt
content
image
tags
publish_date
category
featured
author {
id
artist_name
slug
avatar
}
}
total
}
}
`;
export async function getArticles(fetchFn?: typeof globalThis.fetch) {
export async function getArticles(
params: {
search?: string;
category?: string;
sortBy?: string;
offset?: number;
limit?: number;
featured?: boolean;
tag?: string;
} = {},
fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Article[]; total: number }> {
return loggedApiCall("getArticles", async () => {
const data = await getGraphQLClient(fetchFn).request<{ articles: Article[] }>(ARTICLES_QUERY);
const data = await getGraphQLClient(fetchFn).request<{
articles: { items: Article[]; total: number };
}>(ARTICLES_QUERY, params);
return data.articles;
});
}
@@ -286,39 +318,72 @@ export async function getArticleBySlug(slug: string, fetchFn?: typeof globalThis
// ─── Videos ──────────────────────────────────────────────────────────────────
const VIDEOS_QUERY = gql`
query GetVideos($modelId: String, $featured: Boolean, $limit: Int) {
videos(modelId: $modelId, featured: $featured, limit: $limit) {
id
slug
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
query GetVideos(
$modelId: String
$featured: Boolean
$limit: Int
$search: String
$offset: Int
$sortBy: String
$duration: String
$tag: String
) {
videos(
modelId: $modelId
featured: $featured
limit: $limit
search: $search
offset: $offset
sortBy: $sortBy
duration: $duration
tag: $tag
) {
items {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
}
}
total
}
}
`;
export async function getVideos(fetchFn?: typeof globalThis.fetch) {
export async function getVideos(
params: {
search?: string;
sortBy?: string;
duration?: string;
offset?: number;
limit?: number;
tag?: string;
} = {},
fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Video[]; total: number }> {
return loggedApiCall("getVideos", async () => {
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY);
const data = await getGraphQLClient(fetchFn).request<{
videos: { items: Video[]; total: number };
}>(VIDEOS_QUERY, params);
return data.videos;
});
}
@@ -327,10 +392,10 @@ export async function getVideosForModel(id: string, fetchFn?: typeof globalThis.
return loggedApiCall(
"getVideosForModel",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY, {
modelId: id,
});
return data.videos;
const data = await getGraphQLClient(fetchFn).request<{
videos: { items: Video[]; total: number };
}>(VIDEOS_QUERY, { modelId: id, limit: 10000 });
return data.videos.items;
},
{ modelId: id },
);
@@ -343,11 +408,10 @@ export async function getFeaturedVideos(
return loggedApiCall(
"getFeaturedVideos",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY, {
featured: true,
limit,
});
return data.videos;
const data = await getGraphQLClient(fetchFn).request<{
videos: { items: Video[]; total: number };
}>(VIDEOS_QUERY, { featured: true, limit });
return data.videos.items;
},
{ limit },
);
@@ -402,27 +466,49 @@ export async function getVideoBySlug(slug: string, fetchFn?: typeof globalThis.f
// ─── Models ──────────────────────────────────────────────────────────────────
const MODELS_QUERY = gql`
query GetModels($featured: Boolean, $limit: Int) {
models(featured: $featured, limit: $limit) {
id
slug
artist_name
description
avatar
banner
tags
date_created
photos {
query GetModels(
$featured: Boolean
$limit: Int
$search: String
$offset: Int
$sortBy: String
$tag: String
) {
models(
featured: $featured
limit: $limit
search: $search
offset: $offset
sortBy: $sortBy
tag: $tag
) {
items {
id
filename
slug
artist_name
description
avatar
banner
tags
date_created
photos {
id
filename
}
}
total
}
}
`;
export async function getModels(fetchFn?: typeof globalThis.fetch) {
export async function getModels(
params: { search?: string; sortBy?: string; offset?: number; limit?: number; tag?: string } = {},
fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Model[]; total: number }> {
return loggedApiCall("getModels", async () => {
const data = await getGraphQLClient(fetchFn).request<{ models: Model[] }>(MODELS_QUERY);
const data = await getGraphQLClient(fetchFn).request<{
models: { items: Model[]; total: number };
}>(MODELS_QUERY, params);
return data.models;
});
}
@@ -434,11 +520,10 @@ export async function getFeaturedModels(
return loggedApiCall(
"getFeaturedModels",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ models: Model[] }>(MODELS_QUERY, {
featured: true,
limit,
});
return data.models;
const data = await getGraphQLClient(fetchFn).request<{
models: { items: Model[]; total: number };
}>(MODELS_QUERY, { featured: true, limit });
return data.models.items;
},
{ limit },
);
@@ -668,7 +753,7 @@ export async function countCommentsForModel(
export async function getItemsByTag(
category: "video" | "article" | "model",
_tag: string,
tag: string,
fetchFn?: typeof globalThis.fetch,
) {
return loggedApiCall(
@@ -676,14 +761,14 @@ export async function getItemsByTag(
async () => {
switch (category) {
case "video":
return getVideos(fetchFn);
return getVideos({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
case "model":
return getModels(fetchFn);
return getModels({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
case "article":
return getArticles(fetchFn);
return getArticles({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
}
},
{ category },
{ category, tag },
);
}
@@ -1188,41 +1273,67 @@ export async function adminRemoveUserPhoto(userId: string, fileId: string) {
// ─── Admin: Videos ────────────────────────────────────────────────────────────
const ADMIN_LIST_VIDEOS_QUERY = gql`
query AdminListVideos {
adminListVideos {
id
slug
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
query AdminListVideos(
$search: String
$premium: Boolean
$featured: Boolean
$limit: Int
$offset: Int
) {
adminListVideos(
search: $search
premium: $premium
featured: $featured
limit: $limit
offset: $offset
) {
items {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
}
}
total
}
}
`;
export async function adminListVideos(fetchFn?: typeof globalThis.fetch, token?: string) {
export async function adminListVideos(
opts: {
search?: string;
premium?: boolean;
featured?: boolean;
limit?: number;
offset?: number;
} = {},
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<{ items: Video[]; total: number }> {
return loggedApiCall("adminListVideos", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminListVideos: Video[] }>(
const data = await client.request<{ adminListVideos: { items: Video[]; total: number } }>(
ADMIN_LIST_VIDEOS_QUERY,
opts,
);
return data.adminListVideos;
});
@@ -1374,33 +1485,59 @@ export async function setVideoModels(videoId: string, userIds: string[]) {
// ─── Admin: Articles ──────────────────────────────────────────────────────────
const ADMIN_LIST_ARTICLES_QUERY = gql`
query AdminListArticles {
adminListArticles {
id
slug
title
excerpt
image
tags
publish_date
category
featured
content
author {
query AdminListArticles(
$search: String
$category: String
$featured: Boolean
$limit: Int
$offset: Int
) {
adminListArticles(
search: $search
category: $category
featured: $featured
limit: $limit
offset: $offset
) {
items {
id
artist_name
slug
avatar
title
excerpt
image
tags
publish_date
category
featured
content
author {
id
artist_name
slug
avatar
}
}
total
}
}
`;
export async function adminListArticles(fetchFn?: typeof globalThis.fetch, token?: string) {
export async function adminListArticles(
opts: {
search?: string;
category?: string;
featured?: boolean;
limit?: number;
offset?: number;
} = {},
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<{ items: Article[]; total: number }> {
return loggedApiCall("adminListArticles", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminListArticles: Article[] }>(
const data = await client.request<{ adminListArticles: { items: Article[]; total: number } }>(
ADMIN_LIST_ARTICLES_QUERY,
opts,
);
return data.adminListArticles;
});

View File

@@ -1,7 +1,19 @@
import { adminListArticles } from "$lib/services";
export async function load({ fetch, cookies }) {
export async function load({ fetch, url, cookies }) {
const token = cookies.get("session_token") || "";
const articles = await adminListArticles(fetch, token).catch(() => []);
return { articles };
const search = url.searchParams.get("search") || undefined;
const category = url.searchParams.get("category") || undefined;
const featuredParam = url.searchParams.get("featured");
const featured = featuredParam !== null ? featuredParam === "true" : undefined;
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
const limit = 50;
const result = await adminListArticles(
{ search, category, featured, limit, offset },
fetch,
token,
).catch(() => ({ items: [], total: 0 }));
return { ...result, search, category, featured, offset, limit };
}

View File

@@ -1,10 +1,14 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { deleteArticle } from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import * as Dialog from "$lib/components/ui/dialog";
import type { Article } from "$lib/types";
import TimeAgo from "javascript-time-ago";
@@ -16,6 +20,27 @@
let deleteTarget: Article | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
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("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function setFilter(key: string, value: string | null) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value !== null) params.set(key, value);
else params.delete(key);
params.delete("offset");
goto(`?${params.toString()}`);
}
function confirmDelete(article: Article) {
deleteTarget = article;
@@ -42,8 +67,54 @@
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
<Button href="/admin/articles/new" class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.articles.new_article")}
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
<Button
href="/admin/articles/new"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.articles.new_article")}
</Button>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<Input
placeholder={$_("admin.articles.search_placeholder")}
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
<Select
type="single"
value={data.category ?? "all"}
onValueChange={(v) => setFilter("category", v === "all" ? null : (v ?? null))}
>
<SelectTrigger class="w-40 h-9 text-sm">
{data.category ?? $_("admin.articles.filter_all_categories")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("admin.articles.filter_all_categories")}</SelectItem>
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", data.featured === true ? null : "true")}
>
{$_("admin.common.featured")}
</Button>
</div>
@@ -51,14 +122,22 @@
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.articles.col_article")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.articles.col_category")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.articles.col_published")}</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.articles.col_article")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.articles.col_category")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.articles.col_published")}</th
>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.users.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.articles as article (article.id)}
{#each data.items as article (article.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
@@ -86,7 +165,9 @@
</div>
</div>
</td>
<td class="px-4 py-3 text-muted-foreground capitalize hidden sm:table-cell">{article.category ?? "—"}</td>
<td class="px-4 py-3 text-muted-foreground capitalize hidden sm:table-cell"
>{article.category ?? "—"}</td
>
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell">
{timeAgo.format(new Date(article.publish_date))}
</td>
@@ -108,7 +189,7 @@
</tr>
{/each}
{#if data.articles.length === 0}
{#if data.items.length === 0}
<tr>
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
{$_("admin.articles.no_results")}
@@ -118,6 +199,47 @@
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>

View File

@@ -1,7 +1,20 @@
import { adminListVideos } from "$lib/services";
export async function load({ fetch, cookies }) {
export async function load({ fetch, url, cookies }) {
const token = cookies.get("session_token") || "";
const videos = await adminListVideos(fetch, token).catch(() => []);
return { videos };
const search = url.searchParams.get("search") || undefined;
const featuredParam = url.searchParams.get("featured");
const premiumParam = url.searchParams.get("premium");
const featured = featuredParam !== null ? featuredParam === "true" : undefined;
const premium = premiumParam !== null ? premiumParam === "true" : undefined;
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
const limit = 50;
const result = await adminListVideos(
{ search, featured, premium, limit, offset },
fetch,
token,
).catch(() => ({ items: [], total: 0 }));
return { ...result, search, featured, premium, offset, limit };
}

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { deleteVideo } from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import type { Video } from "$lib/types";
@@ -14,6 +17,27 @@
let deleteTarget: Video | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
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("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function setFilter(key: string, value: string | null) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value !== null) params.set(key, value);
else params.delete(key);
params.delete("offset");
goto(`?${params.toString()}`);
}
function confirmDelete(video: Video) {
deleteTarget = video;
@@ -40,24 +64,78 @@
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
<Button href="/admin/videos/new" class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.videos.new_video")}
</Button>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
<Button
href="/admin/videos/new"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.videos.new_video")}
</Button>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<Input
placeholder={$_("admin.videos.search_placeholder")}
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
<div class="flex gap-1">
<Button
size="sm"
variant={data.featured === undefined ? "default" : "outline"}
onclick={() => setFilter("featured", null)}
>
{$_("admin.common.all")}
</Button>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", "true")}
>
{$_("admin.common.featured")}
</Button>
<Button
size="sm"
variant={data.premium === true ? "default" : "outline"}
onclick={() => setFilter("premium", data.premium === true ? null : "true")}
>
{$_("admin.common.premium")}
</Button>
</div>
</div>
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.videos.col_video")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.videos.col_badges")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.videos.col_plays")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.videos.col_likes")}</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.videos.col_video")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.videos.col_badges")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
>{$_("admin.videos.col_plays")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
>{$_("admin.videos.col_likes")}</th
>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.users.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.videos as video (video.id)}
{#each data.items as video (video.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
@@ -83,15 +161,23 @@
<td class="px-4 py-3 hidden sm:table-cell">
<div class="flex gap-1">
{#if video.premium}
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10">{$_("admin.common.premium")}</Badge>
<Badge
variant="outline"
class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
>{$_("admin.common.premium")}</Badge
>
{/if}
{#if video.featured}
<Badge variant="default">{$_("admin.common.featured")}</Badge>
{/if}
</div>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">{video.plays_count ?? 0}</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">{video.likes_count ?? 0}</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{video.plays_count ?? 0}</td
>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{video.likes_count ?? 0}</td
>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button size="sm" variant="ghost" href="/admin/videos/{video.id}">
@@ -110,14 +196,57 @@
</tr>
{/each}
{#if data.videos.length === 0}
{#if data.items.length === 0}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">{$_("admin.videos.no_results")}</td>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground"
>{$_("admin.videos.no_results")}</td
>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>

View File

@@ -1,6 +1,14 @@
import { getArticles } from "$lib/services";
export async function load({ fetch }) {
return {
articles: await getArticles(fetch),
};
const LIMIT = 24;
export async function load({ fetch, url }) {
const search = url.searchParams.get("search") || undefined;
const sort = url.searchParams.get("sort") || "recent";
const category = url.searchParams.get("category") || undefined;
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
const offset = (page - 1) * LIMIT;
const result = await getArticles({ search, sortBy: sort, category, offset, limit: LIMIT }, fetch);
return { ...result, search, sort, category, page, limit: LIMIT };
}

View File

@@ -1,48 +1,53 @@
<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";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import TimeAgo from "javascript-time-ago";
import type { Article } from "$lib/types";
import { getAssetUrl } from "$lib/api";
import { calcReadingTime } from "$lib/utils.js";
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
let categoryFilter = $state("all");
let sortBy = $state("recent");
const timeAgo = new TimeAgo("en");
const { data }: { data: { articles: Article[] } } = $props();
const { data } = $props();
const featuredArticle = data.articles.find((article) => article.featured);
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
const filteredArticles = $derived(() => {
return data.articles
.filter((article) => {
const matchesSearch =
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.author?.artist_name?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
if (sortBy === "recent")
return new Date(b.publish_date).getTime() - new Date(a.publish_date).getTime();
// if (sortBy === "popular")
// return (
// parseInt(b.views.replace(/[^\d]/g, "")) -
// parseInt(a.views.replace(/[^\d]/g, ""))
// );
if (sortBy === "featured") return (b.featured ? 1 : 0) - (a.featured ? 1 : 0);
return a.title.localeCompare(b.title);
});
});
const featuredArticle =
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null;
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 !== "all" && value !== "recent") 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={$_("magazine.title")} description={$_("magazine.description")} />
@@ -88,28 +93,36 @@
></span>
<Input
placeholder={$_("magazine.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}>
<Select
type="single"
value={data.category ?? "all"}
onValueChange={(v) => v && setParam("category", v)}
>
<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"
{!data.category
? $_("magazine.categories.all")
: categoryFilter === "photography"
: data.category === "photography"
? $_("magazine.categories.photography")
: categoryFilter === "production"
: data.category === "production"
? $_("magazine.categories.production")
: categoryFilter === "interview"
: data.category === "interview"
? $_("magazine.categories.interview")
: categoryFilter === "psychology"
: data.category === "psychology"
? $_("magazine.categories.psychology")
: categoryFilter === "trends"
: data.category === "trends"
? $_("magazine.categories.trends")
: $_("magazine.categories.spotlight")}
</SelectTrigger>
@@ -125,23 +138,18 @@
</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 === "recent"
? $_("magazine.sort.recent")
: sortBy === "popular"
? $_("magazine.sort.popular")
: sortBy === "featured"
? $_("magazine.sort.featured")
: $_("magazine.sort.name")}
{data.sort === "featured"
? $_("magazine.sort.featured")
: data.sort === "name"
? $_("magazine.sort.name")
: $_("magazine.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
<!-- <SelectItem value="popular"
>{$_("magazine.sort.popular")}</SelectItem
> -->
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
</SelectContent>
@@ -153,7 +161,7 @@
<div class="container mx-auto px-4 py-12">
<!-- Featured Article -->
{#if featuredArticle && categoryFilter === "all" && !searchQuery}
{#if featuredArticle}
<Card
class="py-0 mb-12 overflow-hidden bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-2xl shadow-primary/20"
>
@@ -220,7 +228,7 @@
<!-- Articles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each filteredArticles() as article (article.slug)}
{#each data.items as article (article.slug)}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 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"
>
@@ -318,22 +326,46 @@
{/each}
</div>
{#if filteredArticles().length === 0}
{#if data.items.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("magazine.no_results")}
</p>
<Button
variant="outline"
onclick={() => {
searchQuery = "";
categoryFilter = "all";
}}
class="border-primary/20 hover:bg-primary/10"
>
<Button variant="outline" href="/magazine" class="border-primary/20 hover:bg-primary/10">
{$_("magazine.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>

View File

@@ -1,6 +1,13 @@
import { getModels } from "$lib/services";
export async function load({ fetch }) {
return {
models: await getModels(fetch),
};
const LIMIT = 24;
export async function load({ fetch, url }) {
const search = url.searchParams.get("search") || undefined;
const sort = url.searchParams.get("sort") || "name";
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
const offset = (page - 1) * LIMIT;
const result = await getModels({ search, sortBy: sort, offset, limit: LIMIT }, fetch);
return { ...result, search, sort, page, limit: LIMIT };
}

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>

View File

@@ -1,22 +1,20 @@
import { error } from "@sveltejs/kit";
import { getItemsByTag } from "$lib/services";
const getItems = (category, tag: string, fetch) => {
return getItemsByTag(category, fetch).then((items) =>
items
?.filter((i) => i.tags?.includes(tag))
.map((i) => ({ ...i, category, title: i["artist_name"] || i["title"] })),
);
};
export async function load({ fetch, params }) {
try {
return {
tag: params.tag,
items: await Promise.all([
getItems("model", params.tag, fetch),
getItems("video", params.tag, fetch),
getItems("article", params.tag, fetch),
getItemsByTag("model", params.tag, fetch).then((items) =>
items?.map((i) => ({ ...i, category: "model", title: i["artist_name"] || i["title"] })),
),
getItemsByTag("video", params.tag, fetch).then((items) =>
items?.map((i) => ({ ...i, category: "video", title: i["artist_name"] || i["title"] })),
),
getItemsByTag("article", params.tag, fetch).then((items) =>
items?.map((i) => ({ ...i, category: "article", title: i["artist_name"] || i["title"] })),
),
]).then(([a, b, c]) => [...a, ...b, ...c]),
};
} catch {

View File

@@ -1,6 +1,14 @@
import { getVideos } from "$lib/services";
export async function load({ fetch }) {
return {
videos: await getVideos(fetch),
};
const LIMIT = 24;
export async function load({ fetch, url }) {
const search = url.searchParams.get("search") || undefined;
const sort = url.searchParams.get("sort") || "recent";
const duration = url.searchParams.get("duration") || "all";
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
const offset = (page - 1) * LIMIT;
const result = await getVideos({ search, sortBy: sort, duration, offset, limit: LIMIT }, fetch);
return { ...result, search, sort, duration, page, limit: LIMIT };
}

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";
@@ -10,40 +13,38 @@
import { formatVideoDuration } from "$lib/utils";
const timeAgo = new TimeAgo("en");
let searchQuery = $state("");
let sortBy = $state("recent");
let categoryFilter = $state("all");
let durationFilter = $state("all");
const { data } = $props();
const filteredVideos = $derived(() => {
return data.videos
.filter((video) => {
const matchesSearch = video.title.toLowerCase().includes(searchQuery.toLowerCase());
// ||
// video.model.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === "all";
const matchesDuration =
durationFilter === "all" ||
(durationFilter === "short" && (video.movie_file?.duration ?? 0) < 10 * 60) ||
(durationFilter === "medium" &&
(video.movie_file?.duration ?? 0) >= 10 * 60 &&
(video.movie_file?.duration ?? 0) < 20 * 60) ||
(durationFilter === "long" && (video.movie_file?.duration ?? 0) >= 20 * 60);
return matchesSearch && matchesCategory && matchesDuration;
})
.sort((a, b) => {
if (sortBy === "recent")
return new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime();
if (sortBy === "most_liked") return (b.likes_count || 0) - (a.likes_count || 0);
if (sortBy === "most_played") return (b.plays_count || 0) - (a.plays_count || 0);
if (sortBy === "duration")
return (b.movie_file?.duration ?? 0) - (a.movie_file?.duration ?? 0);
return a.title.localeCompare(b.title);
});
});
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 !== "all" && value !== "recent") 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={$_("videos.title")} description={$_("videos.description")} />
@@ -90,49 +91,32 @@
></span>
<Input
placeholder={$_("videos.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 lg: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"
? $_("videos.categories.all")
: categoryFilter === "romantic"
? $_("videos.categories.romantic")
: categoryFilter === "artistic"
? $_("videos.categories.artistic")
: categoryFilter === "intimate"
? $_("videos.categories.intimate")
: $_("videos.categories.performance")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("videos.categories.all")}</SelectItem>
<SelectItem value="romantic">{$_("videos.categories.romantic")}</SelectItem>
<SelectItem value="artistic">{$_("videos.categories.artistic")}</SelectItem>
<SelectItem value="intimate">{$_("videos.categories.intimate")}</SelectItem>
<SelectItem value="performance">{$_("videos.categories.performance")}</SelectItem>
</SelectContent>
</Select>
<!-- Duration Filter -->
<Select type="single" bind:value={durationFilter}>
<Select
type="single"
value={data.duration}
onValueChange={(v) => v && setParam("duration", v)}
>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
{durationFilter === "all"
? $_("videos.duration.all")
: durationFilter === "short"
? $_("videos.duration.short")
: durationFilter === "medium"
? $_("videos.duration.medium")
: $_("videos.duration.long")}
{data.duration === "short"
? $_("videos.duration.short")
: data.duration === "medium"
? $_("videos.duration.medium")
: data.duration === "long"
? $_("videos.duration.long")
: $_("videos.duration.all")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
@@ -143,25 +127,22 @@
</Select>
<!-- Sort -->
<Select type="single" bind:value={sortBy}>
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{sortBy === "recent"
? $_("videos.sort.recent")
: sortBy === "most_liked"
? $_("videos.sort.most_liked")
: sortBy === "most_played"
? $_("videos.sort.most_played")
: sortBy === "duration"
? $_("videos.sort.duration")
: $_("videos.sort.name")}
{data.sort === "most_liked"
? $_("videos.sort.most_liked")
: data.sort === "most_played"
? $_("videos.sort.most_played")
: data.sort === "name"
? $_("videos.sort.name")
: $_("videos.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
<SelectItem value="duration">{$_("videos.sort.duration")}</SelectItem>
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
</SelectContent>
</Select>
@@ -172,7 +153,7 @@
<!-- Videos 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 filteredVideos() as video (video.slug)}
{#each data.items as video (video.slug)}
<Card
class="p-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"
>
@@ -293,23 +274,46 @@
{/each}
</div>
{#if filteredVideos().length === 0}
{#if data.items.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("videos.no_results")}
</p>
<Button
variant="outline"
onclick={() => {
searchQuery = "";
categoryFilter = "all";
durationFilter = "all";
}}
class="border-primary/20 hover:bg-primary/10"
>
<Button variant="outline" href="/videos" class="border-primary/20 hover:bg-primary/10">
{$_("videos.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>