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,7 +1,7 @@
import { builder } from "../builder";
import { ArticleType } from "../types/index";
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
import { articles, users } from "../../db/schema/index";
import { eq, and, lte, desc } from "drizzle-orm";
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
async function enrichArticle(db: any, article: any) {
@@ -24,30 +24,54 @@ async function enrichArticle(db: any, article: any) {
builder.queryField("articles", (t) =>
t.field({
type: [ArticleType],
type: ArticleListType,
args: {
featured: t.arg.boolean(),
limit: t.arg.int(),
search: t.arg.string(),
category: t.arg.string(),
offset: t.arg.int(),
sortBy: t.arg.string(),
tag: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
const dateFilter = lte(articles.publish_date, new Date());
const whereCondition =
args.featured !== null && args.featured !== undefined
? and(dateFilter, eq(articles.featured, args.featured))
: dateFilter;
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
let query = ctx.db
.select()
.from(articles)
.where(whereCondition)
.orderBy(desc(articles.publish_date));
if (args.limit) {
query = (query as any).limit(args.limit);
const conditions: any[] = [lte(articles.publish_date, new Date())];
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(articles.featured, args.featured));
}
if (args.category) conditions.push(eq(articles.category, args.category));
if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag]));
if (args.search) {
conditions.push(
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
);
}
const articleList = await query;
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
const orderArgs =
args.sortBy === "name"
? [asc(articles.title)]
: args.sortBy === "featured"
? [desc(articles.featured), desc(articles.publish_date)]
: [desc(articles.publish_date)];
const where = and(...conditions);
const [articleList, totalRows] = await Promise.all([
(ctx.db.select().from(articles).where(where) as any)
.orderBy(...orderArgs)
.limit(pageSize)
.offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(
articleList.map((article: any) => enrichArticle(ctx.db, article)),
);
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);
@@ -76,11 +100,47 @@ builder.queryField("article", (t) =>
builder.queryField("adminListArticles", (t) =>
t.field({
type: [ArticleType],
resolve: async (_root, _args, ctx) => {
type: AdminArticleListType,
args: {
search: t.arg.string(),
category: t.arg.string(),
featured: t.arg.boolean(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
if (args.search) {
conditions.push(
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
);
}
if (args.category) conditions.push(eq(articles.category, args.category));
if (args.featured !== null && args.featured !== undefined)
conditions.push(eq(articles.featured, args.featured));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [articleList, totalRows] = await Promise.all([
ctx.db
.select()
.from(articles)
.where(where)
.orderBy(desc(articles.publish_date))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(
articleList.map((article: any) => enrichArticle(ctx.db, article)),
);
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);

View File

@@ -1,7 +1,7 @@
import { builder } from "../builder";
import { ModelType } from "../types/index";
import { ModelType, ModelListType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, and, desc } from "drizzle-orm";
import { eq, and, desc, asc, ilike, count, arrayContains } from "drizzle-orm";
async function enrichModel(db: any, user: any) {
// Fetch photos
@@ -20,24 +20,32 @@ async function enrichModel(db: any, user: any) {
builder.queryField("models", (t) =>
t.field({
type: [ModelType],
type: ModelListType,
args: {
featured: t.arg.boolean(),
limit: t.arg.int(),
search: t.arg.string(),
offset: t.arg.int(),
sortBy: t.arg.string(),
tag: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
let query = ctx.db
.select()
.from(users)
.where(eq(users.role, "model"))
.orderBy(desc(users.date_created));
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
if (args.limit) {
query = (query as any).limit(args.limit);
}
const conditions: any[] = [eq(users.role, "model")];
if (args.search) conditions.push(ilike(users.artist_name, `%${args.search}%`));
if (args.tag) conditions.push(arrayContains(users.tags, [args.tag]));
const modelList = await query;
return Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
const order = args.sortBy === "recent" ? desc(users.date_created) : asc(users.artist_name);
const where = and(...conditions);
const [modelList, totalRows] = await Promise.all([
ctx.db.select().from(users).where(where).orderBy(order).limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(users).where(where),
]);
const items = await Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);

View File

@@ -2,6 +2,8 @@ import { GraphQLError } from "graphql";
import { builder } from "../builder";
import {
VideoType,
VideoListType,
AdminVideoListType,
VideoLikeResponseType,
VideoPlayResponseType,
VideoLikeStatusType,
@@ -14,7 +16,19 @@ import {
users,
files,
} from "../../db/schema/index";
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
import {
eq,
and,
lte,
desc,
asc,
inArray,
count,
ilike,
lt,
gte,
arrayContains,
} from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
async function enrichVideo(db: any, video: any) {
@@ -58,67 +72,93 @@ async function enrichVideo(db: any, video: any) {
builder.queryField("videos", (t) =>
t.field({
type: [VideoType],
type: VideoListType,
args: {
modelId: t.arg.string(),
featured: t.arg.boolean(),
limit: t.arg.int(),
search: t.arg.string(),
offset: t.arg.int(),
sortBy: t.arg.string(),
duration: t.arg.string(),
tag: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
// Unauthenticated users cannot see premium videos
const premiumFilter = !ctx.currentUser ? eq(videos.premium, false) : undefined;
let query = ctx.db
.select({ v: videos })
.from(videos)
.where(and(lte(videos.upload_date, new Date()), premiumFilter))
.orderBy(desc(videos.upload_date));
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [lte(videos.upload_date, new Date())];
if (!ctx.currentUser) conditions.push(eq(videos.premium, false));
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(videos.featured, args.featured));
}
if (args.search) {
conditions.push(ilike(videos.title, `%${args.search}%`));
}
if (args.tag) {
conditions.push(arrayContains(videos.tags, [args.tag]));
}
if (args.modelId) {
const videoIds = await ctx.db
.select({ video_id: video_models.video_id })
.from(video_models)
.where(eq(video_models.user_id, args.modelId));
if (videoIds.length === 0) return [];
query = ctx.db
.select({ v: videos })
.from(videos)
.where(
and(
lte(videos.upload_date, new Date()),
premiumFilter,
inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
),
),
)
.orderBy(desc(videos.upload_date));
if (videoIds.length === 0) return { items: [], total: 0 };
conditions.push(
inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
),
);
}
if (args.featured !== null && args.featured !== undefined) {
query = ctx.db
.select({ v: videos })
.from(videos)
.where(
and(
lte(videos.upload_date, new Date()),
premiumFilter,
eq(videos.featured, args.featured),
),
)
.orderBy(desc(videos.upload_date));
const order =
args.sortBy === "most_liked"
? desc(videos.likes_count)
: args.sortBy === "most_played"
? desc(videos.plays_count)
: args.sortBy === "name"
? asc(videos.title)
: desc(videos.upload_date);
const where = and(...conditions);
// Duration filter requires JOIN to files table
if (args.duration && args.duration !== "all") {
const durationCond =
args.duration === "short"
? lt(files.duration, 600)
: args.duration === "medium"
? and(gte(files.duration, 600), lt(files.duration, 1200))
: gte(files.duration, 1200);
const fullWhere = and(where, durationCond);
const [rows, totalRows] = await Promise.all([
ctx.db
.select({ v: videos })
.from(videos)
.leftJoin(files, eq(videos.movie, files.id))
.where(fullWhere)
.orderBy(order)
.limit(pageSize)
.offset(offset),
ctx.db
.select({ total: count() })
.from(videos)
.leftJoin(files, eq(videos.movie, files.id))
.where(fullWhere),
]);
const videoList = rows.map((r: any) => r.v || r);
const items = await Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
}
if (args.limit) {
query = (query as any).limit(args.limit);
}
const rows = await query;
const videoList = rows.map((r: any) => r.v || r);
return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
const [rows, totalRows] = await Promise.all([
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);
@@ -430,11 +470,39 @@ builder.queryField("analytics", (t) =>
builder.queryField("adminListVideos", (t) =>
t.field({
type: [VideoType],
resolve: async (_root, _args, ctx) => {
type: AdminVideoListType,
args: {
search: t.arg.string(),
premium: t.arg.boolean(),
featured: t.arg.boolean(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
if (args.search) conditions.push(ilike(videos.title, `%${args.search}%`));
if (args.premium !== null && args.premium !== undefined)
conditions.push(eq(videos.premium, args.premium));
if (args.featured !== null && args.featured !== undefined)
conditions.push(eq(videos.featured, args.featured));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totalRows] = await Promise.all([
ctx.db
.select()
.from(videos)
.where(where)
.orderBy(desc(videos.upload_date))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);

View File

@@ -329,6 +329,51 @@ export const AchievementType = builder.objectRef<Achievement>("Achievement").imp
}),
});
export const VideoListType = builder
.objectRef<{ items: Video[]; total: number }>("VideoList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [VideoType] }),
total: t.exposeInt("total"),
}),
});
export const ArticleListType = builder
.objectRef<{ items: Article[]; total: number }>("ArticleList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [ArticleType] }),
total: t.exposeInt("total"),
}),
});
export const ModelListType = builder
.objectRef<{ items: Model[]; total: number }>("ModelList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [ModelType] }),
total: t.exposeInt("total"),
}),
});
export const AdminVideoListType = builder
.objectRef<{ items: Video[]; total: number }>("AdminVideoList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [VideoType] }),
total: t.exposeInt("total"),
}),
});
export const AdminArticleListType = builder
.objectRef<{ items: Article[]; total: number }>("AdminArticleList")
.implement({
fields: (t) => ({
items: t.expose("items", { type: [ArticleType] }),
total: t.exposeInt("total"),
}),
});
export const AdminUserListType = builder
.objectRef<{ items: User[]; total: number }>("AdminUserList")
.implement({
@@ -338,24 +383,22 @@ export const AdminUserListType = builder
}),
});
export const AdminUserDetailType = builder
.objectRef<AdminUserDetail>("AdminUserDetail")
.implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType] }),
}),
});
export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUserDetail").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),
first_name: t.exposeString("first_name", { nullable: true }),
last_name: t.exposeString("last_name", { nullable: true }),
artist_name: t.exposeString("artist_name", { nullable: true }),
slug: t.exposeString("slug", { nullable: true }),
description: t.exposeString("description", { nullable: true }),
tags: t.exposeStringList("tags", { nullable: true }),
role: t.exposeString("role"),
is_admin: t.exposeBoolean("is_admin"),
avatar: t.exposeString("avatar", { nullable: true }),
banner: t.exposeString("banner", { nullable: true }),
email_verified: t.exposeBoolean("email_verified"),
date_created: t.expose("date_created", { type: "DateTime" }),
photos: t.expose("photos", { type: [ModelPhotoType] }),
}),
});