refactor: replace all explicit any types with proper TypeScript types

Backend resolvers: typed enrichArticle/enrichVideo/enrichModel with DB
and $inferSelect types, SQL<unknown>[] for conditions arrays, proper
enum casts for status/role fields, $inferInsert for .set() updates,
typed raw SQL result rows in gamification, ReplyLike interface for
ctx.reply in auth. Frontend: typed catch blocks with Error/interface
casts, isActiveLink param, adminGetUser response, tags filter callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 19:25:04 +01:00
parent 8313664d70
commit e236ced12a
30 changed files with 392 additions and 375 deletions

View File

@@ -1,10 +1,11 @@
import { builder } from "../builder";
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
import { articles, users } from "../../db/schema/index";
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains } from "drizzle-orm";
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection";
async function enrichArticle(db: any, article: any) {
async function enrichArticle(db: DB, article: typeof articles.$inferSelect) {
let author = null;
if (article.author) {
const authorUser = await db
@@ -39,7 +40,7 @@ builder.queryField("articles", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [lte(articles.publish_date, new Date())];
const conditions: SQL<unknown>[] = [lte(articles.publish_date, new Date())];
if (args.featured !== null && args.featured !== undefined) {
conditions.push(eq(articles.featured, args.featured));
}
@@ -50,28 +51,24 @@ builder.queryField("articles", (t) =>
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
) as SQL<unknown>,
);
}
const orderArgs =
args.sortBy === "name"
? [asc(articles.title)]
: args.sortBy === "featured"
? [desc(articles.featured), desc(articles.publish_date)]
: [desc(articles.publish_date)];
const where = and(...conditions);
const baseQuery = ctx.db.select().from(articles).where(where);
const ordered =
args.sortBy === "name"
? baseQuery.orderBy(asc(articles.title))
: args.sortBy === "featured"
? baseQuery.orderBy(desc(articles.featured), desc(articles.publish_date))
: baseQuery.orderBy(desc(articles.publish_date));
const [articleList, totalRows] = await Promise.all([
(ctx.db.select().from(articles).where(where) as any)
.orderBy(...orderArgs)
.limit(pageSize)
.offset(offset),
ordered.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)),
);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -130,13 +127,13 @@ builder.queryField("adminListArticles", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) {
conditions.push(
or(
ilike(articles.title, `%${args.search}%`),
ilike(articles.excerpt, `%${args.search}%`),
),
) as SQL<unknown>,
);
}
if (args.category) conditions.push(eq(articles.category, args.category));
@@ -154,9 +151,7 @@ builder.queryField("adminListArticles", (t) =>
.offset(offset),
ctx.db.select({ total: count() }).from(articles).where(where),
]);
const items = await Promise.all(
articleList.map((article: any) => enrichArticle(ctx.db, article)),
);
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -232,7 +227,7 @@ builder.mutationField("updateArticle", (t) =>
const updated = await ctx.db
.update(articles)
.set(updates as any)
.set(updates as Partial<typeof articles.$inferInsert>)
.where(eq(articles.id, args.id))
.returning();
if (!updated[0]) return null;

View File

@@ -3,6 +3,10 @@ import { builder } from "../builder";
import { CurrentUserType } from "../types/index";
import { users } from "../../db/schema/index";
import { eq } from "drizzle-orm";
interface ReplyLike {
header?: (name: string, value: string) => void;
}
import { hash, verify as verifyArgon } from "../../lib/argon";
import { setSession, deleteSession } from "../../lib/auth";
import { sendVerification, sendPasswordReset } from "../../lib/email";
@@ -46,12 +50,7 @@ builder.mutationField("login", (t) =>
// Set session cookie
const isProduction = process.env.NODE_ENV === "production";
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
// For graphql-yoga response
if ((ctx as any).serverResponse) {
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
}
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
return user[0];
},
@@ -76,7 +75,7 @@ builder.mutationField("logout", (t) =>
// Clear cookie
const isProduction = process.env.NODE_ENV === "production";
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
return true;
},
}),

View File

