feat: add server-side pagination, search, and filtering to all collection and admin pages
- Public pages (videos, magazine, models): URL-driven search, sort, category/duration
filters, and Prev/Next pagination (page size 24)
- Admin tables (videos, articles): search input, toggle filters, and pagination (page size 50)
- Tags page: tag filtering now done server-side via DB arrayContains query instead of
fetching all items and filtering client-side
- Backend resolvers updated for videos, articles, models with paginated { items, total }
responses and filter/sort/tag args
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { builder } from "../builder";
|
||||
import { ArticleType } from "../types/index";
|
||||
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
|
||||
import { articles, users } from "../../db/schema/index";
|
||||
import { eq, and, lte, desc } from "drizzle-orm";
|
||||
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains } from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
|
||||
async function enrichArticle(db: any, article: any) {
|
||||
@@ -24,30 +24,54 @@ async function enrichArticle(db: any, article: any) {
|
||||
|
||||
builder.queryField("articles", (t) =>
|
||||
t.field({
|
||||
type: [ArticleType],
|
||||
type: ArticleListType,
|
||||
args: {
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
search: t.arg.string(),
|
||||
category: t.arg.string(),
|
||||
offset: t.arg.int(),
|
||||
sortBy: t.arg.string(),
|
||||
tag: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const dateFilter = lte(articles.publish_date, new Date());
|
||||
const whereCondition =
|
||||
args.featured !== null && args.featured !== undefined
|
||||
? and(dateFilter, eq(articles.featured, args.featured))
|
||||
: dateFilter;
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
let query = ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(articles.publish_date));
|
||||
|
||||
if (args.limit) {
|
||||
query = (query as any).limit(args.limit);
|
||||
const conditions: any[] = [lte(articles.publish_date, new Date())];
|
||||
if (args.featured !== null && args.featured !== undefined) {
|
||||
conditions.push(eq(articles.featured, args.featured));
|
||||
}
|
||||
if (args.category) conditions.push(eq(articles.category, args.category));
|
||||
if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag]));
|
||||
if (args.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(articles.title, `%${args.search}%`),
|
||||
ilike(articles.excerpt, `%${args.search}%`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const articleList = await query;
|
||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||
const orderArgs =
|
||||
args.sortBy === "name"
|
||||
? [asc(articles.title)]
|
||||
: args.sortBy === "featured"
|
||||
? [desc(articles.featured), desc(articles.publish_date)]
|
||||
: [desc(articles.publish_date)];
|
||||
|
||||
const where = and(...conditions);
|
||||
const [articleList, totalRows] = await Promise.all([
|
||||
(ctx.db.select().from(articles).where(where) as any)
|
||||
.orderBy(...orderArgs)
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(articles).where(where),
|
||||
]);
|
||||
const items = await Promise.all(
|
||||
articleList.map((article: any) => enrichArticle(ctx.db, article)),
|
||||
);
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -76,11 +100,47 @@ builder.queryField("article", (t) =>
|
||||
|
||||
builder.queryField("adminListArticles", (t) =>
|
||||
t.field({
|
||||
type: [ArticleType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
type: AdminArticleListType,
|
||||
args: {
|
||||
search: t.arg.string(),
|
||||
category: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
|
||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (args.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(articles.title, `%${args.search}%`),
|
||||
ilike(articles.excerpt, `%${args.search}%`),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (args.category) conditions.push(eq(articles.category, args.category));
|
||||
if (args.featured !== null && args.featured !== undefined)
|
||||
conditions.push(eq(articles.featured, args.featured));
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [articleList, totalRows] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(where)
|
||||
.orderBy(desc(articles.publish_date))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(articles).where(where),
|
||||
]);
|
||||
const items = await Promise.all(
|
||||
articleList.map((article: any) => enrichArticle(ctx.db, article)),
|
||||
);
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { builder } from "../builder";
|
||||
import { ModelType } from "../types/index";
|
||||
import { ModelType, ModelListType } from "../types/index";
|
||||
import { users, user_photos, files } from "../../db/schema/index";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { eq, and, desc, asc, ilike, count, arrayContains } from "drizzle-orm";
|
||||
|
||||
async function enrichModel(db: any, user: any) {
|
||||
// Fetch photos
|
||||
@@ -20,24 +20,32 @@ async function enrichModel(db: any, user: any) {
|
||||
|
||||
builder.queryField("models", (t) =>
|
||||
t.field({
|
||||
type: [ModelType],
|
||||
type: ModelListType,
|
||||
args: {
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
search: t.arg.string(),
|
||||
offset: t.arg.int(),
|
||||
sortBy: t.arg.string(),
|
||||
tag: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
let query = ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.role, "model"))
|
||||
.orderBy(desc(users.date_created));
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
if (args.limit) {
|
||||
query = (query as any).limit(args.limit);
|
||||
}
|
||||
const conditions: any[] = [eq(users.role, "model")];
|
||||
if (args.search) conditions.push(ilike(users.artist_name, `%${args.search}%`));
|
||||
if (args.tag) conditions.push(arrayContains(users.tags, [args.tag]));
|
||||
|
||||
const modelList = await query;
|
||||
return Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
|
||||
const order = args.sortBy === "recent" ? desc(users.date_created) : asc(users.artist_name);
|
||||
|
||||
const where = and(...conditions);
|
||||
const [modelList, totalRows] = await Promise.all([
|
||||
ctx.db.select().from(users).where(where).orderBy(order).limit(pageSize).offset(offset),
|
||||
ctx.db.select({ total: count() }).from(users).where(where),
|
||||
]);
|
||||
const items = await Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder";
|
||||
import {
|
||||
VideoType,
|
||||
VideoListType,
|
||||
AdminVideoListType,
|
||||
VideoLikeResponseType,
|
||||
VideoPlayResponseType,
|
||||
VideoLikeStatusType,
|
||||
@@ -14,7 +16,19 @@ import {
|
||||
users,
|
||||
files,
|
||||
} from "../../db/schema/index";
|
||||
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
lte,
|
||||
desc,
|
||||
asc,
|
||||
inArray,
|
||||
count,
|
||||
ilike,
|
||||
lt,
|
||||
gte,
|
||||
arrayContains,
|
||||
} from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
|
||||
async function enrichVideo(db: any, video: any) {
|
||||
@@ -58,67 +72,93 @@ async function enrichVideo(db: any, video: any) {
|
||||
|
||||
builder.queryField("videos", (t) =>
|
||||
t.field({
|
||||
type: [VideoType],
|
||||
type: VideoListType,
|
||||
args: {
|
||||
modelId: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
search: t.arg.string(),
|
||||
offset: t.arg.int(),
|
||||
sortBy: t.arg.string(),
|
||||
duration: t.arg.string(),
|
||||
tag: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
// Unauthenticated users cannot see premium videos
|
||||
const premiumFilter = !ctx.currentUser ? eq(videos.premium, false) : undefined;
|
||||
|
||||
let query = ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.where(and(lte(videos.upload_date, new Date()), premiumFilter))
|
||||
.orderBy(desc(videos.upload_date));
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [lte(videos.upload_date, new Date())];
|
||||
if (!ctx.currentUser) conditions.push(eq(videos.premium, false));
|
||||
if (args.featured !== null && args.featured !== undefined) {
|
||||
conditions.push(eq(videos.featured, args.featured));
|
||||
}
|
||||
if (args.search) {
|
||||
conditions.push(ilike(videos.title, `%${args.search}%`));
|
||||
}
|
||||
if (args.tag) {
|
||||
conditions.push(arrayContains(videos.tags, [args.tag]));
|
||||
}
|
||||
if (args.modelId) {
|
||||
const videoIds = await ctx.db
|
||||
.select({ video_id: video_models.video_id })
|
||||
.from(video_models)
|
||||
.where(eq(video_models.user_id, args.modelId));
|
||||
|
||||
if (videoIds.length === 0) return [];
|
||||
|
||||
query = ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.where(
|
||||
and(
|
||||
lte(videos.upload_date, new Date()),
|
||||
premiumFilter,
|
||||
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 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 } })}
|
||||
·
|
||||
{$_("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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 } })}
|
||||
·
|
||||
{$_("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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 } })}
|
||||
·
|
||||
{$_("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>
|
||||
|
||||
Reference in New Issue
Block a user