Compare commits
2 Commits
c90c09da9a
...
f2871b98db
| Author | SHA1 | Date | |
|---|---|---|---|
| f2871b98db | |||
| 9c5dba5c90 |
@@ -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] }),
|
});
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -320,6 +320,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,50 +1,49 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
export const badgeVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
secondary:
|
||||||
secondary:
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
destructive:
|
||||||
destructive:
|
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
},
|
||||||
},
|
},
|
||||||
},
|
defaultVariants: {
|
||||||
defaultVariants: {
|
variant: "default",
|
||||||
variant: "default",
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href,
|
href,
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
<svelte:element
|
||||||
this={href ? "a" : "span"}
|
this={href ? "a" : "span"}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
{href}
|
{href}
|
||||||
class={cn(badgeVariants({ variant }), className)}
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
|||||||
@@ -1,76 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from "svelte";
|
||||||
import type Calendar from "./calendar.svelte";
|
import type Calendar from "./calendar.svelte";
|
||||||
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||||
import CalendarYearSelect from "./calendar-year-select.svelte";
|
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
captionLayout,
|
captionLayout,
|
||||||
months,
|
months,
|
||||||
monthFormat,
|
monthFormat,
|
||||||
years,
|
years,
|
||||||
yearFormat,
|
yearFormat,
|
||||||
month,
|
month,
|
||||||
locale,
|
locale,
|
||||||
placeholder = $bindable(),
|
placeholder = $bindable(),
|
||||||
monthIndex = 0,
|
monthIndex = 0,
|
||||||
}: {
|
}: {
|
||||||
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||||
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||||
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||||
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||||
month: DateValue;
|
month: DateValue;
|
||||||
placeholder: DateValue | undefined;
|
placeholder: DateValue | undefined;
|
||||||
locale: string;
|
locale: string;
|
||||||
monthIndex: number;
|
monthIndex: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function formatYear(date: DateValue) {
|
function formatYear(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonth(date: DateValue) {
|
function formatMonth(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet MonthSelect()}
|
{#snippet MonthSelect()}
|
||||||
<CalendarMonthSelect
|
<CalendarMonthSelect
|
||||||
{months}
|
{months}
|
||||||
{monthFormat}
|
{monthFormat}
|
||||||
value={month.month}
|
value={month.month}
|
||||||
onchange={(e) => {
|
onchange={(e) => {
|
||||||
if (!placeholder) return;
|
if (!placeholder) return;
|
||||||
const v = Number.parseInt(e.currentTarget.value);
|
const v = Number.parseInt(e.currentTarget.value);
|
||||||
const newPlaceholder = placeholder.set({ month: v });
|
const newPlaceholder = placeholder.set({ month: v });
|
||||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet YearSelect()}
|
{#snippet YearSelect()}
|
||||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if captionLayout === "dropdown"}
|
{#if captionLayout === "dropdown"}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{@render YearSelect()}
|
{@render YearSelect()}
|
||||||
{:else if captionLayout === "dropdown-months"}
|
{:else if captionLayout === "dropdown-months"}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatYear(placeholder)}
|
{formatYear(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if captionLayout === "dropdown-years"}
|
{:else if captionLayout === "dropdown-years"}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatMonth(placeholder)}
|
{formatMonth(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
{@render YearSelect()}
|
{@render YearSelect()}
|
||||||
{:else}
|
{:else}
|
||||||
{formatMonth(month)} {formatYear(month)}
|
{formatMonth(month)} {formatYear(month)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.CellProps = $props();
|
}: CalendarPrimitive.CellProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Cell
|
<CalendarPrimitive.Cell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.DayProps = $props();
|
}: CalendarPrimitive.DayProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Day
|
<CalendarPrimitive.Day
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||||
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||||
// Outside months
|
// Outside months
|
||||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||||
// Disabled
|
// Disabled
|
||||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
// Unavailable
|
// Unavailable
|
||||||
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||||
// hover
|
// hover
|
||||||
"dark:hover:text-accent-foreground",
|
"dark:hover:text-accent-foreground",
|
||||||
// focus
|
// focus
|
||||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||||
// inner spans
|
// inner spans
|
||||||
"[&>span]:text-xs [&>span]:opacity-70",
|
"[&>span]:text-xs [&>span]:opacity-70",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridBodyProps = $props();
|
}: CalendarPrimitive.GridBodyProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridHeadProps = $props();
|
}: CalendarPrimitive.GridHeadProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridRowProps = $props();
|
}: CalendarPrimitive.GridRowProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridProps = $props();
|
}: CalendarPrimitive.GridProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Grid
|
<CalendarPrimitive.Grid
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.HeadCellProps = $props();
|
}: CalendarPrimitive.HeadCellProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.HeadCell
|
<CalendarPrimitive.HeadCell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.HeaderProps = $props();
|
}: CalendarPrimitive.HeaderProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Header
|
<CalendarPrimitive.Header
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.HeadingProps = $props();
|
}: CalendarPrimitive.HeadingProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Heading
|
<CalendarPrimitive.Heading
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
value,
|
value,
|
||||||
onchange,
|
onchange,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarPrimitive.MonthSelect
|
<CalendarPrimitive.MonthSelect
|
||||||
bind:ref
|
bind:ref
|
||||||
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||||
<select {...props} {value} {onchange}>
|
<select {...props} {value} {onchange}>
|
||||||
{#each monthItems as monthItem (monthItem.value)}
|
{#each monthItems as monthItem (monthItem.value)}
|
||||||
<option
|
<option
|
||||||
value={monthItem.value}
|
value={monthItem.value}
|
||||||
selected={value !== undefined
|
selected={value !== undefined
|
||||||
? monthItem.value === value
|
? monthItem.value === value
|
||||||
: monthItem.value === selectedMonthItem.value}
|
: monthItem.value === selectedMonthItem.value}
|
||||||
>
|
>
|
||||||
{monthItem.label}
|
{monthItem.label}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
<ChevronDownIcon class="size-4" />
|
<ChevronDownIcon class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarPrimitive.MonthSelect>
|
</CalendarPrimitive.MonthSelect>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
{...restProps}
|
{...restProps}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.NextButtonProps & {
|
}: CalendarPrimitive.NextButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Fallback()}
|
{#snippet Fallback()}
|
||||||
<ChevronRightIcon class="size-4" />
|
<ChevronRightIcon class="size-4" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<CalendarPrimitive.NextButton
|
<CalendarPrimitive.NextButton
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.PrevButtonProps & {
|
}: CalendarPrimitive.PrevButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Fallback()}
|
{#snippet Fallback()}
|
||||||
<ChevronLeftIcon class="size-4" />
|
<ChevronLeftIcon class="size-4" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<CalendarPrimitive.PrevButton
|
<CalendarPrimitive.PrevButton
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
value,
|
value,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarPrimitive.YearSelect
|
<CalendarPrimitive.YearSelect
|
||||||
bind:ref
|
bind:ref
|
||||||
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||||
<select {...props} {value}>
|
<select {...props} {value}>
|
||||||
{#each yearItems as yearItem (yearItem.value)}
|
{#each yearItems as yearItem (yearItem.value)}
|
||||||
<option
|
<option
|
||||||
value={yearItem.value}
|
value={yearItem.value}
|
||||||
selected={value !== undefined
|
selected={value !== undefined
|
||||||
? yearItem.value === value
|
? yearItem.value === value
|
||||||
: yearItem.value === selectedYearItem.value}
|
: yearItem.value === selectedYearItem.value}
|
||||||
>
|
>
|
||||||
{yearItem.label}
|
{yearItem.label}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||||
<ChevronDownIcon class="size-4" />
|
<ChevronDownIcon class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarPrimitive.YearSelect>
|
</CalendarPrimitive.YearSelect>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import * as Calendar from "./index.js";
|
import * as Calendar from "./index.js";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import type { ButtonVariant } from "../button/button.svelte";
|
import type { ButtonVariant } from "../button/button.svelte";
|
||||||
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
placeholder = $bindable(),
|
placeholder = $bindable(),
|
||||||
class: className,
|
class: className,
|
||||||
weekdayFormat = "short",
|
weekdayFormat = "short",
|
||||||
buttonVariant = "ghost",
|
buttonVariant = "ghost",
|
||||||
captionLayout = "label",
|
captionLayout = "label",
|
||||||
locale = "en-US",
|
locale = "en-US",
|
||||||
months: monthsProp,
|
months: monthsProp,
|
||||||
years,
|
years,
|
||||||
monthFormat: monthFormatProp,
|
monthFormat: monthFormatProp,
|
||||||
yearFormat = "numeric",
|
yearFormat = "numeric",
|
||||||
day,
|
day,
|
||||||
disableDaysOutsideMonth = false,
|
disableDaysOutsideMonth = false,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||||
buttonVariant?: ButtonVariant;
|
buttonVariant?: ButtonVariant;
|
||||||
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||||
months?: CalendarPrimitive.MonthSelectProps["months"];
|
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||||
years?: CalendarPrimitive.YearSelectProps["years"];
|
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||||
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||||
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const monthFormat = $derived.by(() => {
|
const monthFormat = $derived.by(() => {
|
||||||
if (monthFormatProp) return monthFormatProp;
|
if (monthFormatProp) return monthFormatProp;
|
||||||
if (captionLayout.startsWith("dropdown")) return "short";
|
if (captionLayout.startsWith("dropdown")) return "short";
|
||||||
return "long";
|
return "long";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -44,72 +44,72 @@ Discriminated Unions + Destructing (required for bindable) do not
|
|||||||
get along, so we shut typescript up by casting `value` to `never`.
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
-->
|
-->
|
||||||
<CalendarPrimitive.Root
|
<CalendarPrimitive.Root
|
||||||
bind:value={value as never}
|
bind:value={value as never}
|
||||||
bind:ref
|
bind:ref
|
||||||
bind:placeholder
|
bind:placeholder
|
||||||
{weekdayFormat}
|
{weekdayFormat}
|
||||||
{disableDaysOutsideMonth}
|
{disableDaysOutsideMonth}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{locale}
|
{locale}
|
||||||
{monthFormat}
|
{monthFormat}
|
||||||
{yearFormat}
|
{yearFormat}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet children({ months, weekdays })}
|
{#snippet children({ months, weekdays })}
|
||||||
<Calendar.Months>
|
<Calendar.Months>
|
||||||
<Calendar.Nav>
|
<Calendar.Nav>
|
||||||
<Calendar.PrevButton variant={buttonVariant} />
|
<Calendar.PrevButton variant={buttonVariant} />
|
||||||
<Calendar.NextButton variant={buttonVariant} />
|
<Calendar.NextButton variant={buttonVariant} />
|
||||||
</Calendar.Nav>
|
</Calendar.Nav>
|
||||||
{#each months as month, monthIndex (month)}
|
{#each months as month, monthIndex (month)}
|
||||||
<Calendar.Month>
|
<Calendar.Month>
|
||||||
<Calendar.Header>
|
<Calendar.Header>
|
||||||
<Calendar.Caption
|
<Calendar.Caption
|
||||||
{captionLayout}
|
{captionLayout}
|
||||||
months={monthsProp}
|
months={monthsProp}
|
||||||
{monthFormat}
|
{monthFormat}
|
||||||
{years}
|
{years}
|
||||||
{yearFormat}
|
{yearFormat}
|
||||||
month={month.value}
|
month={month.value}
|
||||||
bind:placeholder
|
bind:placeholder
|
||||||
{locale}
|
{locale}
|
||||||
{monthIndex}
|
{monthIndex}
|
||||||
/>
|
/>
|
||||||
</Calendar.Header>
|
</Calendar.Header>
|
||||||
<Calendar.Grid>
|
<Calendar.Grid>
|
||||||
<Calendar.GridHead>
|
<Calendar.GridHead>
|
||||||
<Calendar.GridRow class="select-none">
|
<Calendar.GridRow class="select-none">
|
||||||
{#each weekdays as weekday (weekday)}
|
{#each weekdays as weekday (weekday)}
|
||||||
<Calendar.HeadCell>
|
<Calendar.HeadCell>
|
||||||
{weekday.slice(0, 2)}
|
{weekday.slice(0, 2)}
|
||||||
</Calendar.HeadCell>
|
</Calendar.HeadCell>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.GridRow>
|
</Calendar.GridRow>
|
||||||
</Calendar.GridHead>
|
</Calendar.GridHead>
|
||||||
<Calendar.GridBody>
|
<Calendar.GridBody>
|
||||||
{#each month.weeks as weekDates (weekDates)}
|
{#each month.weeks as weekDates (weekDates)}
|
||||||
<Calendar.GridRow class="mt-2 w-full">
|
<Calendar.GridRow class="mt-2 w-full">
|
||||||
{#each weekDates as date (date)}
|
{#each weekDates as date (date)}
|
||||||
<Calendar.Cell {date} month={month.value}>
|
<Calendar.Cell {date} month={month.value}>
|
||||||
{#if day}
|
{#if day}
|
||||||
{@render day({
|
{@render day({
|
||||||
day: date,
|
day: date,
|
||||||
outsideMonth: !isEqualMonth(date, month.value),
|
outsideMonth: !isEqualMonth(date, month.value),
|
||||||
})}
|
})}
|
||||||
{:else}
|
{:else}
|
||||||
<Calendar.Day />
|
<Calendar.Day />
|
||||||
{/if}
|
{/if}
|
||||||
</Calendar.Cell>
|
</Calendar.Cell>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.GridRow>
|
</Calendar.GridRow>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.GridBody>
|
</Calendar.GridBody>
|
||||||
</Calendar.Grid>
|
</Calendar.Grid>
|
||||||
</Calendar.Month>
|
</Calendar.Month>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.Months>
|
</Calendar.Months>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarPrimitive.Root>
|
</CalendarPrimitive.Root>
|
||||||
|
|||||||
@@ -18,23 +18,23 @@ import Nav from "./calendar-nav.svelte";
|
|||||||
import Caption from "./calendar-caption.svelte";
|
import Caption from "./calendar-caption.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Day,
|
Day,
|
||||||
Cell,
|
Cell,
|
||||||
Grid,
|
Grid,
|
||||||
Header,
|
Header,
|
||||||
Months,
|
Months,
|
||||||
GridRow,
|
GridRow,
|
||||||
Heading,
|
Heading,
|
||||||
GridBody,
|
GridBody,
|
||||||
GridHead,
|
GridHead,
|
||||||
HeadCell,
|
HeadCell,
|
||||||
NextButton,
|
NextButton,
|
||||||
PrevButton,
|
PrevButton,
|
||||||
Nav,
|
Nav,
|
||||||
Month,
|
Month,
|
||||||
YearSelect,
|
YearSelect,
|
||||||
MonthSelect,
|
MonthSelect,
|
||||||
Caption,
|
Caption,
|
||||||
//
|
//
|
||||||
Root as Calendar,
|
Root as Calendar,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import Trigger from "./popover-trigger.svelte";
|
|||||||
import Portal from "./popover-portal.svelte";
|
import Portal from "./popover-portal.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Content,
|
Content,
|
||||||
Trigger,
|
Trigger,
|
||||||
Close,
|
Close,
|
||||||
Portal,
|
Portal,
|
||||||
//
|
//
|
||||||
Root as Popover,
|
Root as Popover,
|
||||||
Content as PopoverContent,
|
Content as PopoverContent,
|
||||||
Trigger as PopoverTrigger,
|
Trigger as PopoverTrigger,
|
||||||
Close as PopoverClose,
|
Close as PopoverClose,
|
||||||
Portal as PopoverPortal,
|
Portal as PopoverPortal,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
import PopoverPortal from "./popover-portal.svelte";
|
import PopoverPortal from "./popover-portal.svelte";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = "center",
|
align = "center",
|
||||||
portalProps,
|
portalProps,
|
||||||
...restProps
|
...restProps
|
||||||
}: PopoverPrimitive.ContentProps & {
|
}: PopoverPrimitive.ContentProps & {
|
||||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPortal {...portalProps}>
|
<PopoverPortal {...portalProps}>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
{align}
|
{align}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
</PopoverPortal>
|
</PopoverPortal>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Portal {...restProps} />
|
<PopoverPrimitive.Portal {...restProps} />
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: PopoverPrimitive.TriggerProps = $props();
|
}: PopoverPrimitive.TriggerProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Trigger
|
<PopoverPrimitive.Trigger
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="popover-trigger"
|
data-slot="popover-trigger"
|
||||||
class={cn("", className)}
|
class={cn("", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Root bind:open {...restProps} />
|
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,10 +17,12 @@
|
|||||||
|
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
|
|
||||||
<!-- Mobile top nav -->
|
<!-- Mobile top nav -->
|
||||||
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40">
|
<div class="lg:hidden flex items-center gap-2 py-3 border-b border-border/40">
|
||||||
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2">
|
<a
|
||||||
|
href="/"
|
||||||
|
class="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2"
|
||||||
|
>
|
||||||
{$_("admin.nav.back_mobile")}
|
{$_("admin.nav.back_mobile")}
|
||||||
</a>
|
</a>
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ export async function load({ params, fetch, cookies }) {
|
|||||||
const token = cookies.get("session_token") || "";
|
const token = cookies.get("session_token") || "";
|
||||||
const [articles, modelsResult] = await Promise.all([
|
const [articles, modelsResult] = await Promise.all([
|
||||||
adminListArticles(fetch, token).catch(() => []),
|
adminListArticles(fetch, token).catch(() => []),
|
||||||
adminListUsers({ role: "model", limit: 200 }, fetch, token).catch(() => ({ items: [], total: 0 })),
|
adminListUsers({ role: "model", limit: 200 }, fetch, token).catch(() => ({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
})),
|
||||||
]);
|
]);
|
||||||
const article = articles.find((a) => a.id === params.id);
|
const article = articles.find((a) => a.id === params.id);
|
||||||
if (!article) throw error(404, "Article not found");
|
if (!article) throw error(404, "Article not found");
|
||||||
|
|||||||
@@ -107,13 +107,13 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
onclick={() => (editorTab = "write")}
|
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
|
||||||
>{$_("admin.common.write")}</button>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
onclick={() => (editorTab = "preview")}
|
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
|
||||||
>{$_("admin.common.preview")}</button>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||||
@@ -127,7 +127,9 @@
|
|||||||
{#if preview}
|
{#if preview}
|
||||||
{@html preview}
|
{@html preview}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-muted-foreground italic text-sm">{$_("admin.article_form.preview_placeholder")}</p>
|
<p class="text-muted-foreground italic text-sm">
|
||||||
|
{$_("admin.article_form.preview_placeholder")}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +154,11 @@
|
|||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full">
|
||||||
{#if selectedAuthor}
|
{#if selectedAuthor}
|
||||||
{#if selectedAuthor.avatar}
|
{#if selectedAuthor.avatar}
|
||||||
<img src={getAssetUrl(selectedAuthor.avatar, "mini")} alt="" class="h-5 w-5 rounded-full object-cover shrink-0" />
|
<img
|
||||||
|
src={getAssetUrl(selectedAuthor.avatar, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{selectedAuthor.artist_name}
|
{selectedAuthor.artist_name}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -164,7 +170,11 @@
|
|||||||
{#each data.authors as author (author.id)}
|
{#each data.authors as author (author.id)}
|
||||||
<SelectItem value={author.id}>
|
<SelectItem value={author.id}>
|
||||||
{#if author.avatar}
|
{#if author.avatar}
|
||||||
<img src={getAssetUrl(author.avatar, "mini")} alt="" class="h-5 w-5 rounded-full object-cover shrink-0" />
|
<img
|
||||||
|
src={getAssetUrl(author.avatar, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{author.artist_name}
|
{author.artist_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -195,7 +205,11 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<Button onclick={handleSubmit} disabled={saving} class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
|
<Button
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
||||||
|
|||||||
@@ -98,13 +98,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.article_form.slug_placeholder")} />
|
<Input
|
||||||
|
id="slug"
|
||||||
|
bind:value={slug}
|
||||||
|
placeholder={$_("admin.article_form.slug_placeholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||||
<Textarea id="excerpt" bind:value={excerpt} placeholder={$_("admin.article_form.excerpt_placeholder")} rows={2} />
|
<Textarea
|
||||||
|
id="excerpt"
|
||||||
|
bind:value={excerpt}
|
||||||
|
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Markdown editor with live preview -->
|
<!-- Markdown editor with live preview -->
|
||||||
@@ -115,13 +124,13 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
onclick={() => (editorTab = "write")}
|
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
|
||||||
>{$_("admin.common.write")}</button>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
onclick={() => (editorTab = "preview")}
|
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
|
||||||
>{$_("admin.common.preview")}</button>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile: single pane toggled; Desktop: side by side -->
|
<!-- Mobile: single pane toggled; Desktop: side by side -->
|
||||||
@@ -137,7 +146,9 @@
|
|||||||
{#if preview}
|
{#if preview}
|
||||||
{@html preview}
|
{@html preview}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-muted-foreground italic text-sm">{$_("admin.article_form.preview_placeholder")}</p>
|
<p class="text-muted-foreground italic text-sm">
|
||||||
|
{$_("admin.article_form.preview_placeholder")}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,13 +157,19 @@
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
{#if imageId}<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>{/if}
|
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
||||||
|
{$_("admin.common.image_uploaded")} ✓
|
||||||
|
</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||||
<Input id="category" bind:value={category} placeholder={$_("admin.article_form.category_placeholder")} />
|
<Input
|
||||||
|
id="category"
|
||||||
|
bind:value={category}
|
||||||
|
placeholder={$_("admin.article_form.category_placeholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
@@ -171,7 +188,11 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<Button onclick={handleSubmit} disabled={saving} class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
|
<Button
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
|
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
||||||
|
|||||||
@@ -87,7 +87,9 @@
|
|||||||
<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.users.title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
|
||||||
<span class="text-sm text-muted-foreground">{$_("admin.users.total", { values: { total: data.total } })}</span>
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -120,11 +122,21 @@
|
|||||||
<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.users.col_user")}</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.users.col_email")}</th>
|
>{$_("admin.users.col_user")}</th
|
||||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">{$_("admin.users.col_role")}</th>
|
>
|
||||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">{$_("admin.users.col_joined")}</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.users.col_email")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.users.col_role")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
|
||||||
|
>{$_("admin.users.col_joined")}</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">
|
||||||
@@ -147,12 +159,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="font-medium truncate">{user.artist_name || user.first_name || "—"}</span>
|
<span class="font-medium truncate"
|
||||||
|
>{user.artist_name || user.first_name || "—"}</span
|
||||||
|
>
|
||||||
{#if user.is_admin}
|
{#if user.is_admin}
|
||||||
<Badge variant="default" class="shrink-0 text-[10px] px-1.5 py-0">{$_("admin.users.admin_badge")}</Badge>
|
<Badge variant="default" class="shrink-0 text-[10px] px-1.5 py-0"
|
||||||
|
>{$_("admin.users.admin_badge")}</Badge
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-muted-foreground sm:hidden truncate block">{user.email}</span>
|
<span class="text-xs text-muted-foreground sm:hidden truncate block"
|
||||||
|
>{user.email}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -173,7 +191,9 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">{formatDate(user.date_created)}</td>
|
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
|
||||||
|
>{formatDate(user.date_created)}</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/users/{user.id}">
|
<Button size="sm" variant="ghost" href="/admin/users/{user.id}">
|
||||||
@@ -195,7 +215,9 @@
|
|||||||
|
|
||||||
{#if data.items.length === 0}
|
{#if data.items.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">{$_("admin.users.no_results")}</td>
|
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>{$_("admin.users.no_results")}</td
|
||||||
|
>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -206,7 +228,13 @@
|
|||||||
{#if data.total > data.limit}
|
{#if data.total > data.limit}
|
||||||
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
||||||
<span class="text-sm text-muted-foreground">
|
<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 } })}
|
{$_("admin.users.showing", {
|
||||||
|
values: {
|
||||||
|
start: data.offset + 1,
|
||||||
|
end: Math.min(data.offset + data.limit, data.total),
|
||||||
|
total: data.total,
|
||||||
|
},
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -244,7 +272,9 @@
|
|||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{$_("admin.users.delete_title")}</Dialog.Title>
|
<Dialog.Title>{$_("admin.users.delete_title")}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
{$_("admin.users.delete_description", { values: { name: deleteTarget?.artist_name || deleteTarget?.email } })}
|
{$_("admin.users.delete_description", {
|
||||||
|
values: { name: deleteTarget?.artist_name || deleteTarget?.email },
|
||||||
|
})}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
|
|||||||
@@ -106,7 +106,11 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
||||||
<p class="text-xs text-muted-foreground">{data.user.email} · {data.user.role}{data.user.is_admin ? " · " + $_("admin.users.admin_badge").toLowerCase() : ""}</p>
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{data.user.email} · {data.user.role}{data.user.is_admin
|
||||||
|
? " · " + $_("admin.users.admin_badge").toLowerCase()
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,8 +159,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin flag -->
|
<!-- Admin flag -->
|
||||||
<label class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors">
|
<label
|
||||||
<input type="checkbox" bind:checked={isAdmin} class="h-4 w-4 rounded accent-primary shrink-0" />
|
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={isAdmin}
|
||||||
|
class="h-4 w-4 rounded accent-primary shrink-0"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
|
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
|
||||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
|
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
|
||||||
@@ -164,7 +174,11 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button onclick={handleSave} disabled={saving} class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
|
<Button
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -97,7 +97,11 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
<Input id="title" bind:value={title} placeholder={$_("admin.video_form.title_placeholder")} />
|
<Input
|
||||||
|
id="title"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder={$_("admin.video_form.title_placeholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
@@ -107,7 +111,12 @@
|
|||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||||
<Textarea id="description" bind:value={description} placeholder={$_("admin.video_form.description_placeholder")} rows={3} />
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder={$_("admin.video_form.description_placeholder")}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -127,7 +136,7 @@
|
|||||||
{#if movieId}
|
{#if movieId}
|
||||||
<video
|
<video
|
||||||
src={getAssetUrl(movieId)}
|
src={getAssetUrl(movieId)}
|
||||||
poster={imageId ? getAssetUrl(imageId, "preview") ?? undefined : undefined}
|
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
|
||||||
controls
|
controls
|
||||||
class="w-full rounded-lg bg-black max-h-72 mb-2"
|
class="w-full rounded-lg bg-black max-h-72 mb-2"
|
||||||
></video>
|
></video>
|
||||||
@@ -142,7 +151,11 @@
|
|||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
<DatePicker bind:value={uploadDate} placeholder={$_("admin.common.publish_date")} showTime={false} />
|
<DatePicker
|
||||||
|
bind:value={uploadDate}
|
||||||
|
placeholder={$_("admin.common.publish_date")}
|
||||||
|
showTime={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
@@ -162,7 +175,9 @@
|
|||||||
<Select type="multiple" bind:value={selectedModelIds}>
|
<Select type="multiple" bind:value={selectedModelIds}>
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full">
|
||||||
{#if selectedModelIds.length}
|
{#if selectedModelIds.length}
|
||||||
{$_("admin.video_form.models_selected", { values: { count: selectedModelIds.length } })}
|
{$_("admin.video_form.models_selected", {
|
||||||
|
values: { count: selectedModelIds.length },
|
||||||
|
})}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
|
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -171,7 +186,11 @@
|
|||||||
{#each data.models as model (model.id)}
|
{#each data.models as model (model.id)}
|
||||||
<SelectItem value={model.id}>
|
<SelectItem value={model.id}>
|
||||||
{#if model.avatar}
|
{#if model.avatar}
|
||||||
<img src={getAssetUrl(model.avatar, "mini")} alt="" class="h-5 w-5 rounded-full object-cover shrink-0" />
|
<img
|
||||||
|
src={getAssetUrl(model.avatar, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{model.artist_name}
|
{model.artist_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -182,7 +201,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<Button onclick={handleSubmit} disabled={saving} class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
|
<Button
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
||||||
|
|||||||
@@ -137,13 +137,17 @@
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
{#if imageId}<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>{/if}
|
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
||||||
|
{$_("admin.common.image_uploaded")} ✓
|
||||||
|
</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||||
{#if movieId}<p class="text-xs text-green-600 mt-1">{$_("admin.video_form.video_uploaded")} ✓</p>{/if}
|
{#if movieId}<p class="text-xs text-green-600 mt-1">
|
||||||
|
{$_("admin.video_form.video_uploaded")} ✓
|
||||||
|
</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -153,7 +157,11 @@
|
|||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
<DatePicker bind:value={uploadDate} placeholder={$_("admin.common.publish_date")} showTime={false} />
|
<DatePicker
|
||||||
|
bind:value={uploadDate}
|
||||||
|
placeholder={$_("admin.common.publish_date")}
|
||||||
|
showTime={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
@@ -189,7 +197,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<Button onclick={handleSubmit} disabled={saving} class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90">
|
<Button
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
|
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
||||||
|
|||||||
@@ -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