From 9c5dba5c90570dda24009db8d1f32dcf742dd3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 7 Mar 2026 10:43:26 +0100 Subject: [PATCH] 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 --- .../backend/src/graphql/resolvers/articles.ts | 104 +++-- .../backend/src/graphql/resolvers/models.ts | 34 +- .../backend/src/graphql/resolvers/videos.ts | 168 +++++--- packages/backend/src/graphql/types/index.ts | 85 +++- packages/frontend/src/lib/i18n/locales/en.ts | 10 +- packages/frontend/src/lib/services.ts | 371 ++++++++++++------ .../src/routes/admin/articles/+page.server.ts | 18 +- .../src/routes/admin/articles/+page.svelte | 142 ++++++- .../src/routes/admin/videos/+page.server.ts | 19 +- .../src/routes/admin/videos/+page.svelte | 159 +++++++- .../src/routes/magazine/+page.server.ts | 16 +- .../frontend/src/routes/magazine/+page.svelte | 154 +++++--- .../src/routes/models/+page.server.ts | 15 +- .../frontend/src/routes/models/+page.svelte | 144 +++---- .../src/routes/tags/[tag]/+page.server.ts | 20 +- .../src/routes/videos/+page.server.ts | 16 +- .../frontend/src/routes/videos/+page.svelte | 180 ++++----- 17 files changed, 1159 insertions(+), 496 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/articles.ts b/packages/backend/src/graphql/resolvers/articles.ts index 044f44f..41cb1b6 100644 --- a/packages/backend/src/graphql/resolvers/articles.ts +++ b/packages/backend/src/graphql/resolvers/articles.ts @@ -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 }; }, }), ); diff --git a/packages/backend/src/graphql/resolvers/models.ts b/packages/backend/src/graphql/resolvers/models.ts index c54f3a6..f694370 100644 --- a/packages/backend/src/graphql/resolvers/models.ts +++ b/packages/backend/src/graphql/resolvers/models.ts @@ -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 }; }, }), ); diff --git a/packages/backend/src/graphql/resolvers/videos.ts b/packages/backend/src/graphql/resolvers/videos.ts index 0db2eeb..9e31b0b 100644 --- a/packages/backend/src/graphql/resolvers/videos.ts +++ b/packages/backend/src/graphql/resolvers/videos.ts @@ -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 }; }, }), ); diff --git a/packages/backend/src/graphql/types/index.ts b/packages/backend/src/graphql/types/index.ts index 75a6dba..4ec566b 100644 --- a/packages/backend/src/graphql/types/index.ts +++ b/packages/backend/src/graphql/types/index.ts @@ -329,6 +329,51 @@ export const AchievementType = builder.objectRef("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") - .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").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] }), + }), +}); diff --git a/packages/frontend/src/lib/i18n/locales/en.ts b/packages/frontend/src/lib/i18n/locales/en.ts index c570838..863e99b 100644 --- a/packages/frontend/src/lib/i18n/locales/en.ts +++ b/packages/frontend/src/lib/i18n/locales/en.ts @@ -23,6 +23,8 @@ export default { my_profile: "My Profile", anonymous: "Anonymous", load_more: "Load More", + page_of: "Page {page} of {total}", + total_results: "{total} results", }, header: { home: "Home", @@ -251,6 +253,7 @@ export default { rating: "Highest Rated", videos: "Most Videos", name: "A-Z", + recent: "Newest", }, online: "Online", followers: "followers", @@ -913,6 +916,7 @@ export default { saving: "Saving…", creating: "Creating…", deleting: "Deleting…", + all: "All", featured: "Featured", premium: "Premium", write: "Write", @@ -944,7 +948,8 @@ export default { role_updated: "Role updated to {role}", role_update_failed: "Failed to update role", delete_title: "Delete user", - delete_description: "Are you sure you want to permanently delete {name}? This cannot be undone.", + delete_description: + "Are you sure you want to permanently delete {name}? This cannot be undone.", delete_success: "User deleted", delete_error: "Failed to delete user", }, @@ -971,6 +976,7 @@ export default { videos: { title: "Videos", new_video: "New video", + search_placeholder: "Search videos...", col_video: "Video", col_badges: "Badges", col_plays: "Plays", @@ -1005,6 +1011,8 @@ export default { articles: { title: "Articles", new_article: "New article", + search_placeholder: "Search articles...", + filter_all_categories: "All categories", col_article: "Article", col_category: "Category", col_published: "Published", diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 8832183..4082c69 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -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; }); diff --git a/packages/frontend/src/routes/admin/articles/+page.server.ts b/packages/frontend/src/routes/admin/articles/+page.server.ts index d944816..d0b1e1d 100644 --- a/packages/frontend/src/routes/admin/articles/+page.server.ts +++ b/packages/frontend/src/routes/admin/articles/+page.server.ts @@ -1,7 +1,19 @@ import { adminListArticles } from "$lib/services"; -export async function load({ fetch, cookies }) { +export async function load({ fetch, url, cookies }) { const token = cookies.get("session_token") || ""; - const articles = await adminListArticles(fetch, token).catch(() => []); - return { articles }; + const search = url.searchParams.get("search") || undefined; + const category = url.searchParams.get("category") || undefined; + const featuredParam = url.searchParams.get("featured"); + const featured = featuredParam !== null ? featuredParam === "true" : undefined; + const offset = parseInt(url.searchParams.get("offset") || "0", 10); + const limit = 50; + + const result = await adminListArticles( + { search, category, featured, limit, offset }, + fetch, + token, + ).catch(() => ({ items: [], total: 0 })); + + return { ...result, search, category, featured, offset, limit }; } diff --git a/packages/frontend/src/routes/admin/articles/+page.svelte b/packages/frontend/src/routes/admin/articles/+page.svelte index 2d0fc11..3696ba8 100644 --- a/packages/frontend/src/routes/admin/articles/+page.svelte +++ b/packages/frontend/src/routes/admin/articles/+page.svelte @@ -1,10 +1,14 @@ @@ -88,28 +93,36 @@ > { + searchValue = (e.target as HTMLInputElement).value; + debounceSearch(searchValue); + }} class="pl-10 bg-background/50 border-primary/20 focus:border-primary" /> - v && setParam("category", v)} + > - {categoryFilter === "all" + {!data.category ? $_("magazine.categories.all") - : categoryFilter === "photography" + : data.category === "photography" ? $_("magazine.categories.photography") - : categoryFilter === "production" + : data.category === "production" ? $_("magazine.categories.production") - : categoryFilter === "interview" + : data.category === "interview" ? $_("magazine.categories.interview") - : categoryFilter === "psychology" + : data.category === "psychology" ? $_("magazine.categories.psychology") - : categoryFilter === "trends" + : data.category === "trends" ? $_("magazine.categories.trends") : $_("magazine.categories.spotlight")} @@ -125,23 +138,18 @@ - v && setParam("sort", v)}> - {sortBy === "recent" - ? $_("magazine.sort.recent") - : sortBy === "popular" - ? $_("magazine.sort.popular") - : sortBy === "featured" - ? $_("magazine.sort.featured") - : $_("magazine.sort.name")} + {data.sort === "featured" + ? $_("magazine.sort.featured") + : data.sort === "name" + ? $_("magazine.sort.name") + : $_("magazine.sort.recent")} {$_("magazine.sort.recent")} - {$_("magazine.sort.featured")} {$_("magazine.sort.name")} @@ -153,7 +161,7 @@
- {#if featuredArticle && categoryFilter === "all" && !searchQuery} + {#if featuredArticle} @@ -220,7 +228,7 @@
- {#each filteredArticles() as article (article.slug)} + {#each data.items as article (article.slug)} @@ -318,22 +326,46 @@ {/each}
- {#if filteredArticles().length === 0} + {#if data.items.length === 0}

{$_("magazine.no_results")}

-
{/if} + + + {#if totalPages > 1} +
+ + {$_("common.page_of", { values: { page: data.page, total: totalPages } })} +  ·  + {$_("common.total_results", { values: { total: data.total } })} + +
+ + +
+
+ {/if}
diff --git a/packages/frontend/src/routes/models/+page.server.ts b/packages/frontend/src/routes/models/+page.server.ts index a605893..28215f0 100644 --- a/packages/frontend/src/routes/models/+page.server.ts +++ b/packages/frontend/src/routes/models/+page.server.ts @@ -1,6 +1,13 @@ import { getModels } from "$lib/services"; -export async function load({ fetch }) { - return { - models: await getModels(fetch), - }; + +const LIMIT = 24; + +export async function load({ fetch, url }) { + const search = url.searchParams.get("search") || undefined; + const sort = url.searchParams.get("sort") || "name"; + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10)); + const offset = (page - 1) * LIMIT; + + const result = await getModels({ search, sortBy: sort, offset, limit: LIMIT }, fetch); + return { ...result, search, sort, page, limit: LIMIT }; } diff --git a/packages/frontend/src/routes/models/+page.svelte b/packages/frontend/src/routes/models/+page.svelte index 8a9938c..9ca18f0 100644 --- a/packages/frontend/src/routes/models/+page.svelte +++ b/packages/frontend/src/routes/models/+page.svelte @@ -1,5 +1,8 @@ @@ -76,51 +84,25 @@ > { + searchValue = (e.target as HTMLInputElement).value; + debounceSearch(searchValue); + }} class="pl-10 bg-background/50 border-primary/20 focus:border-primary" /> - - - - v && setParam("sort", v)}> - {sortBy === "popular" - ? $_("models.sort.popular") - : sortBy === "rating" - ? $_("models.sort.rating") - : sortBy === "videos" - ? $_("models.sort.videos") - : $_("models.sort.name")} + {data.sort === "recent" ? $_("models.sort.recent") : $_("models.sort.name")} - {$_("models.sort.popular")} - {$_("models.sort.rating")} - {$_("models.sort.videos")} {$_("models.sort.name")} + {$_("models.sort.recent")} @@ -130,7 +112,7 @@
- {#each filteredModels() as model (model.slug)} + {#each data.items as model (model.slug)} @@ -227,20 +209,44 @@ {/each}
- {#if filteredModels().length === 0} + {#if data.items.length === 0}

{$_("models.no_results")}

-
{/if} + + + {#if totalPages > 1} +
+ + {$_("common.page_of", { values: { page: data.page, total: totalPages } })} +  ·  + {$_("common.total_results", { values: { total: data.total } })} + +
+ + +
+
+ {/if}
diff --git a/packages/frontend/src/routes/tags/[tag]/+page.server.ts b/packages/frontend/src/routes/tags/[tag]/+page.server.ts index cf21395..2dad110 100644 --- a/packages/frontend/src/routes/tags/[tag]/+page.server.ts +++ b/packages/frontend/src/routes/tags/[tag]/+page.server.ts @@ -1,22 +1,20 @@ import { error } from "@sveltejs/kit"; import { getItemsByTag } from "$lib/services"; -const getItems = (category, tag: string, fetch) => { - return getItemsByTag(category, fetch).then((items) => - items - ?.filter((i) => i.tags?.includes(tag)) - .map((i) => ({ ...i, category, title: i["artist_name"] || i["title"] })), - ); -}; - export async function load({ fetch, params }) { try { return { tag: params.tag, items: await Promise.all([ - getItems("model", params.tag, fetch), - getItems("video", params.tag, fetch), - getItems("article", params.tag, fetch), + getItemsByTag("model", params.tag, fetch).then((items) => + items?.map((i) => ({ ...i, category: "model", title: i["artist_name"] || i["title"] })), + ), + getItemsByTag("video", params.tag, fetch).then((items) => + items?.map((i) => ({ ...i, category: "video", title: i["artist_name"] || i["title"] })), + ), + getItemsByTag("article", params.tag, fetch).then((items) => + items?.map((i) => ({ ...i, category: "article", title: i["artist_name"] || i["title"] })), + ), ]).then(([a, b, c]) => [...a, ...b, ...c]), }; } catch { diff --git a/packages/frontend/src/routes/videos/+page.server.ts b/packages/frontend/src/routes/videos/+page.server.ts index 58d90e0..e2f8ed6 100644 --- a/packages/frontend/src/routes/videos/+page.server.ts +++ b/packages/frontend/src/routes/videos/+page.server.ts @@ -1,6 +1,14 @@ import { getVideos } from "$lib/services"; -export async function load({ fetch }) { - return { - videos: await getVideos(fetch), - }; + +const LIMIT = 24; + +export async function load({ fetch, url }) { + const search = url.searchParams.get("search") || undefined; + const sort = url.searchParams.get("sort") || "recent"; + const duration = url.searchParams.get("duration") || "all"; + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10)); + const offset = (page - 1) * LIMIT; + + const result = await getVideos({ search, sortBy: sort, duration, offset, limit: LIMIT }, fetch); + return { ...result, search, sort, duration, page, limit: LIMIT }; } diff --git a/packages/frontend/src/routes/videos/+page.svelte b/packages/frontend/src/routes/videos/+page.svelte index ea8eb0a..1c609ea 100644 --- a/packages/frontend/src/routes/videos/+page.svelte +++ b/packages/frontend/src/routes/videos/+page.svelte @@ -1,5 +1,8 @@ @@ -90,49 +91,32 @@ > { + searchValue = (e.target as HTMLInputElement).value; + debounceSearch(searchValue); + }} class="pl-10 bg-background/50 border-primary/20 focus:border-primary" /> - - - - v && setParam("duration", v)} + > - {durationFilter === "all" - ? $_("videos.duration.all") - : durationFilter === "short" - ? $_("videos.duration.short") - : durationFilter === "medium" - ? $_("videos.duration.medium") - : $_("videos.duration.long")} + {data.duration === "short" + ? $_("videos.duration.short") + : data.duration === "medium" + ? $_("videos.duration.medium") + : data.duration === "long" + ? $_("videos.duration.long") + : $_("videos.duration.all")} {$_("videos.duration.all")} @@ -143,25 +127,22 @@ - v && setParam("sort", v)}> - {sortBy === "recent" - ? $_("videos.sort.recent") - : sortBy === "most_liked" - ? $_("videos.sort.most_liked") - : sortBy === "most_played" - ? $_("videos.sort.most_played") - : sortBy === "duration" - ? $_("videos.sort.duration") - : $_("videos.sort.name")} + {data.sort === "most_liked" + ? $_("videos.sort.most_liked") + : data.sort === "most_played" + ? $_("videos.sort.most_played") + : data.sort === "name" + ? $_("videos.sort.name") + : $_("videos.sort.recent")} {$_("videos.sort.recent")} {$_("videos.sort.most_liked")} {$_("videos.sort.most_played")} - {$_("videos.sort.duration")} {$_("videos.sort.name")} @@ -172,7 +153,7 @@
- {#each filteredVideos() as video (video.slug)} + {#each data.items as video (video.slug)} @@ -293,23 +274,46 @@ {/each}
- {#if filteredVideos().length === 0} + {#if data.items.length === 0}

{$_("videos.no_results")}

-
{/if} + + + {#if totalPages > 1} +
+ + {$_("common.page_of", { values: { page: data.page, total: totalPages } })} +  ·  + {$_("common.total_results", { values: { total: data.total } })} + +
+ + +
+
+ {/if}