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 { builder } from "../builder";
|
||||||
import { ArticleType } from "../types/index";
|
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
|
||||||
import { articles, users } from "../../db/schema/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";
|
import { requireAdmin } from "../../lib/acl";
|
||||||
|
|
||||||
async function enrichArticle(db: any, article: any) {
|
async function enrichArticle(db: any, article: any) {
|
||||||
@@ -24,30 +24,54 @@ async function enrichArticle(db: any, article: any) {
|
|||||||
|
|
||||||
builder.queryField("articles", (t) =>
|
builder.queryField("articles", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [ArticleType],
|
type: ArticleListType,
|
||||||
args: {
|
args: {
|
||||||
featured: t.arg.boolean(),
|
featured: t.arg.boolean(),
|
||||||
limit: t.arg.int(),
|
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) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
const dateFilter = lte(articles.publish_date, new Date());
|
const pageSize = args.limit ?? 24;
|
||||||
const whereCondition =
|
const offset = args.offset ?? 0;
|
||||||
args.featured !== null && args.featured !== undefined
|
|
||||||
? and(dateFilter, eq(articles.featured, args.featured))
|
|
||||||
: dateFilter;
|
|
||||||
|
|
||||||
let query = ctx.db
|
const conditions: any[] = [lte(articles.publish_date, new Date())];
|
||||||
.select()
|
if (args.featured !== null && args.featured !== undefined) {
|
||||||
.from(articles)
|
conditions.push(eq(articles.featured, args.featured));
|
||||||
.where(whereCondition)
|
}
|
||||||
.orderBy(desc(articles.publish_date));
|
if (args.category) conditions.push(eq(articles.category, args.category));
|
||||||
|
if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag]));
|
||||||
if (args.limit) {
|
if (args.search) {
|
||||||
query = (query as any).limit(args.limit);
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(articles.title, `%${args.search}%`),
|
||||||
|
ilike(articles.excerpt, `%${args.search}%`),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const articleList = await query;
|
const orderArgs =
|
||||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
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) =>
|
builder.queryField("adminListArticles", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [ArticleType],
|
type: AdminArticleListType,
|
||||||
resolve: async (_root, _args, ctx) => {
|
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);
|
requireAdmin(ctx);
|
||||||
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
|
const limit = args.limit ?? 50;
|
||||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
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 { builder } from "../builder";
|
||||||
import { ModelType } from "../types/index";
|
import { ModelType, ModelListType } from "../types/index";
|
||||||
import { users, user_photos, files } from "../../db/schema/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) {
|
async function enrichModel(db: any, user: any) {
|
||||||
// Fetch photos
|
// Fetch photos
|
||||||
@@ -20,24 +20,32 @@ async function enrichModel(db: any, user: any) {
|
|||||||
|
|
||||||
builder.queryField("models", (t) =>
|
builder.queryField("models", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [ModelType],
|
type: ModelListType,
|
||||||
args: {
|
args: {
|
||||||
featured: t.arg.boolean(),
|
featured: t.arg.boolean(),
|
||||||
limit: t.arg.int(),
|
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) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
let query = ctx.db
|
const pageSize = args.limit ?? 24;
|
||||||
.select()
|
const offset = args.offset ?? 0;
|
||||||
.from(users)
|
|
||||||
.where(eq(users.role, "model"))
|
|
||||||
.orderBy(desc(users.date_created));
|
|
||||||
|
|
||||||
if (args.limit) {
|
const conditions: any[] = [eq(users.role, "model")];
|
||||||
query = (query as any).limit(args.limit);
|
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;
|
const order = args.sortBy === "recent" ? desc(users.date_created) : asc(users.artist_name);
|
||||||
return Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
|
|
||||||
|
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 { builder } from "../builder";
|
||||||
import {
|
import {
|
||||||
VideoType,
|
VideoType,
|
||||||
|
VideoListType,
|
||||||
|
AdminVideoListType,
|
||||||
VideoLikeResponseType,
|
VideoLikeResponseType,
|
||||||
VideoPlayResponseType,
|
VideoPlayResponseType,
|
||||||
VideoLikeStatusType,
|
VideoLikeStatusType,
|
||||||
@@ -14,7 +16,19 @@ import {
|
|||||||
users,
|
users,
|
||||||
files,
|
files,
|
||||||
} from "../../db/schema/index";
|
} 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";
|
import { requireAdmin } from "../../lib/acl";
|
||||||
|
|
||||||
async function enrichVideo(db: any, video: any) {
|
async function enrichVideo(db: any, video: any) {
|
||||||
@@ -58,67 +72,93 @@ async function enrichVideo(db: any, video: any) {
|
|||||||
|
|
||||||
builder.queryField("videos", (t) =>
|
builder.queryField("videos", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [VideoType],
|
type: VideoListType,
|
||||||
args: {
|
args: {
|
||||||
modelId: t.arg.string(),
|
modelId: t.arg.string(),
|
||||||
featured: t.arg.boolean(),
|
featured: t.arg.boolean(),
|
||||||
limit: t.arg.int(),
|
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) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
// Unauthenticated users cannot see premium videos
|
const pageSize = args.limit ?? 24;
|
||||||
const premiumFilter = !ctx.currentUser ? eq(videos.premium, false) : undefined;
|
const offset = args.offset ?? 0;
|
||||||
|
|
||||||
let query = ctx.db
|
|
||||||
.select({ v: videos })
|
|
||||||
.from(videos)
|
|
||||||
.where(and(lte(videos.upload_date, new Date()), premiumFilter))
|
|
||||||
.orderBy(desc(videos.upload_date));
|
|
||||||
|
|
||||||
|
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) {
|
if (args.modelId) {
|
||||||
const videoIds = await ctx.db
|
const videoIds = await ctx.db
|
||||||
.select({ video_id: video_models.video_id })
|
.select({ video_id: video_models.video_id })
|
||||||
.from(video_models)
|
.from(video_models)
|
||||||
.where(eq(video_models.user_id, args.modelId));
|
.where(eq(video_models.user_id, args.modelId));
|
||||||
|
if (videoIds.length === 0) return { items: [], total: 0 };
|
||||||
if (videoIds.length === 0) return [];
|
conditions.push(
|
||||||
|
inArray(
|
||||||
query = ctx.db
|
videos.id,
|
||||||
.select({ v: videos })
|
videoIds.map((v: any) => v.video_id),
|
||||||
.from(videos)
|
),
|
||||||
.where(
|
);
|
||||||
and(
|
|
||||||
lte(videos.upload_date, new Date()),
|
|
||||||
premiumFilter,
|
|
||||||
inArray(
|
|
||||||
videos.id,
|
|
||||||
videoIds.map((v: any) => v.video_id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(videos.upload_date));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.featured !== null && args.featured !== undefined) {
|
const order =
|
||||||
query = ctx.db
|
args.sortBy === "most_liked"
|
||||||
.select({ v: videos })
|
? desc(videos.likes_count)
|
||||||
.from(videos)
|
: args.sortBy === "most_played"
|
||||||
.where(
|
? desc(videos.plays_count)
|
||||||
and(
|
: args.sortBy === "name"
|
||||||
lte(videos.upload_date, new Date()),
|
? asc(videos.title)
|
||||||
premiumFilter,
|
: desc(videos.upload_date);
|
||||||
eq(videos.featured, args.featured),
|
|
||||||
),
|
const where = and(...conditions);
|
||||||
)
|
|
||||||
.orderBy(desc(videos.upload_date));
|
// Duration filter requires JOIN to files table
|
||||||
|
if (args.duration && args.duration !== "all") {
|
||||||
|
const durationCond =
|
||||||
|
args.duration === "short"
|
||||||
|
? lt(files.duration, 600)
|
||||||
|
: args.duration === "medium"
|
||||||
|
? and(gte(files.duration, 600), lt(files.duration, 1200))
|
||||||
|
: gte(files.duration, 1200);
|
||||||
|
|
||||||
|
const fullWhere = and(where, durationCond);
|
||||||
|
const [rows, totalRows] = await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.select({ v: videos })
|
||||||
|
.from(videos)
|
||||||
|
.leftJoin(files, eq(videos.movie, files.id))
|
||||||
|
.where(fullWhere)
|
||||||
|
.orderBy(order)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
ctx.db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(videos)
|
||||||
|
.leftJoin(files, eq(videos.movie, files.id))
|
||||||
|
.where(fullWhere),
|
||||||
|
]);
|
||||||
|
const videoList = rows.map((r: any) => r.v || r);
|
||||||
|
const items = await Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
|
||||||
|
return { items, total: totalRows[0]?.total ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.limit) {
|
const [rows, totalRows] = await Promise.all([
|
||||||
query = (query as any).limit(args.limit);
|
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
|
||||||
}
|
ctx.db.select({ total: count() }).from(videos).where(where),
|
||||||
|
]);
|
||||||
const rows = await query;
|
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
||||||
const videoList = rows.map((r: any) => r.v || r);
|
return { items, total: totalRows[0]?.total ?? 0 };
|
||||||
return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -430,11 +470,39 @@ builder.queryField("analytics", (t) =>
|
|||||||
|
|
||||||
builder.queryField("adminListVideos", (t) =>
|
builder.queryField("adminListVideos", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [VideoType],
|
type: AdminVideoListType,
|
||||||
resolve: async (_root, _args, ctx) => {
|
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);
|
requireAdmin(ctx);
|
||||||
const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
|
const limit = args.limit ?? 50;
|
||||||
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
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
|
export const AdminUserListType = builder
|
||||||
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
||||||
.implement({
|
.implement({
|
||||||
@@ -338,24 +383,22 @@ export const AdminUserListType = builder
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AdminUserDetailType = builder
|
export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUserDetail").implement({
|
||||||
.objectRef<AdminUserDetail>("AdminUserDetail")
|
fields: (t) => ({
|
||||||
.implement({
|
id: t.exposeString("id"),
|
||||||
fields: (t) => ({
|
email: t.exposeString("email"),
|
||||||
id: t.exposeString("id"),
|
first_name: t.exposeString("first_name", { nullable: true }),
|
||||||
email: t.exposeString("email"),
|
last_name: t.exposeString("last_name", { nullable: true }),
|
||||||
first_name: t.exposeString("first_name", { nullable: true }),
|
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||||
last_name: t.exposeString("last_name", { nullable: true }),
|
slug: t.exposeString("slug", { nullable: true }),
|
||||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
description: t.exposeString("description", { nullable: true }),
|
||||||
slug: t.exposeString("slug", { nullable: true }),
|
tags: t.exposeStringList("tags", { nullable: true }),
|
||||||
description: t.exposeString("description", { nullable: true }),
|
role: t.exposeString("role"),
|
||||||
tags: t.exposeStringList("tags", { nullable: true }),
|
is_admin: t.exposeBoolean("is_admin"),
|
||||||
role: t.exposeString("role"),
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
is_admin: t.exposeBoolean("is_admin"),
|
banner: t.exposeString("banner", { nullable: true }),
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
email_verified: t.exposeBoolean("email_verified"),
|
||||||
banner: t.exposeString("banner", { nullable: true }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
email_verified: t.exposeBoolean("email_verified"),
|
photos: t.expose("photos", { type: [ModelPhotoType] }),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
}),
|
||||||
photos: t.expose("photos", { type: [ModelPhotoType] }),
|
});
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default {
|
|||||||
my_profile: "My Profile",
|
my_profile: "My Profile",
|
||||||
anonymous: "Anonymous",
|
anonymous: "Anonymous",
|
||||||
load_more: "Load More",
|
load_more: "Load More",
|
||||||
|
page_of: "Page {page} of {total}",
|
||||||
|
total_results: "{total} results",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
home: "Home",
|
home: "Home",
|
||||||
@@ -251,6 +253,7 @@ export default {
|
|||||||
rating: "Highest Rated",
|
rating: "Highest Rated",
|
||||||
videos: "Most Videos",
|
videos: "Most Videos",
|
||||||
name: "A-Z",
|
name: "A-Z",
|
||||||
|
recent: "Newest",
|
||||||
},
|
},
|
||||||
online: "Online",
|
online: "Online",
|
||||||
followers: "followers",
|
followers: "followers",
|
||||||
@@ -913,6 +916,7 @@ export default {
|
|||||||
saving: "Saving…",
|
saving: "Saving…",
|
||||||
creating: "Creating…",
|
creating: "Creating…",
|
||||||
deleting: "Deleting…",
|
deleting: "Deleting…",
|
||||||
|
all: "All",
|
||||||
featured: "Featured",
|
featured: "Featured",
|
||||||
premium: "Premium",
|
premium: "Premium",
|
||||||
write: "Write",
|
write: "Write",
|
||||||
@@ -944,7 +948,8 @@ export default {
|
|||||||
role_updated: "Role updated to {role}",
|
role_updated: "Role updated to {role}",
|
||||||
role_update_failed: "Failed to update role",
|
role_update_failed: "Failed to update role",
|
||||||
delete_title: "Delete user",
|
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_success: "User deleted",
|
||||||
delete_error: "Failed to delete user",
|
delete_error: "Failed to delete user",
|
||||||
},
|
},
|
||||||
@@ -971,6 +976,7 @@ export default {
|
|||||||
videos: {
|
videos: {
|
||||||
title: "Videos",
|
title: "Videos",
|
||||||
new_video: "New video",
|
new_video: "New video",
|
||||||
|
search_placeholder: "Search videos...",
|
||||||
col_video: "Video",
|
col_video: "Video",
|
||||||
col_badges: "Badges",
|
col_badges: "Badges",
|
||||||
col_plays: "Plays",
|
col_plays: "Plays",
|
||||||
@@ -1005,6 +1011,8 @@ export default {
|
|||||||
articles: {
|
articles: {
|
||||||
title: "Articles",
|
title: "Articles",
|
||||||
new_article: "New article",
|
new_article: "New article",
|
||||||
|
search_placeholder: "Search articles...",
|
||||||
|
filter_all_categories: "All categories",
|
||||||
col_article: "Article",
|
col_article: "Article",
|
||||||
col_category: "Category",
|
col_category: "Category",
|
||||||
col_published: "Published",
|
col_published: "Published",
|
||||||
|
|||||||
@@ -216,31 +216,63 @@ export async function resetPassword(token: string, password: string) {
|
|||||||
// ─── Articles ────────────────────────────────────────────────────────────────
|
// ─── Articles ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ARTICLES_QUERY = gql`
|
const ARTICLES_QUERY = gql`
|
||||||
query GetArticles {
|
query GetArticles(
|
||||||
articles {
|
$search: String
|
||||||
id
|
$category: String
|
||||||
slug
|
$sortBy: String
|
||||||
title
|
$offset: Int
|
||||||
excerpt
|
$limit: Int
|
||||||
content
|
$featured: Boolean
|
||||||
image
|
$tag: String
|
||||||
tags
|
) {
|
||||||
publish_date
|
articles(
|
||||||
category
|
search: $search
|
||||||
featured
|
category: $category
|
||||||
author {
|
sortBy: $sortBy
|
||||||
|
offset: $offset
|
||||||
|
limit: $limit
|
||||||
|
featured: $featured
|
||||||
|
tag: $tag
|
||||||
|
) {
|
||||||
|
items {
|
||||||
id
|
id
|
||||||
artist_name
|
|
||||||
slug
|
slug
|
||||||
avatar
|
title
|
||||||
|
excerpt
|
||||||
|
content
|
||||||
|
image
|
||||||
|
tags
|
||||||
|
publish_date
|
||||||
|
category
|
||||||
|
featured
|
||||||
|
author {
|
||||||
|
id
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getArticles(fetchFn?: typeof globalThis.fetch) {
|
export async function getArticles(
|
||||||
|
params: {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
featured?: boolean;
|
||||||
|
tag?: string;
|
||||||
|
} = {},
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
): Promise<{ items: Article[]; total: number }> {
|
||||||
return loggedApiCall("getArticles", async () => {
|
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;
|
return data.articles;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,39 +318,72 @@ export async function getArticleBySlug(slug: string, fetchFn?: typeof globalThis
|
|||||||
// ─── Videos ──────────────────────────────────────────────────────────────────
|
// ─── Videos ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const VIDEOS_QUERY = gql`
|
const VIDEOS_QUERY = gql`
|
||||||
query GetVideos($modelId: String, $featured: Boolean, $limit: Int) {
|
query GetVideos(
|
||||||
videos(modelId: $modelId, featured: $featured, limit: $limit) {
|
$modelId: String
|
||||||
id
|
$featured: Boolean
|
||||||
slug
|
$limit: Int
|
||||||
title
|
$search: String
|
||||||
description
|
$offset: Int
|
||||||
image
|
$sortBy: String
|
||||||
movie
|
$duration: String
|
||||||
tags
|
$tag: String
|
||||||
upload_date
|
) {
|
||||||
premium
|
videos(
|
||||||
featured
|
modelId: $modelId
|
||||||
likes_count
|
featured: $featured
|
||||||
plays_count
|
limit: $limit
|
||||||
models {
|
search: $search
|
||||||
|
offset: $offset
|
||||||
|
sortBy: $sortBy
|
||||||
|
duration: $duration
|
||||||
|
tag: $tag
|
||||||
|
) {
|
||||||
|
items {
|
||||||
id
|
id
|
||||||
artist_name
|
|
||||||
slug
|
slug
|
||||||
avatar
|
title
|
||||||
}
|
description
|
||||||
movie_file {
|
image
|
||||||
id
|
movie
|
||||||
filename
|
tags
|
||||||
mime_type
|
upload_date
|
||||||
duration
|
premium
|
||||||
|
featured
|
||||||
|
likes_count
|
||||||
|
plays_count
|
||||||
|
models {
|
||||||
|
id
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
movie_file {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
mime_type
|
||||||
|
duration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getVideos(fetchFn?: typeof globalThis.fetch) {
|
export async function getVideos(
|
||||||
|
params: {
|
||||||
|
search?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
duration?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
tag?: string;
|
||||||
|
} = {},
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
): Promise<{ items: Video[]; total: number }> {
|
||||||
return loggedApiCall("getVideos", async () => {
|
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;
|
return data.videos;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -327,10 +392,10 @@ export async function getVideosForModel(id: string, fetchFn?: typeof globalThis.
|
|||||||
return loggedApiCall(
|
return loggedApiCall(
|
||||||
"getVideosForModel",
|
"getVideosForModel",
|
||||||
async () => {
|
async () => {
|
||||||
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY, {
|
const data = await getGraphQLClient(fetchFn).request<{
|
||||||
modelId: id,
|
videos: { items: Video[]; total: number };
|
||||||
});
|
}>(VIDEOS_QUERY, { modelId: id, limit: 10000 });
|
||||||
return data.videos;
|
return data.videos.items;
|
||||||
},
|
},
|
||||||
{ modelId: id },
|
{ modelId: id },
|
||||||
);
|
);
|
||||||
@@ -343,11 +408,10 @@ export async function getFeaturedVideos(
|
|||||||
return loggedApiCall(
|
return loggedApiCall(
|
||||||
"getFeaturedVideos",
|
"getFeaturedVideos",
|
||||||
async () => {
|
async () => {
|
||||||
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY, {
|
const data = await getGraphQLClient(fetchFn).request<{
|
||||||
featured: true,
|
videos: { items: Video[]; total: number };
|
||||||
limit,
|
}>(VIDEOS_QUERY, { featured: true, limit });
|
||||||
});
|
return data.videos.items;
|
||||||
return data.videos;
|
|
||||||
},
|
},
|
||||||
{ limit },
|
{ limit },
|
||||||
);
|
);
|
||||||
@@ -402,27 +466,49 @@ export async function getVideoBySlug(slug: string, fetchFn?: typeof globalThis.f
|
|||||||
// ─── Models ──────────────────────────────────────────────────────────────────
|
// ─── Models ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const MODELS_QUERY = gql`
|
const MODELS_QUERY = gql`
|
||||||
query GetModels($featured: Boolean, $limit: Int) {
|
query GetModels(
|
||||||
models(featured: $featured, limit: $limit) {
|
$featured: Boolean
|
||||||
id
|
$limit: Int
|
||||||
slug
|
$search: String
|
||||||
artist_name
|
$offset: Int
|
||||||
description
|
$sortBy: String
|
||||||
avatar
|
$tag: String
|
||||||
banner
|
) {
|
||||||
tags
|
models(
|
||||||
date_created
|
featured: $featured
|
||||||
photos {
|
limit: $limit
|
||||||
|
search: $search
|
||||||
|
offset: $offset
|
||||||
|
sortBy: $sortBy
|
||||||
|
tag: $tag
|
||||||
|
) {
|
||||||
|
items {
|
||||||
id
|
id
|
||||||
filename
|
slug
|
||||||
|
artist_name
|
||||||
|
description
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
tags
|
||||||
|
date_created
|
||||||
|
photos {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getModels(fetchFn?: typeof globalThis.fetch) {
|
export async function getModels(
|
||||||
|
params: { search?: string; sortBy?: string; offset?: number; limit?: number; tag?: string } = {},
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
): Promise<{ items: Model[]; total: number }> {
|
||||||
return loggedApiCall("getModels", async () => {
|
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;
|
return data.models;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -434,11 +520,10 @@ export async function getFeaturedModels(
|
|||||||
return loggedApiCall(
|
return loggedApiCall(
|
||||||
"getFeaturedModels",
|
"getFeaturedModels",
|
||||||
async () => {
|
async () => {
|
||||||
const data = await getGraphQLClient(fetchFn).request<{ models: Model[] }>(MODELS_QUERY, {
|
const data = await getGraphQLClient(fetchFn).request<{
|
||||||
featured: true,
|
models: { items: Model[]; total: number };
|
||||||
limit,
|
}>(MODELS_QUERY, { featured: true, limit });
|
||||||
});
|
return data.models.items;
|
||||||
return data.models;
|
|
||||||
},
|
},
|
||||||
{ limit },
|
{ limit },
|
||||||
);
|
);
|
||||||
@@ -668,7 +753,7 @@ export async function countCommentsForModel(
|
|||||||
|
|
||||||
export async function getItemsByTag(
|
export async function getItemsByTag(
|
||||||
category: "video" | "article" | "model",
|
category: "video" | "article" | "model",
|
||||||
_tag: string,
|
tag: string,
|
||||||
fetchFn?: typeof globalThis.fetch,
|
fetchFn?: typeof globalThis.fetch,
|
||||||
) {
|
) {
|
||||||
return loggedApiCall(
|
return loggedApiCall(
|
||||||
@@ -676,14 +761,14 @@ export async function getItemsByTag(
|
|||||||
async () => {
|
async () => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "video":
|
case "video":
|
||||||
return getVideos(fetchFn);
|
return getVideos({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
|
||||||
case "model":
|
case "model":
|
||||||
return getModels(fetchFn);
|
return getModels({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
|
||||||
case "article":
|
case "article":
|
||||||
return getArticles(fetchFn);
|
return getArticles({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ category },
|
{ category, tag },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,41 +1273,67 @@ export async function adminRemoveUserPhoto(userId: string, fileId: string) {
|
|||||||
// ─── Admin: Videos ────────────────────────────────────────────────────────────
|
// ─── Admin: Videos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ADMIN_LIST_VIDEOS_QUERY = gql`
|
const ADMIN_LIST_VIDEOS_QUERY = gql`
|
||||||
query AdminListVideos {
|
query AdminListVideos(
|
||||||
adminListVideos {
|
$search: String
|
||||||
id
|
$premium: Boolean
|
||||||
slug
|
$featured: Boolean
|
||||||
title
|
$limit: Int
|
||||||
description
|
$offset: Int
|
||||||
image
|
) {
|
||||||
movie
|
adminListVideos(
|
||||||
tags
|
search: $search
|
||||||
upload_date
|
premium: $premium
|
||||||
premium
|
featured: $featured
|
||||||
featured
|
limit: $limit
|
||||||
likes_count
|
offset: $offset
|
||||||
plays_count
|
) {
|
||||||
models {
|
items {
|
||||||
id
|
id
|
||||||
artist_name
|
|
||||||
slug
|
slug
|
||||||
avatar
|
title
|
||||||
}
|
description
|
||||||
movie_file {
|
image
|
||||||
id
|
movie
|
||||||
filename
|
tags
|
||||||
mime_type
|
upload_date
|
||||||
duration
|
premium
|
||||||
|
featured
|
||||||
|
likes_count
|
||||||
|
plays_count
|
||||||
|
models {
|
||||||
|
id
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
movie_file {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
mime_type
|
||||||
|
duration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function adminListVideos(fetchFn?: typeof globalThis.fetch, token?: string) {
|
export async function adminListVideos(
|
||||||
|
opts: {
|
||||||
|
search?: string;
|
||||||
|
premium?: boolean;
|
||||||
|
featured?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
} = {},
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
token?: string,
|
||||||
|
): Promise<{ items: Video[]; total: number }> {
|
||||||
return loggedApiCall("adminListVideos", async () => {
|
return loggedApiCall("adminListVideos", async () => {
|
||||||
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
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,
|
ADMIN_LIST_VIDEOS_QUERY,
|
||||||
|
opts,
|
||||||
);
|
);
|
||||||
return data.adminListVideos;
|
return data.adminListVideos;
|
||||||
});
|
});
|
||||||
@@ -1374,33 +1485,59 @@ export async function setVideoModels(videoId: string, userIds: string[]) {
|
|||||||
// ─── Admin: Articles ──────────────────────────────────────────────────────────
|
// ─── Admin: Articles ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ADMIN_LIST_ARTICLES_QUERY = gql`
|
const ADMIN_LIST_ARTICLES_QUERY = gql`
|
||||||
query AdminListArticles {
|
query AdminListArticles(
|
||||||
adminListArticles {
|
$search: String
|
||||||
id
|
$category: String
|
||||||
slug
|
$featured: Boolean
|
||||||
title
|
$limit: Int
|
||||||
excerpt
|
$offset: Int
|
||||||
image
|
) {
|
||||||
tags
|
adminListArticles(
|
||||||
publish_date
|
search: $search
|
||||||
category
|
category: $category
|
||||||
featured
|
featured: $featured
|
||||||
content
|
limit: $limit
|
||||||
author {
|
offset: $offset
|
||||||
|
) {
|
||||||
|
items {
|
||||||
id
|
id
|
||||||
artist_name
|
|
||||||
slug
|
slug
|
||||||
avatar
|
title
|
||||||
|
excerpt
|
||||||
|
image
|
||||||
|
tags
|
||||||
|
publish_date
|
||||||
|
category
|
||||||
|
featured
|
||||||
|
content
|
||||||
|
author {
|
||||||
|
id
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function adminListArticles(fetchFn?: typeof globalThis.fetch, token?: string) {
|
export async function adminListArticles(
|
||||||
|
opts: {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
} = {},
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
token?: string,
|
||||||
|
): Promise<{ items: Article[]; total: number }> {
|
||||||
return loggedApiCall("adminListArticles", async () => {
|
return loggedApiCall("adminListArticles", async () => {
|
||||||
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
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,
|
ADMIN_LIST_ARTICLES_QUERY,
|
||||||
|
opts,
|
||||||
);
|
);
|
||||||
return data.adminListArticles;
|
return data.adminListArticles;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { adminListArticles } from "$lib/services";
|
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 token = cookies.get("session_token") || "";
|
||||||
const articles = await adminListArticles(fetch, token).catch(() => []);
|
const search = url.searchParams.get("search") || undefined;
|
||||||
return { articles };
|
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">
|
<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 { toast } from "svelte-sonner";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { deleteArticle } from "$lib/services";
|
import { deleteArticle } from "$lib/services";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { Button } from "$lib/components/ui/button";
|
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 * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Article } from "$lib/types";
|
import type { Article } from "$lib/types";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
@@ -16,6 +20,27 @@
|
|||||||
let deleteTarget: Article | null = $state(null);
|
let deleteTarget: Article | null = $state(null);
|
||||||
let deleteOpen = $state(false);
|
let deleteOpen = $state(false);
|
||||||
let deleting = $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) {
|
function confirmDelete(article: Article) {
|
||||||
deleteTarget = article;
|
deleteTarget = article;
|
||||||
@@ -42,8 +67,54 @@
|
|||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<div class="py-3 sm:py-6 sm:pl-6">
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
|
<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="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.articles.new_article")}
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,14 +122,22 @@
|
|||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-muted/30">
|
<thead class="bg-muted/30">
|
||||||
<tr>
|
<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"
|
||||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.articles.col_category")}</th>
|
>{$_("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_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 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border/30">
|
<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">
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -86,7 +165,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell">
|
||||||
{timeAgo.format(new Date(article.publish_date))}
|
{timeAgo.format(new Date(article.publish_date))}
|
||||||
</td>
|
</td>
|
||||||
@@ -108,7 +189,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if data.articles.length === 0}
|
{#if data.items.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
|
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
|
||||||
{$_("admin.articles.no_results")}
|
{$_("admin.articles.no_results")}
|
||||||
@@ -118,6 +199,47 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Dialog.Root bind:open={deleteOpen}>
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { adminListVideos } from "$lib/services";
|
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 token = cookies.get("session_token") || "";
|
||||||
const videos = await adminListVideos(fetch, token).catch(() => []);
|
const search = url.searchParams.get("search") || undefined;
|
||||||
return { videos };
|
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">
|
<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 { toast } from "svelte-sonner";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { deleteVideo } from "$lib/services";
|
import { deleteVideo } from "$lib/services";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Video } from "$lib/types";
|
import type { Video } from "$lib/types";
|
||||||
|
|
||||||
@@ -14,6 +17,27 @@
|
|||||||
let deleteTarget: Video | null = $state(null);
|
let deleteTarget: Video | null = $state(null);
|
||||||
let deleteOpen = $state(false);
|
let deleteOpen = $state(false);
|
||||||
let deleting = $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) {
|
function confirmDelete(video: Video) {
|
||||||
deleteTarget = video;
|
deleteTarget = video;
|
||||||
@@ -40,24 +64,78 @@
|
|||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<div class="py-3 sm:py-6 sm:pl-6">
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
|
<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="icon-[ri--add-line] h-4 w-4 mr-1"></span>{$_("admin.videos.new_video")}
|
<span class="text-sm text-muted-foreground"
|
||||||
</Button>
|
>{$_("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>
|
||||||
|
|
||||||
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-muted/30">
|
<thead class="bg-muted/30">
|
||||||
<tr>
|
<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"
|
||||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">{$_("admin.videos.col_badges")}</th>
|
>{$_("admin.videos.col_video")}</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-left font-medium text-muted-foreground hidden sm:table-cell"
|
||||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground">{$_("admin.users.col_actions")}</th>
|
>{$_("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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border/30">
|
<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">
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -83,15 +161,23 @@
|
|||||||
<td class="px-4 py-3 hidden sm:table-cell">
|
<td class="px-4 py-3 hidden sm:table-cell">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{#if video.premium}
|
{#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}
|
||||||
{#if video.featured}
|
{#if video.featured}
|
||||||
<Badge variant="default">{$_("admin.common.featured")}</Badge>
|
<Badge variant="default">{$_("admin.common.featured")}</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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"
|
||||||
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">{video.likes_count ?? 0}</td>
|
>{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">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<Button size="sm" variant="ghost" href="/admin/videos/{video.id}">
|
<Button size="sm" variant="ghost" href="/admin/videos/{video.id}">
|
||||||
@@ -110,14 +196,57 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if data.videos.length === 0}
|
{#if data.items.length === 0}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Dialog.Root bind:open={deleteOpen}>
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { getArticles } from "$lib/services";
|
import { getArticles } from "$lib/services";
|
||||||
export async function load({ fetch }) {
|
|
||||||
return {
|
const LIMIT = 24;
|
||||||
articles: await getArticles(fetch),
|
|
||||||
};
|
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">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
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 { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
|
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
import type { Article } from "$lib/types";
|
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { calcReadingTime } from "$lib/utils.js";
|
import { calcReadingTime } from "$lib/utils.js";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
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 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(() => {
|
const featuredArticle =
|
||||||
return data.articles
|
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null;
|
||||||
.filter((article) => {
|
|
||||||
const matchesSearch =
|
function debounceSearch(value: string) {
|
||||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
clearTimeout(searchTimeout);
|
||||||
article.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
searchTimeout = setTimeout(() => {
|
||||||
article.author?.artist_name?.toLowerCase().includes(searchQuery.toLowerCase());
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
|
if (value) params.set("search", value);
|
||||||
return matchesSearch && matchesCategory;
|
else params.delete("search");
|
||||||
})
|
params.delete("page");
|
||||||
.sort((a, b) => {
|
goto(`?${params.toString()}`, { keepFocus: true });
|
||||||
if (sortBy === "recent")
|
}, 400);
|
||||||
return new Date(b.publish_date).getTime() - new Date(a.publish_date).getTime();
|
}
|
||||||
// if (sortBy === "popular")
|
|
||||||
// return (
|
function setParam(key: string, value: string) {
|
||||||
// parseInt(b.views.replace(/[^\d]/g, "")) -
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
// parseInt(a.views.replace(/[^\d]/g, ""))
|
if (value && value !== "all" && value !== "recent") params.set(key, value);
|
||||||
// );
|
else params.delete(key);
|
||||||
if (sortBy === "featured") return (b.featured ? 1 : 0) - (a.featured ? 1 : 0);
|
params.delete("page");
|
||||||
return a.title.localeCompare(b.title);
|
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>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
||||||
@@ -88,28 +93,36 @@
|
|||||||
></span>
|
></span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("magazine.search_placeholder")}
|
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"
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Filter -->
|
<!-- Category Filter -->
|
||||||
<Select type="single" bind:value={categoryFilter}>
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={data.category ?? "all"}
|
||||||
|
onValueChange={(v) => v && setParam("category", v)}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
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>
|
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||||
{categoryFilter === "all"
|
{!data.category
|
||||||
? $_("magazine.categories.all")
|
? $_("magazine.categories.all")
|
||||||
: categoryFilter === "photography"
|
: data.category === "photography"
|
||||||
? $_("magazine.categories.photography")
|
? $_("magazine.categories.photography")
|
||||||
: categoryFilter === "production"
|
: data.category === "production"
|
||||||
? $_("magazine.categories.production")
|
? $_("magazine.categories.production")
|
||||||
: categoryFilter === "interview"
|
: data.category === "interview"
|
||||||
? $_("magazine.categories.interview")
|
? $_("magazine.categories.interview")
|
||||||
: categoryFilter === "psychology"
|
: data.category === "psychology"
|
||||||
? $_("magazine.categories.psychology")
|
? $_("magazine.categories.psychology")
|
||||||
: categoryFilter === "trends"
|
: data.category === "trends"
|
||||||
? $_("magazine.categories.trends")
|
? $_("magazine.categories.trends")
|
||||||
: $_("magazine.categories.spotlight")}
|
: $_("magazine.categories.spotlight")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -125,23 +138,18 @@
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<!-- Sort -->
|
<!-- Sort -->
|
||||||
<Select type="single" bind:value={sortBy}>
|
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
{sortBy === "recent"
|
{data.sort === "featured"
|
||||||
? $_("magazine.sort.recent")
|
? $_("magazine.sort.featured")
|
||||||
: sortBy === "popular"
|
: data.sort === "name"
|
||||||
? $_("magazine.sort.popular")
|
? $_("magazine.sort.name")
|
||||||
: sortBy === "featured"
|
: $_("magazine.sort.recent")}
|
||||||
? $_("magazine.sort.featured")
|
|
||||||
: $_("magazine.sort.name")}
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
||||||
<!-- <SelectItem value="popular"
|
|
||||||
>{$_("magazine.sort.popular")}</SelectItem
|
|
||||||
> -->
|
|
||||||
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
||||||
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -153,7 +161,7 @@
|
|||||||
|
|
||||||
<div class="container mx-auto px-4 py-12">
|
<div class="container mx-auto px-4 py-12">
|
||||||
<!-- Featured Article -->
|
<!-- Featured Article -->
|
||||||
{#if featuredArticle && categoryFilter === "all" && !searchQuery}
|
{#if featuredArticle}
|
||||||
<Card
|
<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"
|
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 -->
|
<!-- Articles Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<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
|
<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"
|
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filteredArticles().length === 0}
|
{#if data.items.length === 0}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-muted-foreground text-lg mb-4">
|
<p class="text-muted-foreground text-lg mb-4">
|
||||||
{$_("magazine.no_results")}
|
{$_("magazine.no_results")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button variant="outline" href="/magazine" class="border-primary/20 hover:bg-primary/10">
|
||||||
variant="outline"
|
|
||||||
onclick={() => {
|
|
||||||
searchQuery = "";
|
|
||||||
categoryFilter = "all";
|
|
||||||
}}
|
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
{$_("magazine.clear_filters")}
|
{$_("magazine.clear_filters")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { getModels } from "$lib/services";
|
import { getModels } from "$lib/services";
|
||||||
export async function load({ fetch }) {
|
|
||||||
return {
|
const LIMIT = 24;
|
||||||
models: await getModels(fetch),
|
|
||||||
};
|
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">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
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 { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
@@ -7,33 +10,38 @@
|
|||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
let searchQuery = $state("");
|
|
||||||
let sortBy = $state("popular");
|
|
||||||
let categoryFilter = $state("all");
|
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
const filteredModels = $derived(() => {
|
let searchValue = $state(data.search ?? "");
|
||||||
return data.models
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
.filter((model) => {
|
|
||||||
const matchesSearch =
|
function debounceSearch(value: string) {
|
||||||
searchQuery === "" ||
|
clearTimeout(searchTimeout);
|
||||||
model.artist_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
searchTimeout = setTimeout(() => {
|
||||||
model.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
const matchesCategory = categoryFilter === "all";
|
if (value) params.set("search", value);
|
||||||
return matchesSearch && matchesCategory;
|
else params.delete("search");
|
||||||
})
|
params.delete("page");
|
||||||
.sort((a, b) => {
|
goto(`?${params.toString()}`, { keepFocus: true });
|
||||||
// if (sortBy === "popular") {
|
}, 400);
|
||||||
// const aNum = parseInt(a.subscribers.replace(/[^\d]/g, ""));
|
}
|
||||||
// const bNum = parseInt(b.subscribers.replace(/[^\d]/g, ""));
|
|
||||||
// return bNum - aNum;
|
function setParam(key: string, value: string) {
|
||||||
// }
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
// if (sortBy === "rating") return b.rating - a.rating;
|
if (value && value !== "name") params.set(key, value);
|
||||||
// if (sortBy === "videos") return b.videos - a.videos;
|
else params.delete(key);
|
||||||
return (a.artist_name ?? "").localeCompare(b.artist_name ?? "");
|
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>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_("models.title")} description={$_("models.description")} />
|
<Meta title={$_("models.title")} description={$_("models.description")} />
|
||||||
@@ -76,51 +84,25 @@
|
|||||||
></span>
|
></span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("models.search_placeholder")}
|
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"
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Sort -->
|
||||||
<Select type="single" bind:value={sortBy}>
|
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
{sortBy === "popular"
|
{data.sort === "recent" ? $_("models.sort.recent") : $_("models.sort.name")}
|
||||||
? $_("models.sort.popular")
|
|
||||||
: sortBy === "rating"
|
|
||||||
? $_("models.sort.rating")
|
|
||||||
: sortBy === "videos"
|
|
||||||
? $_("models.sort.videos")
|
|
||||||
: $_("models.sort.name")}
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<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="name">{$_("models.sort.name")}</SelectItem>
|
||||||
|
<SelectItem value="recent">{$_("models.sort.recent")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +112,7 @@
|
|||||||
<!-- Models Grid -->
|
<!-- Models Grid -->
|
||||||
<div class="container mx-auto px-4 py-12">
|
<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">
|
<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
|
<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"
|
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filteredModels().length === 0}
|
{#if data.items.length === 0}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-muted-foreground text-lg">{$_("models.no_results")}</p>
|
<p class="text-muted-foreground text-lg">{$_("models.no_results")}</p>
|
||||||
<Button
|
<Button variant="outline" href="/models" class="mt-4">
|
||||||
variant="outline"
|
|
||||||
onclick={() => {
|
|
||||||
searchQuery = "";
|
|
||||||
categoryFilter = "all";
|
|
||||||
}}
|
|
||||||
class="mt-4"
|
|
||||||
>
|
|
||||||
{$_("models.clear_filters")}
|
{$_("models.clear_filters")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import { getItemsByTag } from "$lib/services";
|
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 }) {
|
export async function load({ fetch, params }) {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
tag: params.tag,
|
tag: params.tag,
|
||||||
items: await Promise.all([
|
items: await Promise.all([
|
||||||
getItems("model", params.tag, fetch),
|
getItemsByTag("model", params.tag, fetch).then((items) =>
|
||||||
getItems("video", params.tag, fetch),
|
items?.map((i) => ({ ...i, category: "model", title: i["artist_name"] || i["title"] })),
|
||||||
getItems("article", params.tag, fetch),
|
),
|
||||||
|
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]),
|
]).then(([a, b, c]) => [...a, ...b, ...c]),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { getVideos } from "$lib/services";
|
import { getVideos } from "$lib/services";
|
||||||
export async function load({ fetch }) {
|
|
||||||
return {
|
const LIMIT = 24;
|
||||||
videos: await getVideos(fetch),
|
|
||||||
};
|
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">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
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 { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
@@ -10,40 +13,38 @@
|
|||||||
import { formatVideoDuration } from "$lib/utils";
|
import { formatVideoDuration } from "$lib/utils";
|
||||||
|
|
||||||
const timeAgo = new TimeAgo("en");
|
const timeAgo = new TimeAgo("en");
|
||||||
|
|
||||||
let searchQuery = $state("");
|
|
||||||
let sortBy = $state("recent");
|
|
||||||
let categoryFilter = $state("all");
|
|
||||||
let durationFilter = $state("all");
|
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
const filteredVideos = $derived(() => {
|
let searchValue = $state(data.search ?? "");
|
||||||
return data.videos
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
.filter((video) => {
|
|
||||||
const matchesSearch = video.title.toLowerCase().includes(searchQuery.toLowerCase());
|
function debounceSearch(value: string) {
|
||||||
// ||
|
clearTimeout(searchTimeout);
|
||||||
// video.model.toLowerCase().includes(searchQuery.toLowerCase());
|
searchTimeout = setTimeout(() => {
|
||||||
const matchesCategory = categoryFilter === "all";
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
const matchesDuration =
|
if (value) params.set("search", value);
|
||||||
durationFilter === "all" ||
|
else params.delete("search");
|
||||||
(durationFilter === "short" && (video.movie_file?.duration ?? 0) < 10 * 60) ||
|
params.delete("page");
|
||||||
(durationFilter === "medium" &&
|
goto(`?${params.toString()}`, { keepFocus: true });
|
||||||
(video.movie_file?.duration ?? 0) >= 10 * 60 &&
|
}, 400);
|
||||||
(video.movie_file?.duration ?? 0) < 20 * 60) ||
|
}
|
||||||
(durationFilter === "long" && (video.movie_file?.duration ?? 0) >= 20 * 60);
|
|
||||||
return matchesSearch && matchesCategory && matchesDuration;
|
function setParam(key: string, value: string) {
|
||||||
})
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
.sort((a, b) => {
|
if (value && value !== "all" && value !== "recent") params.set(key, value);
|
||||||
if (sortBy === "recent")
|
else params.delete(key);
|
||||||
return new Date(b.upload_date).getTime() - new Date(a.upload_date).getTime();
|
params.delete("page");
|
||||||
if (sortBy === "most_liked") return (b.likes_count || 0) - (a.likes_count || 0);
|
goto(`?${params.toString()}`);
|
||||||
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);
|
function goToPage(p: number) {
|
||||||
return a.title.localeCompare(b.title);
|
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>
|
</script>
|
||||||
|
|
||||||
<Meta title={$_("videos.title")} description={$_("videos.description")} />
|
<Meta title={$_("videos.title")} description={$_("videos.description")} />
|
||||||
@@ -90,49 +91,32 @@
|
|||||||
></span>
|
></span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("videos.search_placeholder")}
|
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"
|
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Duration Filter -->
|
||||||
<Select type="single" bind:value={durationFilter}>
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={data.duration}
|
||||||
|
onValueChange={(v) => v && setParam("duration", v)}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
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>
|
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
|
||||||
{durationFilter === "all"
|
{data.duration === "short"
|
||||||
? $_("videos.duration.all")
|
? $_("videos.duration.short")
|
||||||
: durationFilter === "short"
|
: data.duration === "medium"
|
||||||
? $_("videos.duration.short")
|
? $_("videos.duration.medium")
|
||||||
: durationFilter === "medium"
|
: data.duration === "long"
|
||||||
? $_("videos.duration.medium")
|
? $_("videos.duration.long")
|
||||||
: $_("videos.duration.long")}
|
: $_("videos.duration.all")}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
|
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
|
||||||
@@ -143,25 +127,22 @@
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<!-- Sort -->
|
<!-- Sort -->
|
||||||
<Select type="single" bind:value={sortBy}>
|
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||||
>
|
>
|
||||||
{sortBy === "recent"
|
{data.sort === "most_liked"
|
||||||
? $_("videos.sort.recent")
|
? $_("videos.sort.most_liked")
|
||||||
: sortBy === "most_liked"
|
: data.sort === "most_played"
|
||||||
? $_("videos.sort.most_liked")
|
? $_("videos.sort.most_played")
|
||||||
: sortBy === "most_played"
|
: data.sort === "name"
|
||||||
? $_("videos.sort.most_played")
|
? $_("videos.sort.name")
|
||||||
: sortBy === "duration"
|
: $_("videos.sort.recent")}
|
||||||
? $_("videos.sort.duration")
|
|
||||||
: $_("videos.sort.name")}
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
|
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
|
||||||
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
|
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
|
||||||
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
|
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
|
||||||
<SelectItem value="duration">{$_("videos.sort.duration")}</SelectItem>
|
|
||||||
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
|
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -172,7 +153,7 @@
|
|||||||
<!-- Videos Grid -->
|
<!-- Videos Grid -->
|
||||||
<div class="container mx-auto px-4 py-12">
|
<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">
|
<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
|
<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"
|
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filteredVideos().length === 0}
|
{#if data.items.length === 0}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-muted-foreground text-lg mb-4">
|
<p class="text-muted-foreground text-lg mb-4">
|
||||||
{$_("videos.no_results")}
|
{$_("videos.no_results")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button variant="outline" href="/videos" class="border-primary/20 hover:bg-primary/10">
|
||||||
variant="outline"
|
|
||||||
onclick={() => {
|
|
||||||
searchQuery = "";
|
|
||||||
categoryFilter = "all";
|
|
||||||
durationFilter = "all";
|
|
||||||
}}
|
|
||||||
class="border-primary/20 hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
{$_("videos.clear_filters")}
|
{$_("videos.clear_filters")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user