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