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

@@ -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;
});