@@ -20,7 +20,7 @@ builder.queryField("commentsForVideo", (t) =>
.orderBy(desc(comments.date_created));
return Promise.all(
commentList.map(async (c: any) => {
commentList.map(async (c) => {
const user = await ctx.db
.select({
id: users.id,
@@ -125,7 +125,7 @@ builder.queryField("adminListComments", (t) =>
]);
const items = await Promise.all(
commentList.map(async (c: any) => {
commentList.map(async (c) => {
const user = await ctx.db
.select({
id: users.id,

View File

@@ -37,7 +37,7 @@ builder.queryField("leaderboard", (t) =>
.limit(limit)
.offset(offset);
return entries.map((e: any, i: number) => ({ ...e, rank: offset + i + 1 }));
return entries.map((e, i) => ({ ...e, rank: offset + i + 1 }));
},
}),
);
@@ -101,7 +101,7 @@ builder.queryField("userGamification", (t) =>
return {
stats: stats[0] ? { ...stats[0], rank } : null,
achievements: userAchievements.map((a: any) => ({
achievements: userAchievements.map((a) => ({
...a,
date_unlocked: a.date_unlocked!,
})),

View File

@@ -1,9 +1,10 @@
import { builder } from "../builder";
import { ModelType, ModelListType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, and, desc, asc, ilike, count, arrayContains } from "drizzle-orm";
import { eq, and, desc, asc, ilike, count, arrayContains, type SQL } from "drizzle-orm";
import type { DB } from "../../db/connection";
async function enrichModel(db: any, user: any) {
async function enrichModel(db: DB, user: typeof users.$inferSelect) {
// Fetch photos
const photoRows = await db
.select({ id: files.id, filename: files.filename })
@@ -14,8 +15,8 @@ async function enrichModel(db: any, user: any) {
const seen = new Set<string>();
const photos = photoRows
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p: any) => ({ id: p.id, filename: p.filename }));
.filter((p) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p) => ({ id: p.id, filename: p.filename }));
return { ...user, photos };
}
@@ -35,7 +36,7 @@ builder.queryField("models", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [eq(users.role, "model")];
const conditions: SQL<unknown>[] = [eq(users.role, "model")];
if (args.search) conditions.push(ilike(users.artist_name, `%${args.search}%`));
if (args.tag) conditions.push(arrayContains(users.tags, [args.tag]));
@@ -46,7 +47,7 @@ builder.queryField("models", (t) =>
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)));
const items = await Promise.all(modelList.map((m) => enrichModel(ctx.db, m)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),

View File

@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { RecordingType, AdminRecordingListType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/index";
import { eq, and, desc, ilike, count } from "drizzle-orm";
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
import { slugify } from "../../lib/slugify";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireAdmin } from "../../lib/acl";
@@ -21,7 +21,7 @@ builder.queryField("recordings", (t) =>
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
if (args.status) conditions.push(eq(recordings.status, args.status as any));
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
const limit = args.limit || 50;
@@ -115,7 +115,7 @@ builder.mutationField("createRecording", (t) =>
user_id: ctx.currentUser.id,
tags: args.tags || [],
linked_video: args.linkedVideoId || null,
status: (args.status as any) || "draft",
status: (args.status as "draft" | "published") || "draft",
public: false,
})
.returning();
@@ -174,7 +174,7 @@ builder.mutationField("updateRecording", (t) =>
const updated = await ctx.db
.update(recordings)
.set(updates as any)
.set(updates as Partial<typeof recordings.$inferInsert>)
.where(eq(recordings.id, args.id))
.returning();
@@ -355,9 +355,9 @@ builder.queryField("adminListRecordings", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) conditions.push(ilike(recordings.title, `%${args.search}%`));
if (args.status) conditions.push(eq(recordings.status, args.status as any));
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totalRows] = await Promise.all([

View File

@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm";
import { eq, ilike, or, count, and, asc, type SQL } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
builder.queryField("me", (t) =>
@@ -63,7 +63,7 @@ builder.mutationField("updateProfile", (t) =>
await ctx.db
.update(users)
.set(updates as any)
.set(updates as Partial<typeof users.$inferInsert>)
.where(eq(users.id, ctx.currentUser.id));
const updated = await ctx.db
@@ -93,27 +93,27 @@ builder.queryField("adminListUsers", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
let query = ctx.db.select().from(users);
let countQuery = ctx.db.select({ total: count() }).from(users);
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.role) {
conditions.push(eq(users.role, args.role as any));
conditions.push(eq(users.role, args.role as "model" | "viewer" | "admin"));
}
if (args.search) {
const pattern = `%${args.search}%`;
conditions.push(or(ilike(users.email, pattern), ilike(users.artist_name, pattern)));
}
if (conditions.length > 0) {
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
query = (query as any).where(where);
countQuery = (countQuery as any).where(where);
conditions.push(
or(ilike(users.email, pattern), ilike(users.artist_name, pattern)) as SQL<unknown>,
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, totalRows] = await Promise.all([
(query as any).limit(limit).offset(offset),
countQuery,
ctx.db
.select()
.from(users)
.where(where)
.orderBy(asc(users.artist_name))
.limit(limit)
.offset(offset),
ctx.db.select({ total: count() }).from(users).where(where),
]);
return { items, total: totalRows[0]?.total ?? 0 };
@@ -139,7 +139,8 @@ builder.mutationField("adminUpdateUser", (t) =>
requireAdmin(ctx);
const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
if (args.role !== undefined && args.role !== null)
updates.role = args.role as "model" | "viewer" | "admin";
if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName;
@@ -151,7 +152,7 @@ builder.mutationField("adminUpdateUser", (t) =>
const updated = await ctx.db
.update(users)
.set(updates as any)
.set(updates as Partial<typeof users.$inferInsert>)
.where(eq(users.id, args.userId))
.returning();
@@ -194,8 +195,8 @@ builder.queryField("adminGetUser", (t) =>
.orderBy(user_photos.sort);
const seen = new Set<string>();
const photos = photoRows
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p: any) => ({ id: p.id, filename: p.filename }));
.filter((p) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p) => ({ id: p.id, filename: p.filename }));
return { ...user[0], photos };
},
}),

View File

@@ -28,10 +28,12 @@ import {
lt,
gte,
arrayContains,
type SQL,
} from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection";
async function enrichVideo(db: any, video: any) {
async function enrichVideo(db: DB, video: typeof videos.$inferSelect) {
// Fetch models
const modelRows = await db
.select({
@@ -87,7 +89,7 @@ builder.queryField("videos", (t) =>
const pageSize = args.limit ?? 24;
const offset = args.offset ?? 0;
const conditions: any[] = [lte(videos.upload_date, new Date())];
const conditions: SQL<unknown>[] = [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));
@@ -107,7 +109,7 @@ builder.queryField("videos", (t) =>
conditions.push(
inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
videoIds.map((v) => v.video_id),
),
);
}
@@ -148,8 +150,8 @@ builder.queryField("videos", (t) =>
.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)));
const videoList = rows.map((r) => r.v);
const items = await Promise.all(videoList.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
}
@@ -157,7 +159,7 @@ builder.queryField("videos", (t) =>
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -421,7 +423,7 @@ builder.queryField("analytics", (t) =>
};
}
const videoIds = modelVideoIds.map((v: any) => v.video_id);
const videoIds = modelVideoIds.map((v) => v.video_id);
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
const plays = await ctx.db
.select()
@@ -435,14 +437,14 @@ builder.queryField("analytics", (t) =>
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
const playsByDate = plays.reduce((acc: any, play) => {
const playsByDate = plays.reduce((acc: Record<string, number>, play) => {
const date = new Date(play.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
return acc;
}, {});
const likesByDate = likes.reduce((acc: any, like) => {
const likesByDate = likes.reduce((acc: Record<string, number>, like) => {
const date = new Date(like.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
@@ -499,7 +501,7 @@ builder.queryField("adminListVideos", (t) =>
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
const conditions: any[] = [];
const conditions: SQL<unknown>[] = [];
if (args.search) conditions.push(ilike(videos.title, `%${args.search}%`));
if (args.premium !== null && args.premium !== undefined)
conditions.push(eq(videos.premium, args.premium));
@@ -517,7 +519,7 @@ builder.queryField("adminListVideos", (t) =>
.offset(offset),
ctx.db.select({ total: count() }).from(videos).where(where),
]);
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
@@ -590,7 +592,7 @@ builder.mutationField("updateVideo", (t) =>
const updated = await ctx.db
.update(videos)
.set(updates as any)
.set(updates as Partial<typeof videos.$inferInsert>)
.where(eq(videos.id, args.id))
.returning();
if (!updated[0]) return null;

View File

@@ -47,7 +47,7 @@ export async function calculateWeightedScore(db: DB, userId: string): Promise<nu
FROM user_points
WHERE user_id = ${userId}
`);
return parseFloat((result.rows[0] as any)?.weighted_score || "0");
return parseFloat((result.rows[0] as { weighted_score?: string })?.weighted_score || "0");
}
export async function updateUserStats(db: DB, userId: string): Promise<void> {
@@ -84,7 +84,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
sql`, `,
)})
`);
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
playbacksCount = parseInt((playbacksResult.rows[0] as { count?: string })?.count || "0");
} else {
const playbacksResult = await db
.select({ count: count() })
@@ -242,7 +242,7 @@ async function getAchievementProgress(
WHERE rp.user_id = ${userId}
AND r.user_id != ${userId}
`);
return parseInt((result.rows[0] as any)?.count || "0");
return parseInt((result.rows[0] as { count?: string })?.count || "0");
}
if (["completionist_10", "completionist_100"].includes(code)) {
@@ -293,7 +293,7 @@ async function getAchievementProgress(
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
`);
const rc = recordingsResult[0]?.count || 0;
const pc = parseInt((playsResult.rows[0] as any)?.count || "0");
const pc = parseInt((playsResult.rows[0] as { count?: string })?.count || "0");
return rc >= 50 && pc >= 100 ? 1 : 0;
}

View File

@@ -38,7 +38,7 @@
isMobileMenuOpen = false;
}
function isActiveLink(link: any) {
function isActiveLink(link: { name: string; href: string }) {
return (
(page.url.pathname === "/" && link === navLinks[0]) ||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])

View File

@@ -20,7 +20,9 @@
{title}
</h1>
{#if description}
<p class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto">
<p
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
>
{description}
</p>
{/if}

View File

@@ -26,7 +26,7 @@
return "text-green-400 bg-green-400/20";
case "draft":
return "text-yellow-400 bg-yellow-400/20";
default:
default:
return "text-gray-400 bg-gray-400/20";
}
}

View File

@@ -1245,7 +1245,9 @@ export async function adminGetUser(userId: string, token?: string) {
"adminGetUser",
async () => {
const client = token ? getAuthClient(token) : getGraphQLClient();
const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId });
const data = await client.request<{
adminGetUser: User & { photos: Array<{ id: string; filename: string }> };
}>(ADMIN_GET_USER_QUERY, { userId });
return data.adminGetUser;
},
{ userId },

View File

@@ -75,7 +75,9 @@
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all bg-muted"
/>
</div>
<h3 class="font-semibold text-lg group-hover:text-primary transition-colors">{model.artist_name}</h3>
<h3 class="font-semibold text-lg group-hover:text-primary transition-colors">
{model.artist_name}
</h3>
</CardContent>
</Card>
</a>
@@ -110,7 +112,9 @@
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
></div>
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
{#if video.movie_file?.duration}{formatVideoDuration(
video.movie_file.duration,
)}{/if}
</div>
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"

View File

@@ -66,8 +66,8 @@
});
toast.success($_("admin.article_form.update_success"));
goto("/admin/articles");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.article_form.update_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.article_form.update_error"));
} finally {
saving = false;
}

View File

@@ -67,8 +67,8 @@
});
toast.success($_("admin.article_form.create_success"));
goto("/admin/articles");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.article_form.create_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.article_form.create_error"));
} finally {
saving = false;
}

View File

@@ -91,8 +91,8 @@
isAdmin,
});
toast.success($_("admin.user_edit.save_success"));
} catch (e: any) {
toast.error(e?.message ?? $_("admin.user_edit.save_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.user_edit.save_error"));
} finally {
saving = false;
}

View File

@@ -77,8 +77,8 @@
await setVideoModels(data.video.id, selectedModelIds);
toast.success($_("admin.video_form.update_success"));
goto("/admin/videos");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.video_form.update_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.video_form.update_error"));
} finally {
saving = false;
}

View File

@@ -89,8 +89,8 @@
}
toast.success($_("admin.video_form.create_success"));
goto("/admin/videos");
} catch (e: any) {
toast.error(e?.message ?? $_("admin.video_form.create_error"));
} catch (e) {
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.video_form.create_error"));
} finally {
saving = false;
}

View File

@@ -32,8 +32,9 @@
isLoading = true;
await login(email, password);
goto("/videos", { invalidateAll: true });
} catch (err: any) {
const raw = err.response?.errors?.[0]?.message ?? err.message;
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
const raw = e.response?.errors?.[0]?.message ?? e.message;
error = raw === "Invalid credentials" ? $_("auth.login.error_invalid_credentials") : raw;
isError = true;
} finally {

View File

@@ -315,8 +315,8 @@
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>{$_("common.previous")}</Button>
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
>
{#each pageNumbers() as p, i (i)}
{#if p === -1}
<span class="px-2 text-muted-foreground select-none"></span>
@@ -327,8 +327,8 @@
onclick={() => goToPage(p)}
class={p === data.page
? "bg-gradient-to-r from-primary to-accent min-w-9"
: "border-primary/20 hover:bg-primary/10 min-w-9"}
>{p}</Button>
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
>
{/if}
{/each}
<Button
@@ -336,8 +336,8 @@
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>{$_("common.next")}</Button>
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
>
</div>
<p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })}

View File

@@ -153,10 +153,15 @@
<div class="flex-1">
<h3 class="font-semibold text-lg mb-1">About {author.artist_name}</h3>
{#if author.description}
<p class="text-sm text-muted-foreground mb-3 leading-relaxed">{author.description}</p>
<p class="text-sm text-muted-foreground mb-3 leading-relaxed">
{author.description}
</p>
{/if}
{#if author.slug}
<a href="/models/{author.slug}" class="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<a
href="/models/{author.slug}"
class="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
View profile
<span class="icon-[ri--arrow-right-line] w-3.5 h-3.5"></span>
</a>

View File

@@ -90,8 +90,9 @@
});
toast.success($_("me.settings.toast_update"));
invalidateAll();
} catch (err: any) {
profileError = err.response?.errors?.[0]?.message ?? err.message;
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isProfileError = true;
} finally {
isProfileLoading = false;
@@ -114,8 +115,9 @@
toast.success($_("me.settings.toast_update"));
invalidateAll();
password = confirmPassword = "";
} catch (err: any) {
securityError = err.response?.errors?.[0]?.message ?? err.message;
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
securityError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isSecurityError = true;
} finally {
isSecurityLoading = false;
@@ -290,9 +292,7 @@
</div>
</FileDropZone>
<div class="flex flex-col gap-1">
<p class="text-sm text-muted-foreground">
JPG, PNG · max 2 MB
</p>
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
<p class="text-xs text-muted-foreground/70">
Click or drop to {avatar ? "change" : "upload"}
</p>

View File

@@ -70,34 +70,34 @@
<PageHero title={$_("models.title")} description={$_("models.description")}>
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
<!-- Search -->
<div class="relative flex-1">
<span
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
></span>
<Input
placeholder={$_("models.search_placeholder")}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Search -->
<div class="relative flex-1">
<span
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
></span>
<Input
placeholder={$_("models.search_placeholder")}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Sort -->
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{data.sort === "recent" ? $_("models.sort.recent") : $_("models.sort.name")}
</SelectTrigger>
<SelectContent>
<SelectItem value="name">{$_("models.sort.name")}</SelectItem>
<SelectItem value="recent">{$_("models.sort.recent")}</SelectItem>
</SelectContent>
</Select>
<!-- Sort -->
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{data.sort === "recent" ? $_("models.sort.recent") : $_("models.sort.name")}
</SelectTrigger>
<SelectContent>
<SelectItem value="name">{$_("models.sort.name")}</SelectItem>
<SelectItem value="recent">{$_("models.sort.recent")}</SelectItem>
</SelectContent>
</Select>
</div>
</PageHero>
<!-- Models Grid -->
@@ -105,18 +105,18 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each data.items as model (model.slug)}
<a href="/models/{model.slug}" class="block group">
<Card
class="py-0 h-full 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"
>
<div class="relative">
<img
src={getAssetUrl(model.avatar, "preview")}
alt={model.artist_name}
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>
<Card
class="py-0 h-full 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"
>
<div class="relative">
<img
src={getAssetUrl(model.avatar, "preview")}
alt={model.artist_name}
class="w-full aspect-square object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>
<!-- Online Status -->
<!-- {#if model.isOnline}
<!-- Online Status -->
<!-- {#if model.isOnline}
<div
class="absolute top-3 left-3 bg-green-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
>
@@ -125,8 +125,8 @@
</div>
{/if} -->
<!-- Heart Button -->
<!-- <button
<!-- Heart Button -->
<!-- <button
class="absolute top-3 right-3 w-10 h-10 bg-black/50 hover:bg-primary/80 rounded-full flex items-center justify-center transition-colors group/heart"
>
<HeartIcon
@@ -134,24 +134,24 @@
/>
</button> -->
<!-- Hover Overlay -->
<div
aria-hidden="true"
class="absolute inset-0 group-hover:scale-105 transition bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 flex items-center justify-center"
>
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"></span>
<!-- Hover Overlay -->
<div
aria-hidden="true"
class="absolute inset-0 group-hover:scale-105 transition bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 flex items-center justify-center"
>
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white ml-1"></span>
</div>
</div>
</div>
</div>
<CardContent class="p-6">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
{model.artist_name}
</h3>
<!-- <div
<CardContent class="p-6">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
{model.artist_name}
</h3>
<!-- <div
class="flex items-center gap-4 text-sm text-muted-foreground"
>
<div class="flex items-center gap-1">
@@ -160,29 +160,28 @@
</div>
<div>{model.subscribers} followers</div>
</div> -->
</div>
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each model.tags as tag (tag)}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
>
{tag}
</a>
{/each}
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each model.tags as tag (tag)}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
>
{tag}
</a>
{/each}
</div>
<!-- Stats -->
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
<!-- <span>{model.videos} videos</span> -->
<!-- category not available -->
</div>
</CardContent>
</Card>
<!-- Stats -->
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
<!-- <span>{model.videos} videos</span> -->
<!-- category not available -->
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
@@ -205,8 +204,8 @@
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>{$_("common.previous")}</Button>
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
>
{#each pageNumbers() as p, i (i)}
{#if p === -1}
<span class="px-2 text-muted-foreground select-none"></span>
@@ -217,8 +216,8 @@
onclick={() => goToPage(p)}
class={p === data.page
? "bg-gradient-to-r from-primary to-accent min-w-9"
: "border-primary/20 hover:bg-primary/10 min-w-9"}
>{p}</Button>
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
>
{/if}
{/each}
<Button
@@ -226,8 +225,8 @@
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>{$_("common.next")}</Button>
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
>
</div>
<p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })}

View File

@@ -30,8 +30,9 @@
await requestPassword(email);
toast.success($_("auth.password_request.toast_request", { values: { email } }));
goto("/login");
} catch (err: any) {
error = err.response?.errors?.[0]?.message ?? err.message;
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
error = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
isError = true;
} finally {
isLoading = false;

View File

@@ -39,8 +39,9 @@
await resetPassword(data.token, password);
toast.success($_("auth.password_reset.toast_reset"));
goto("/login");
} catch (err: any) {
const raw = err.response?.errors?.[0]?.message ?? err.message;
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
const raw = e.response?.errors?.[0]?.message ?? e.message;
const tokenErrors = ["Invalid or expired reset token", "Reset token expired"];
error = tokenErrors.includes(raw) ? $_("auth.password_reset.error_invalid_token") : raw;
isError = true;

View File

@@ -47,8 +47,9 @@
await register(email, password, firstName, lastName);
toast.success($_("auth.signup.toast_register", { values: { email } }));
goto("/login");
} catch (err: any) {
const raw = err.response?.errors?.[0]?.message ?? err.message;
} catch (err) {
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
const raw = e.response?.errors?.[0]?.message ?? e.message;
error = raw === "Email already registered" ? $_("auth.signup.error_email_taken") : raw;
isError = true;
} finally {

View File

@@ -27,7 +27,7 @@
const filteredItems = $derived(() => {
return data.items
.filter((item: any) => {
.filter((item) => {
const matchesSearch =
searchQuery === "" || item.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === "all" || item.category === categoryFilter;
@@ -72,7 +72,9 @@
/>
</div>
<Select type="single" bind:value={categoryFilter}>
<SelectTrigger class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary">
<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"
? $_("tags.categories.all")
@@ -96,46 +98,46 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each filteredItems() as item (item.slug)}
<a href={getUrlForItem(item)} class="block group">
<Card
class="py-0 h-full 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"
>
<div class="relative">
<img
src={getAssetUrl(item["image"] || item["avatar"], "preview")}
alt={item.title}
class="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
></div>
<Card
class="py-0 h-full 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"
>
<div class="relative">
<img
src={getAssetUrl(item["image"] || item["avatar"], "preview")}
alt={item.title}
class="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
></div>
<!-- Category Badge -->
<div
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
>
{item.category}
</div>
</div>
<CardContent class="p-6">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
{item.title}
</h3>
<!-- Category Badge -->
<div
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
>
{item.category}
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2">
{#each item.tags as tag (tag)}
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
{tag}
</span>
{/each}
</div>
</CardContent>
</Card>
<CardContent class="p-6">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-lg mb-1 group-hover:text-primary transition-colors">
{item.title}
</h3>
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2">
{#each item.tags as tag (tag)}
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
{tag}
</span>
{/each}
</div>
</CardContent>
</Card>
</a>
{/each}
</div>

View File

@@ -73,68 +73,68 @@
<PageHero title={$_("videos.title")} description={$_("videos.description")}>
<div class="flex flex-col lg:flex-row gap-4 max-w-6xl mx-auto">
<!-- Search -->
<div class="relative flex-1">
<span
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
></span>
<Input
placeholder={$_("videos.search_placeholder")}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Search -->
<div class="relative flex-1">
<span
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
></span>
<Input
placeholder={$_("videos.search_placeholder")}
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<!-- Duration Filter -->
<Select
type="single"
value={data.duration}
onValueChange={(v) => v && setParam("duration", v)}
>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
{data.duration === "short"
? $_("videos.duration.short")
: data.duration === "medium"
? $_("videos.duration.medium")
: data.duration === "long"
? $_("videos.duration.long")
: $_("videos.duration.all")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
<SelectItem value="short">{$_("videos.duration.short")}</SelectItem>
<SelectItem value="medium">{$_("videos.duration.medium")}</SelectItem>
<SelectItem value="long">{$_("videos.duration.long")}</SelectItem>
</SelectContent>
</Select>
<!-- Duration Filter -->
<Select
type="single"
value={data.duration}
onValueChange={(v) => v && setParam("duration", v)}
>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
<span class="icon-[ri--timer-2-line] w-4 h-4 mr-2"></span>
{data.duration === "short"
? $_("videos.duration.short")
: data.duration === "medium"
? $_("videos.duration.medium")
: data.duration === "long"
? $_("videos.duration.long")
: $_("videos.duration.all")}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{$_("videos.duration.all")}</SelectItem>
<SelectItem value="short">{$_("videos.duration.short")}</SelectItem>
<SelectItem value="medium">{$_("videos.duration.medium")}</SelectItem>
<SelectItem value="long">{$_("videos.duration.long")}</SelectItem>
</SelectContent>
</Select>
<!-- Sort -->
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{data.sort === "most_liked"
? $_("videos.sort.most_liked")
: data.sort === "most_played"
? $_("videos.sort.most_played")
: data.sort === "name"
? $_("videos.sort.name")
: $_("videos.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
</SelectContent>
</Select>
<!-- Sort -->
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
<SelectTrigger
class="w-full lg:w-48 bg-background/50 border-primary/20 focus:border-primary"
>
{data.sort === "most_liked"
? $_("videos.sort.most_liked")
: data.sort === "most_played"
? $_("videos.sort.most_played")
: data.sort === "name"
? $_("videos.sort.name")
: $_("videos.sort.recent")}
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">{$_("videos.sort.recent")}</SelectItem>
<SelectItem value="most_liked">{$_("videos.sort.most_liked")}</SelectItem>
<SelectItem value="most_played">{$_("videos.sort.most_played")}</SelectItem>
<SelectItem value="name">{$_("videos.sort.name")}</SelectItem>
</SelectContent>
</Select>
</div>
</PageHero>
<!-- Videos Grid -->
@@ -142,61 +142,63 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each data.items as video (video.slug)}
<a href={`/videos/${video.slug}`} class="block group">
<Card
class="p-0 h-full 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"
>
<div class="relative">
<img
src={getAssetUrl(video.image, "preview")}
alt={video.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>
<Card
class="p-0 h-full 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"
>
<div class="relative">
<img
src={getAssetUrl(video.image, "preview")}
alt={video.title}
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
/>
<!-- Overlay Gradient -->
<div
class="absolute inset-0 group-hover:scale-105 bg-gradient-to-t from-black/60 via-transparent to-black/20 duration-300"
></div>
<!-- Duration -->
<div
class="absolute bottom-3 left-3 bg-black/70 text-white text-sm px-2 py-1 rounded font-medium"
>
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
</div>
<!-- Premium Badge -->
{#if video.premium}
<!-- Overlay Gradient -->
<div
class="absolute top-3 left-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full font-medium"
>
{$_("videos.premium")}
</div>
{/if}
class="absolute inset-0 group-hover:scale-105 bg-gradient-to-t from-black/60 via-transparent to-black/20 duration-300"
></div>
<!-- Play Count -->
{#if video.plays_count}
<!-- Duration -->
<div
class="absolute top-3 right-3 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
class="absolute bottom-3 left-3 bg-black/70 text-white text-sm px-2 py-1 rounded font-medium"
>
<span class="icon-[ri--play-fill] w-3 h-3"></span>
{video.plays_count}
{#if video.movie_file?.duration}{formatVideoDuration(
video.movie_file.duration,
)}{/if}
</div>
{/if}
<!-- Play Overlay -->
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-hidden="true"
>
<!-- Premium Badge -->
{#if video.premium}
<div
class="absolute top-3 left-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full font-medium"
>
{$_("videos.premium")}
</div>
{/if}
<!-- Play Count -->
{#if video.plays_count}
<div
class="absolute top-3 right-3 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
>
<span class="icon-[ri--play-fill] w-3 h-3"></span>
{video.plays_count}
</div>
{/if}
<!-- Play Overlay -->
<div
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-hidden="true"
>
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
<div
class="w-16 h-16 bg-primary/90 rounded-full flex flex-col items-center justify-center shadow-2xl"
>
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
</div>
</div>
</div>
<!-- Model Info -->
<!-- <div class="absolute bottom-3 right-3 text-white text-sm">
<!-- Model Info -->
<!-- <div class="absolute bottom-3 right-3 text-white text-sm">
<button
onclick={() => onNavigate("model")}
class="hover:text-primary transition-colors"
@@ -204,23 +206,23 @@
{video.model}
</button>
</div> -->
</div>
<CardContent class="p-6">
<div class="mb-3">
<h3
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
>
{video.title}
</h3>
<p class="text-sm text-muted-foreground">
{timeAgo.format(new Date(video.upload_date))}
</p>
</div>
<!-- Stats -->
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
<!-- <div class="flex items-center gap-4">
<CardContent class="p-6">
<div class="mb-3">
<h3
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
>
{video.title}
</h3>
<p class="text-sm text-muted-foreground">
{timeAgo.format(new Date(video.upload_date))}
</p>
</div>
<!-- Stats -->
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
<!-- <div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<EyeIcon class="w-4 h-4" />
{video.views}
@@ -230,15 +232,14 @@
{video.likes}
</div>
</div> -->
<!-- <span
<!-- <span
class="capitalize bg-primary/10 text-primary px-2 py-1 rounded-full text-xs"
>
{video.category}
</span> -->
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
@@ -263,8 +264,8 @@
size="sm"
disabled={data.page <= 1}
onclick={() => goToPage(data.page - 1)}
class="border-primary/20 hover:bg-primary/10"
>{$_("common.previous")}</Button>
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
>
{#each pageNumbers() as p, i (i)}
{#if p === -1}
<span class="px-2 text-muted-foreground select-none"></span>
@@ -275,8 +276,8 @@
onclick={() => goToPage(p)}
class={p === data.page
? "bg-gradient-to-r from-primary to-accent min-w-9"
: "border-primary/20 hover:bg-primary/10 min-w-9"}
>{p}</Button>
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
>
{/if}
{/each}
<Button
@@ -284,8 +285,8 @@
size="sm"
disabled={data.page >= totalPages}
onclick={() => goToPage(data.page + 1)}
class="border-primary/20 hover:bg-primary/10"
>{$_("common.next")}</Button>
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
>
</div>
<p class="text-sm text-muted-foreground">
{$_("common.total_results", { values: { total: data.total } })}

View File

@@ -58,8 +58,8 @@
isLiked = true;
toast.success("Added to liked videos");
}
} catch (error: any) {
toast.error(error.message || "Failed to update like");
} catch (error) {
toast.error((error instanceof Error ? error.message : null) || "Failed to update like");
} finally {
isLikeLoading = false;
}
@@ -86,8 +86,8 @@
invalidateAll();
newComment = "";
showComments = true;
} catch (err: any) {
commentError = err.message;
} catch (err) {
commentError = err instanceof Error ? err.message : "Unknown error";
isCommentError = true;
} finally {
isCommentLoading = false;