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,
if (videoIds.length === 0) return { items: [], total: 0 };
conditions.push(
inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
),
),
)
.orderBy(desc(videos.upload_date));
);
}
if (args.featured !== null && args.featured !== undefined) {
query = ctx.db
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)
.where(
and(
lte(videos.upload_date, new Date()),
premiumFilter,
eq(videos.featured, args.featured),
),
)
.orderBy(desc(videos.upload_date));
}
if (args.limit) {
query = (query as any).limit(args.limit);
}
const rows = await query;
.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);
return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
const items = await Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
}
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,9 +383,7 @@ export const AdminUserListType = builder
}),
});
export const AdminUserDetailType = builder
.objectRef<AdminUserDetail>("AdminUserDetail")
.implement({
export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUserDetail").implement({
fields: (t) => ({
id: t.exposeString("id"),
email: t.exposeString("email"),

View File

@@ -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",

View File

@@ -216,8 +216,25 @@ export async function resetPassword(token: string, password: string) {
// ─── Articles ────────────────────────────────────────────────────────────────
const ARTICLES_QUERY = gql`
query GetArticles {
articles {
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
slug
title
@@ -235,12 +252,27 @@ const ARTICLES_QUERY = gql`
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,8 +318,27 @@ 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) {
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
slug
title
@@ -313,12 +364,26 @@ const VIDEOS_QUERY = gql`
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,8 +466,23 @@ 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) {
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
slug
artist_name
@@ -417,12 +496,19 @@ const MODELS_QUERY = gql`
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,8 +1273,21 @@ export async function adminRemoveUserPhoto(userId: string, fileId: string) {
// ─── Admin: Videos ────────────────────────────────────────────────────────────
const ADMIN_LIST_VIDEOS_QUERY = gql`
query AdminListVideos {
adminListVideos {
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
slug
title
@@ -1215,14 +1313,27 @@ const ADMIN_LIST_VIDEOS_QUERY = gql`
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,8 +1485,21 @@ export async function setVideoModels(videoId: string, userIds: string[]) {
// ─── Admin: Articles ──────────────────────────────────────────────────────────
const ADMIN_LIST_ARTICLES_QUERY = gql`
query AdminListArticles {
adminListArticles {
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
slug
title
@@ -1393,14 +1517,27 @@ const ADMIN_LIST_ARTICLES_QUERY = gql`
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;
});

View File

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

View File

@@ -1,10 +1,14 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { deleteArticle } from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import * as Dialog from "$lib/components/ui/dialog";
import type { Article } from "$lib/types";
import TimeAgo from "javascript-time-ago";
@@ -16,6 +20,27 @@
let deleteTarget: Article | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function setFilter(key: string, value: string | null) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value !== null) params.set(key, value);
else params.delete(key);
params.delete("offset");
goto(`?${params.toString()}`);
}
function confirmDelete(article: Article) {
deleteTarget = article;
@@ -42,23 +67,77 @@
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
<Button href="/admin/articles/new" class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
<Button
href="/admin/articles/new"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.articles.new_article")}
</Button>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<Input
placeholder={$_("admin.articles.search_placeholder")}
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
<Select
type="single"
value={data.category ?? "all"}
onValueChange={(v) => setFilter("category", v === "all" ? null : (v ?? null))}
>
<SelectTrigger class="w-40 h-9 text-sm">
{data.category ?? $_("admin.articles.filter_all_categories")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("admin.articles.filter_all_categories")}</SelectItem>
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", data.featured === true ? null : "true")}
>
{$_("admin.common.featured")}
</Button>
</div>
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.articles.col_article")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.articles.col_category")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.articles.col_published")}</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.articles.col_article")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.articles.col_category")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.articles.col_published")}</th
>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.users.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.articles as article (article.id)}
{#each data.items as article (article.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
@@ -86,7 +165,9 @@
</div>
</div>
</td>
<td class="px-4 py-3 text-muted-foreground capitalize hidden sm:table-cell">{article.category ?? "—"}</td>
<td class="px-4 py-3 text-muted-foreground capitalize hidden sm:table-cell"
>{article.category ?? "—"}</td
>
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell">
{timeAgo.format(new Date(article.publish_date))}
</td>
@@ -108,7 +189,7 @@
</tr>
{/each}
{#if data.articles.length === 0}
{#if data.items.length === 0}
<tr>
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
{$_("admin.articles.no_results")}
@@ -118,6 +199,47 @@
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>

View File

@@ -1,7 +1,20 @@
import { adminListVideos } from "$lib/services";
export async function load({ fetch, cookies }) {
export async function load({ fetch, url, cookies }) {
const token = cookies.get("session_token") || "";
const videos = await adminListVideos(fetch, token).catch(() => []);
return { videos };
const search = url.searchParams.get("search") || undefined;
const featuredParam = url.searchParams.get("featured");
const premiumParam = url.searchParams.get("premium");
const featured = featuredParam !== null ? featuredParam === "true" : undefined;
const premium = premiumParam !== null ? premiumParam === "true" : undefined;
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
const limit = 50;
const result = await adminListVideos(
{ search, featured, premium, limit, offset },
fetch,
token,
).catch(() => ({ items: [], total: 0 }));
return { ...result, search, featured, premium, offset, limit };
}

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { deleteVideo } from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import type { Video } from "$lib/types";
@@ -14,6 +17,27 @@
let deleteTarget: Video | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function setFilter(key: string, value: string | null) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value !== null) params.set(key, value);
else params.delete(key);
params.delete("offset");
goto(`?${params.toString()}`);
}
function confirmDelete(video: Video) {
deleteTarget = video;
@@ -40,24 +64,78 @@
<div class="py-3 sm:py-6 sm:pl-6">
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
<Button href="/admin/videos/new" class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"
>{$_("admin.users.total", { values: { total: data.total } })}</span
>
<Button
href="/admin/videos/new"
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.videos.new_video")}
</Button>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
<Input
placeholder={$_("admin.videos.search_placeholder")}
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
<div class="flex gap-1">
<Button
size="sm"
variant={data.featured === undefined ? "default" : "outline"}
onclick={() => setFilter("featured", null)}
>
{$_("admin.common.all")}
</Button>
<Button
size="sm"
variant={data.featured === true ? "default" : "outline"}
onclick={() => setFilter("featured", "true")}
>
{$_("admin.common.featured")}
</Button>
<Button
size="sm"
variant={data.premium === true ? "default" : "outline"}
onclick={() => setFilter("premium", data.premium === true ? null : "true")}
>
{$_("admin.common.premium")}
</Button>
</div>
</div>
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.videos.col_video")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.videos.col_badges")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.videos.col_plays")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.videos.col_likes")}</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
>{$_("admin.videos.col_video")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
>{$_("admin.videos.col_badges")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
>{$_("admin.videos.col_plays")}</th
>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
>{$_("admin.videos.col_likes")}</th
>
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
>{$_("admin.users.col_actions")}</th
>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.videos as video (video.id)}
{#each data.items as video (video.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
@@ -83,15 +161,23 @@
<td class="px-4 py-3 hidden sm:table-cell">
<div class="flex gap-1">
{#if video.premium}
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10">{$_("admin.common.premium")}</Badge>
<Badge
variant="outline"
class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
>{$_("admin.common.premium")}</Badge
>
{/if}
{#if video.featured}
<Badge variant="default">{$_("admin.common.featured")}</Badge>
{/if}
</div>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">{video.plays_count ?? 0}</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">{video.likes_count ?? 0}</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{video.plays_count ?? 0}</td
>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{video.likes_count ?? 0}</td
>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button size="sm" variant="ghost" href="/admin/videos/{video.id}">
@@ -110,14 +196,57 @@
</tr>
{/each}
{#if data.videos.length === 0}
{#if data.items.length === 0}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">{$_("admin.videos.no_results")}</td>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground"
>{$_("admin.videos.no_results")}</td
>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
</div>
<Dialog.Root bind:open={deleteOpen}>

View File

@@ -1,6 +1,14 @@
import { getArticles } from "$lib/services";
export async function load({ fetch }) {
return {
articles: await getArticles(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 category = url.searchParams.get("category") || undefined;
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
const offset = (page - 1) * LIMIT;
const result = await getArticles({ search, sortBy: sort, category, offset, limit: LIMIT }, fetch);
return { ...result, search, sort, category, page, limit: LIMIT };
}

View File

@@ -1,48 +1,53 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import TimeAgo from "javascript-time-ago";
import type { Article } from "$lib/types";
import { getAssetUrl } from "$lib/api";
import { calcReadingTime } from "$lib/utils.js";
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
let categoryFilter = $state("all");
let sortBy = $state("recent");
const timeAgo = new TimeAgo("en");
const { data }: { data: { articles: Article[] } } = $props();
const { data } = $props();
const featuredArticle = data.articles.find((article) => article.featured);
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
const filteredArticles = $derived(() => {
return data.articles
.filter((article) => {
const matchesSearch =
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.author?.artist_name?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
if (sortBy === "recent")
return new Date(b.publish_date).getTime() - new Date(a.publish_date).getTime();
// if (sortBy === "popular")
// return (
// parseInt(b.views.replace(/[^\d]/g, "")) -
// parseInt(a.views.replace(/[^\d]/g, ""))
// );
if (sortBy === "featured") return (b.featured ? 1 : 0) - (a.featured ? 1 : 0);
return a.title.localeCompare(b.title);
});
});
const featuredArticle =
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("page");
goto(`?${params.toString()}`, { keepFocus: true });
}, 400);
}
function setParam(key: string, value: string) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value && value !== "all" && value !== "recent") params.set(key, value);
else params.delete(key);
params.delete("page");
goto(`?${params.toString()}`);
}
function goToPage(p: number) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (p > 1) params.set("page", String(p));
else params.delete("page");
goto(`?${params.toString()}`);
}
const totalPages = $derived(Math.ceil(data.total / data.limit));
</script>
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
@@ -88,28 +93,36 @@
></span>
<Input
placeholder={$_("magazine.search_placeholder")}
bind:value={searchQuery}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Category Filter -->
<Select type="single" bind:value={categoryFilter}>
<Select
type="single"
value={data.category ?? "all"}
onValueChange={(v) => v && setParam("category", v)}
>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
{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")}
</SelectTrigger>
@@ -125,23 +138,18 @@
</Select>
<!-- Sort -->
<Select type="single" bind:value={sortBy}>
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{sortBy === "recent"
? $_("magazine.sort.recent")
: sortBy === "popular"
? $_("magazine.sort.popular")
: sortBy === "featured"
{data.sort === "featured"
? $_("magazine.sort.featured")
: $_("magazine.sort.name")}
: data.sort === "name"
? $_("magazine.sort.name")
: $_("magazine.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
<!-- <SelectItem value="popular"
>{$_("magazine.sort.popular")}</SelectItem
> -->
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
</SelectContent>
@@ -153,7 +161,7 @@
<div class="container mx-auto px-4 py-12">
<!-- Featured Article -->
{#if featuredArticle && categoryFilter === "all" && !searchQuery}
{#if featuredArticle}
<Card
class="py-0 mb-12 overflow-hidden bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-2xl shadow-primary/20"
>
@@ -220,7 +228,7 @@
<!-- Articles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each filteredArticles() as article (article.slug)}
{#each data.items as article (article.slug)}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
>
@@ -318,22 +326,46 @@
{/each}
</div>
{#if filteredArticles().length === 0}
{#if data.items.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("magazine.no_results")}
</p>
<Button
variant="outline"
onclick={() => {
searchQuery = "";
categoryFilter = "all";
}}
class="border-primary/20 hover:bg-primary/10"
>
<Button variant="outline" href="/magazine" class="border-primary/20 hover:bg-primary/10">
{$_("magazine.clear_filters")}
</Button>
</div>
{/if}
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-10">
<span class="text-sm text-muted-foreground">
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
&nbsp;·&nbsp;
{$_("common.total_results", { values: { total: data.total } })}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.previous")}
</Button>
<Button
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
</div>
</div>

View File

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

View File

@@ -1,5 +1,8 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
@@ -7,33 +10,38 @@
import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
let sortBy = $state("popular");
let categoryFilter = $state("all");
const { data } = $props();
const filteredModels = $derived(() => {
return data.models
.filter((model) => {
const matchesSearch =
searchQuery === "" ||
model.artist_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesCategory = categoryFilter === "all";
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
// if (sortBy === "popular") {
// const aNum = parseInt(a.subscribers.replace(/[^\d]/g, ""));
// const bNum = parseInt(b.subscribers.replace(/[^\d]/g, ""));
// return bNum - aNum;
// }
// if (sortBy === "rating") return b.rating - a.rating;
// if (sortBy === "videos") return b.videos - a.videos;
return (a.artist_name ?? "").localeCompare(b.artist_name ?? "");
});
});
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("page");
goto(`?${params.toString()}`, { keepFocus: true });
}, 400);
}
function setParam(key: string, value: string) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value && value !== "name") params.set(key, value);
else params.delete(key);
params.delete("page");
goto(`?${params.toString()}`);
}
function goToPage(p: number) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (p > 1) params.set("page", String(p));
else params.delete("page");
goto(`?${params.toString()}`);
}
const totalPages = $derived(Math.ceil(data.total / data.limit));
</script>
<Meta title={$_("models.title")} description={$_("models.description")} />
@@ -76,51 +84,25 @@
></span>
<Input
placeholder={$_("models.search_placeholder")}
bind:value={searchQuery}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Category Filter -->
<Select type="single" bind:value={categoryFilter}>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
{categoryFilter === "all"
? $_("models.categories.all")
: categoryFilter === "romantic"
? $_("models.categories.romantic")
: categoryFilter === "artistic"
? $_("models.categories.artistic")
: $_("models.categories.intimate")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("models.categories.all")}</SelectItem>
<SelectItem value="romantic">{$_("models.categories.romantic")}</SelectItem>
<SelectItem value="artistic">{$_("models.categories.artistic")}</SelectItem>
<SelectItem value="intimate">{$_("models.categories.intimate")}</SelectItem>
</SelectContent>
</Select>
<!-- Sort -->
<Select type="single" bind:value={sortBy}>
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{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")}
</SelectTrigger>
<SelectContent>
<SelectItem value="popular">{$_("models.sort.popular")}</SelectItem>
<SelectItem value="rating">{$_("models.sort.rating")}</SelectItem>
<SelectItem value="videos">{$_("models.sort.videos")}</SelectItem>
<SelectItem value="name">{$_("models.sort.name")}</SelectItem>
<SelectItem value="recent">{$_("models.sort.recent")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -130,7 +112,7 @@
<!-- Models Grid -->
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each filteredModels() as model (model.slug)}
{#each data.items as model (model.slug)}
<Card
class="py-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-500 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
>
@@ -227,20 +209,44 @@
{/each}
</div>
{#if filteredModels().length === 0}
{#if data.items.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg">{$_("models.no_results")}</p>
<Button
variant="outline"
onclick={() => {
searchQuery = "";
categoryFilter = "all";
}}
class="mt-4"
>
<Button variant="outline" href="/models" class="mt-4">
{$_("models.clear_filters")}
</Button>
</div>
{/if}
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-10">
<span class="text-sm text-muted-foreground">
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
&nbsp;·&nbsp;
{$_("common.total_results", { values: { total: data.total } })}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.previous")}
</Button>
<Button
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
</div>
</div>

View File

@@ -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 {

View File

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

View File

@@ -1,5 +1,8 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
@@ -10,40 +13,38 @@
import { formatVideoDuration } from "$lib/utils";
const timeAgo = new TimeAgo("en");
let searchQuery = $state("");
let sortBy = $state("recent");
let categoryFilter = $state("all");
let durationFilter = $state("all");
const { data } = $props();
const filteredVideos = $derived(() => {
return data.videos
.filter((video) => {
const matchesSearch = video.title.toLowerCase().includes(searchQuery.toLowerCase());
// ||
// video.model.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === "all";
const matchesDuration =
durationFilter === "all" ||
(durationFilter === "short" && (video.movie_file?.duration ?? 0) < 10 * 60) ||
(durationFilter === "medium" &&
(video.movie_file?.duration ?? 0) >= 10 * 60 &&
(video.movie_file?.duration ?? 0) < 20 * 60) ||
(durationFilter === "long" && (video.movie_file?.duration ?? 0) >= 20 * 60);
return matchesSearch && matchesCategory && matchesDuration;
})
.sort((a, b) => {
if (sortBy === "recent")
return new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime();
if (sortBy === "most_liked") return (b.likes_count || 0) - (a.likes_count || 0);
if (sortBy === "most_played") return (b.plays_count || 0) - (a.plays_count || 0);
if (sortBy === "duration")
return (b.movie_file?.duration ?? 0) - (a.movie_file?.duration ?? 0);
return a.title.localeCompare(b.title);
});
});
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("page");
goto(`?${params.toString()}`, { keepFocus: true });
}, 400);
}
function setParam(key: string, value: string) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value && value !== "all" && value !== "recent") params.set(key, value);
else params.delete(key);
params.delete("page");
goto(`?${params.toString()}`);
}
function goToPage(p: number) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (p > 1) params.set("page", String(p));
else params.delete("page");
goto(`?${params.toString()}`);
}
const totalPages = $derived(Math.ceil(data.total / data.limit));
</script>
<Meta title={$_("videos.title")} description={$_("videos.description")} />
@@ -90,49 +91,32 @@
></span>
<Input
placeholder={$_("videos.search_placeholder")}
bind:value={searchQuery}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Category Filter -->
<Select type="single" bind:value={categoryFilter}>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
{categoryFilter === "all"
? $_("videos.categories.all")
: categoryFilter === "romantic"
? $_("videos.categories.romantic")
: categoryFilter === "artistic"
? $_("videos.categories.artistic")
: categoryFilter === "intimate"
? $_("videos.categories.intimate")
: $_("videos.categories.performance")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("videos.categories.all")}</SelectItem>
<SelectItem value="romantic">{$_("videos.categories.romantic")}</SelectItem>
<SelectItem value="artistic">{$_("videos.categories.artistic")}</SelectItem>
<SelectItem value="intimate">{$_("videos.categories.intimate")}</SelectItem>
<SelectItem value="performance">{$_("videos.categories.performance")}</SelectItem>
</SelectContent>
</Select>
<!-- Duration Filter -->
<Select type="single" bind:value={durationFilter}>
<Select
type="single"
value={data.duration}
onValueChange={(v) => v && setParam("duration", v)}
>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
{durationFilter === "all"
? $_("videos.duration.all")
: durationFilter === "short"
{data.duration === "short"
? $_("videos.duration.short")
: durationFilter === "medium"
: data.duration === "medium"
? $_("videos.duration.medium")
: $_("videos.duration.long")}
: data.duration === "long"
? $_("videos.duration.long")
: $_("videos.duration.all")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
@@ -143,25 +127,22 @@
</Select>
<!-- Sort -->
<Select type="single" bind:value={sortBy}>
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{sortBy === "recent"
? $_("videos.sort.recent")
: sortBy === "most_liked"
{data.sort === "most_liked"
? $_("videos.sort.most_liked")
: sortBy === "most_played"
: data.sort === "most_played"
? $_("videos.sort.most_played")
: sortBy === "duration"
? $_("videos.sort.duration")
: $_("videos.sort.name")}
: data.sort === "name"
? $_("videos.sort.name")
: $_("videos.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
<SelectItem value="duration">{$_("videos.sort.duration")}</SelectItem>
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
</SelectContent>
</Select>
@@ -172,7 +153,7 @@
<!-- Videos Grid -->
<div class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each filteredVideos() as video (video.slug)}
{#each data.items as video (video.slug)}
<Card
class="p-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-500 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
>
@@ -293,23 +274,46 @@
{/each}
</div>
{#if filteredVideos().length === 0}
{#if data.items.length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground text-lg mb-4">
{$_("videos.no_results")}
</p>
<Button
variant="outline"
onclick={() => {
searchQuery = "";
categoryFilter = "all";
durationFilter = "all";
}}
class="border-primary/20 hover:bg-primary/10"
>
<Button variant="outline" href="/videos" class="border-primary/20 hover:bg-primary/10">
{$_("videos.clear_filters")}
</Button>
</div>
{/if}
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-10">
<span class="text-sm text-muted-foreground">
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
&nbsp;·&nbsp;
{$_("common.total_results", { values: { total: data.total } })}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.previous")}
</Button>
<Button
variant="outline"
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
</div>
</div>