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

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