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:
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user