Compare commits
10 Commits
b200498a10
...
a7fafaf7c5
| Author | SHA1 | Date | |
|---|---|---|---|
| a7fafaf7c5 | |||
| b71d7dc559 | |||
| f764e27d59 | |||
| d7eb2acc6c | |||
| fb38d6b9a9 | |||
| d021acaf0b | |||
| e06a1915f2 | |||
| ebab3405b1 | |||
| ad7ceee5f8 | |||
| c1770ab9c9 |
@@ -76,7 +76,7 @@ Points + achievements system tracked in `user_points` and `user_stats` tables. L
|
|||||||
## Environment Variables (Backend)
|
## Environment Variables (Backend)
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|----------|---------|
|
| --------------------------- | ---------------------------- |
|
||||||
| `DATABASE_URL` | PostgreSQL connection string |
|
| `DATABASE_URL` | PostgreSQL connection string |
|
||||||
| `REDIS_URL` | Redis connection string |
|
| `REDIS_URL` | Redis connection string |
|
||||||
| `COOKIE_SECRET` | Session cookie signing |
|
| `COOKIE_SECRET` | Session cookie signing |
|
||||||
|
|||||||
@@ -2,32 +2,12 @@ import { builder } from "../builder";
|
|||||||
import { ArticleType } from "../types/index";
|
import { ArticleType } 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 } from "drizzle-orm";
|
||||||
|
import { requireRole } from "../../lib/acl";
|
||||||
|
|
||||||
builder.queryField("articles", (t) =>
|
async function enrichArticle(db: any, article: any) {
|
||||||
t.field({
|
|
||||||
type: [ArticleType],
|
|
||||||
args: {
|
|
||||||
featured: t.arg.boolean(),
|
|
||||||
limit: t.arg.int(),
|
|
||||||
},
|
|
||||||
resolve: async (_root, args, ctx) => {
|
|
||||||
let query = ctx.db
|
|
||||||
.select()
|
|
||||||
.from(articles)
|
|
||||||
.where(lte(articles.publish_date, new Date()))
|
|
||||||
.orderBy(desc(articles.publish_date));
|
|
||||||
|
|
||||||
if (args.limit) {
|
|
||||||
query = (query as any).limit(args.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
const articleList = await query;
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
articleList.map(async (article: any) => {
|
|
||||||
let author = null;
|
let author = null;
|
||||||
if (article.author) {
|
if (article.author) {
|
||||||
const authorUser = await ctx.db
|
const authorUser = await db
|
||||||
.select({
|
.select({
|
||||||
first_name: users.first_name,
|
first_name: users.first_name,
|
||||||
last_name: users.last_name,
|
last_name: users.last_name,
|
||||||
@@ -40,8 +20,34 @@ builder.queryField("articles", (t) =>
|
|||||||
author = authorUser[0] || null;
|
author = authorUser[0] || null;
|
||||||
}
|
}
|
||||||
return { ...article, author };
|
return { ...article, author };
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
|
builder.queryField("articles", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: [ArticleType],
|
||||||
|
args: {
|
||||||
|
featured: t.arg.boolean(),
|
||||||
|
limit: t.arg.int(),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
const dateFilter = lte(articles.publish_date, new Date());
|
||||||
|
const whereCondition =
|
||||||
|
args.featured !== null && args.featured !== undefined
|
||||||
|
? and(dateFilter, eq(articles.featured, args.featured))
|
||||||
|
: dateFilter;
|
||||||
|
|
||||||
|
let query = ctx.db
|
||||||
|
.select()
|
||||||
|
.from(articles)
|
||||||
|
.where(whereCondition)
|
||||||
|
.orderBy(desc(articles.publish_date));
|
||||||
|
|
||||||
|
if (args.limit) {
|
||||||
|
query = (query as any).limit(args.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleList = await query;
|
||||||
|
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -61,23 +67,111 @@ builder.queryField("article", (t) =>
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!article[0]) return null;
|
if (!article[0]) return null;
|
||||||
|
return enrichArticle(ctx.db, article[0]);
|
||||||
let author = null;
|
},
|
||||||
if (article[0].author) {
|
}),
|
||||||
const authorUser = await ctx.db
|
);
|
||||||
.select({
|
|
||||||
first_name: users.first_name,
|
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||||
last_name: users.last_name,
|
|
||||||
avatar: users.avatar,
|
builder.queryField("adminListArticles", (t) =>
|
||||||
description: users.description,
|
t.field({
|
||||||
})
|
type: [ArticleType],
|
||||||
.from(users)
|
resolve: async (_root, _args, ctx) => {
|
||||||
.where(eq(users.id, article[0].author))
|
requireRole(ctx, "admin");
|
||||||
.limit(1);
|
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
|
||||||
author = authorUser[0] || null;
|
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||||
}
|
},
|
||||||
|
}),
|
||||||
return { ...article[0], author };
|
);
|
||||||
|
|
||||||
|
builder.mutationField("createArticle", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: ArticleType,
|
||||||
|
args: {
|
||||||
|
title: t.arg.string({ required: true }),
|
||||||
|
slug: t.arg.string({ required: true }),
|
||||||
|
excerpt: t.arg.string(),
|
||||||
|
content: t.arg.string(),
|
||||||
|
imageId: t.arg.string(),
|
||||||
|
tags: t.arg.stringList(),
|
||||||
|
category: t.arg.string(),
|
||||||
|
featured: t.arg.boolean(),
|
||||||
|
publishDate: t.arg.string(),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
const inserted = await ctx.db
|
||||||
|
.insert(articles)
|
||||||
|
.values({
|
||||||
|
title: args.title,
|
||||||
|
slug: args.slug,
|
||||||
|
excerpt: args.excerpt || null,
|
||||||
|
content: args.content || null,
|
||||||
|
image: args.imageId || null,
|
||||||
|
tags: args.tags || [],
|
||||||
|
category: args.category || null,
|
||||||
|
featured: args.featured ?? false,
|
||||||
|
publish_date: args.publishDate ? new Date(args.publishDate) : new Date(),
|
||||||
|
author: ctx.currentUser!.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return enrichArticle(ctx.db, inserted[0]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("updateArticle", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: ArticleType,
|
||||||
|
nullable: true,
|
||||||
|
args: {
|
||||||
|
id: t.arg.string({ required: true }),
|
||||||
|
title: t.arg.string(),
|
||||||
|
slug: t.arg.string(),
|
||||||
|
excerpt: t.arg.string(),
|
||||||
|
content: t.arg.string(),
|
||||||
|
imageId: t.arg.string(),
|
||||||
|
tags: t.arg.stringList(),
|
||||||
|
category: t.arg.string(),
|
||||||
|
featured: t.arg.boolean(),
|
||||||
|
publishDate: t.arg.string(),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||||
|
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||||
|
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||||
|
if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
|
||||||
|
if (args.content !== undefined) updates.content = args.content;
|
||||||
|
if (args.imageId !== undefined) updates.image = args.imageId;
|
||||||
|
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||||
|
if (args.category !== undefined) updates.category = args.category;
|
||||||
|
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
|
||||||
|
if (args.publishDate !== undefined && args.publishDate !== null)
|
||||||
|
updates.publish_date = new Date(args.publishDate);
|
||||||
|
|
||||||
|
const updated = await ctx.db
|
||||||
|
.update(articles)
|
||||||
|
.set(updates as any)
|
||||||
|
.where(eq(articles.id, args.id))
|
||||||
|
.returning();
|
||||||
|
if (!updated[0]) return null;
|
||||||
|
return enrichArticle(ctx.db, updated[0]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("deleteArticle", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: "Boolean",
|
||||||
|
args: {
|
||||||
|
id: t.arg.string({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
await ctx.db.delete(articles).where(eq(articles.id, args.id));
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CommentType } from "../types/index";
|
|||||||
import { comments, users } from "../../db/schema/index";
|
import { comments, users } from "../../db/schema/index";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
||||||
|
import { requireOwnerOrAdmin } from "../../lib/acl";
|
||||||
|
|
||||||
builder.queryField("commentsForVideo", (t) =>
|
builder.queryField("commentsForVideo", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -78,3 +79,19 @@ builder.mutationField("createCommentForVideo", (t) =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
builder.mutationField("deleteComment", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: "Boolean",
|
||||||
|
args: {
|
||||||
|
id: t.arg.int({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
const comment = await ctx.db.select().from(comments).where(eq(comments.id, args.id)).limit(1);
|
||||||
|
if (!comment[0]) throw new GraphQLError("Comment not found");
|
||||||
|
requireOwnerOrAdmin(ctx, comment[0].user_id);
|
||||||
|
await ctx.db.delete(comments).where(eq(comments.id, args.id));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { GraphQLError } from "graphql";
|
import { GraphQLError } from "graphql";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { CurrentUserType, UserType } from "../types/index";
|
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
|
||||||
import { users } from "../../db/schema/index";
|
import { users, user_photos, files } from "../../db/schema/index";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, ilike, or, count, and } from "drizzle-orm";
|
||||||
|
import { requireRole } from "../../lib/acl";
|
||||||
|
|
||||||
builder.queryField("me", (t) =>
|
builder.queryField("me", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -72,3 +73,157 @@ builder.mutationField("updateProfile", (t) =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
builder.queryField("adminListUsers", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: AdminUserListType,
|
||||||
|
args: {
|
||||||
|
role: t.arg.string(),
|
||||||
|
search: t.arg.string(),
|
||||||
|
limit: t.arg.int(),
|
||||||
|
offset: t.arg.int(),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
|
||||||
|
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[] = [];
|
||||||
|
if (args.role) {
|
||||||
|
conditions.push(eq(users.role, args.role as any));
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, totalRows] = await Promise.all([
|
||||||
|
(query as any).limit(limit).offset(offset),
|
||||||
|
countQuery,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total: totalRows[0]?.total ?? 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("adminUpdateUser", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: UserType,
|
||||||
|
nullable: true,
|
||||||
|
args: {
|
||||||
|
userId: t.arg.string({ required: true }),
|
||||||
|
role: t.arg.string(),
|
||||||
|
firstName: t.arg.string(),
|
||||||
|
lastName: t.arg.string(),
|
||||||
|
artistName: t.arg.string(),
|
||||||
|
avatarId: t.arg.string(),
|
||||||
|
bannerId: t.arg.string(),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||||
|
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
|
||||||
|
if (args.firstName !== undefined && args.firstName !== null)
|
||||||
|
updates.first_name = args.firstName;
|
||||||
|
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
|
||||||
|
if (args.artistName !== undefined && args.artistName !== null)
|
||||||
|
updates.artist_name = args.artistName;
|
||||||
|
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
|
||||||
|
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
|
||||||
|
|
||||||
|
const updated = await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set(updates as any)
|
||||||
|
.where(eq(users.id, args.userId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated[0] || null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("adminDeleteUser", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: "Boolean",
|
||||||
|
args: {
|
||||||
|
userId: t.arg.string({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself");
|
||||||
|
await ctx.db.delete(users).where(eq(users.id, args.userId));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.queryField("adminGetUser", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: AdminUserDetailType,
|
||||||
|
nullable: true,
|
||||||
|
args: {
|
||||||
|
userId: t.arg.string({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1);
|
||||||
|
if (!user[0]) return null;
|
||||||
|
const photoRows = await ctx.db
|
||||||
|
.select({ id: files.id, filename: files.filename })
|
||||||
|
.from(user_photos)
|
||||||
|
.leftJoin(files, eq(user_photos.file_id, files.id))
|
||||||
|
.where(eq(user_photos.user_id, args.userId))
|
||||||
|
.orderBy(user_photos.sort);
|
||||||
|
return {
|
||||||
|
...user[0],
|
||||||
|
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("adminAddUserPhoto", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: "Boolean",
|
||||||
|
args: {
|
||||||
|
userId: t.arg.string({ required: true }),
|
||||||
|
fileId: t.arg.string({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("adminRemoveUserPhoto", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: "Boolean",
|
||||||
|
args: {
|
||||||
|
userId: t.arg.string({ required: true }),
|
||||||
|
fileId: t.arg.string({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
await ctx.db
|
||||||
|
.delete(user_photos)
|
||||||
|
.where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId)));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
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, inArray, count } from "drizzle-orm";
|
||||||
|
import { requireRole } from "../../lib/acl";
|
||||||
|
|
||||||
async function enrichVideo(db: any, video: any) {
|
async function enrichVideo(db: any, video: any) {
|
||||||
// Fetch models
|
// Fetch models
|
||||||
@@ -64,10 +65,13 @@ builder.queryField("videos", (t) =>
|
|||||||
limit: t.arg.int(),
|
limit: t.arg.int(),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
|
// Unauthenticated users cannot see premium videos
|
||||||
|
const premiumFilter = !ctx.currentUser ? eq(videos.premium, false) : undefined;
|
||||||
|
|
||||||
let query = ctx.db
|
let query = ctx.db
|
||||||
.select({ v: videos })
|
.select({ v: videos })
|
||||||
.from(videos)
|
.from(videos)
|
||||||
.where(lte(videos.upload_date, new Date()))
|
.where(and(lte(videos.upload_date, new Date()), premiumFilter))
|
||||||
.orderBy(desc(videos.upload_date));
|
.orderBy(desc(videos.upload_date));
|
||||||
|
|
||||||
if (args.modelId) {
|
if (args.modelId) {
|
||||||
@@ -84,6 +88,7 @@ builder.queryField("videos", (t) =>
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
lte(videos.upload_date, new Date()),
|
lte(videos.upload_date, new Date()),
|
||||||
|
premiumFilter,
|
||||||
inArray(
|
inArray(
|
||||||
videos.id,
|
videos.id,
|
||||||
videoIds.map((v: any) => v.video_id),
|
videoIds.map((v: any) => v.video_id),
|
||||||
@@ -97,7 +102,13 @@ builder.queryField("videos", (t) =>
|
|||||||
query = ctx.db
|
query = ctx.db
|
||||||
.select({ v: videos })
|
.select({ v: videos })
|
||||||
.from(videos)
|
.from(videos)
|
||||||
.where(and(lte(videos.upload_date, new Date()), eq(videos.featured, args.featured)))
|
.where(
|
||||||
|
and(
|
||||||
|
lte(videos.upload_date, new Date()),
|
||||||
|
premiumFilter,
|
||||||
|
eq(videos.featured, args.featured),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(desc(videos.upload_date));
|
.orderBy(desc(videos.upload_date));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +138,11 @@ builder.queryField("video", (t) =>
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!video[0]) return null;
|
if (!video[0]) return null;
|
||||||
|
|
||||||
|
if (video[0].premium && !ctx.currentUser) {
|
||||||
|
throw new GraphQLError("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
return enrichVideo(ctx.db, video[0]);
|
return enrichVideo(ctx.db, video[0]);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -295,6 +311,19 @@ builder.mutationField("updateVideoPlay", (t) =>
|
|||||||
completed: t.arg.boolean({ required: true }),
|
completed: t.arg.boolean({ required: true }),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
|
const play = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(video_plays)
|
||||||
|
.where(eq(video_plays.id, args.playId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!play[0]) return false;
|
||||||
|
|
||||||
|
// If play belongs to a user, verify ownership
|
||||||
|
if (play[0].user_id && (!ctx.currentUser || play[0].user_id !== ctx.currentUser.id)) {
|
||||||
|
throw new GraphQLError("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(video_plays)
|
.update(video_plays)
|
||||||
.set({
|
.set({
|
||||||
@@ -396,3 +425,129 @@ builder.queryField("analytics", (t) =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
builder.queryField("adminListVideos", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: [VideoType],
|
||||||
|
resolve: async (_root, _args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
|
||||||
|
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("createVideo", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: VideoType,
|
||||||
|
args: {
|
||||||
|
title: t.arg.string({ required: true }),
|
||||||
|
slug: t.arg.string({ required: true }),
|
||||||
|
description: t.arg.string(),
|
||||||
|
imageId: t.arg.string(),
|
||||||
|
movieId: t.arg.string(),
|
||||||
|
tags: t.arg.stringList(),
|
||||||
|
premium: t.arg.boolean(),
|
||||||
|
featured: t.arg.boolean(),
|
||||||
|
uploadDate: t.arg.string(),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
const inserted = await ctx.db
|
||||||
|
.insert(videos)
|
||||||
|
.values({
|
||||||
|
title: args.title,
|
||||||
|
slug: args.slug,
|
||||||
|
description: args.description || null,
|
||||||
|
image: args.imageId || null,
|
||||||
|
movie: args.movieId || null,
|
||||||
|
tags: args.tags || [],
|
||||||
|
premium: args.premium ?? false,
|
||||||
|
featured: args.featured ?? false,
|
||||||
|
upload_date: args.uploadDate ? new Date(args.uploadDate) : new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return enrichVideo(ctx.db, inserted[0]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("updateVideo", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: VideoType,
|
||||||
|
nullable: true,
|
||||||
|
args: {
|
||||||
|
id: t.arg.string({ required: true }),
|
||||||
|
title: t.arg.string(),
|
||||||
|
slug: t.arg.string(),
|
||||||
|
description: t.arg.string(),
|
||||||
|
imageId: t.arg.string(),
|
||||||
|
movieId: t.arg.string(),
|
||||||
|
tags: t.arg.stringList(),
|
||||||
|
premium: t.arg.boolean(),
|
||||||
|
featured: t.arg.boolean(),
|
||||||
|
uploadDate: t.arg.string(),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||||
|
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||||
|
if (args.description !== undefined) updates.description = args.description;
|
||||||
|
if (args.imageId !== undefined) updates.image = args.imageId;
|
||||||
|
if (args.movieId !== undefined) updates.movie = args.movieId;
|
||||||
|
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||||
|
if (args.premium !== undefined && args.premium !== null) updates.premium = args.premium;
|
||||||
|
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
|
||||||
|
if (args.uploadDate !== undefined && args.uploadDate !== null)
|
||||||
|
updates.upload_date = new Date(args.uploadDate);
|
||||||
|
|
||||||
|
const updated = await ctx.db
|
||||||
|
.update(videos)
|
||||||
|
.set(updates as any)
|
||||||
|
.where(eq(videos.id, args.id))
|
||||||
|
.returning();
|
||||||
|
if (!updated[0]) return null;
|
||||||
|
return enrichVideo(ctx.db, updated[0]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("deleteVideo", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: "Boolean",
|
||||||
|
args: {
|
||||||
|
id: t.arg.string({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
await ctx.db.delete(videos).where(eq(videos.id, args.id));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.mutationField("setVideoModels", (t) =>
|
||||||
|
t.field({
|
||||||
|
type: "Boolean",
|
||||||
|
args: {
|
||||||
|
videoId: t.arg.string({ required: true }),
|
||||||
|
userIds: t.arg.stringList({ required: true }),
|
||||||
|
},
|
||||||
|
resolve: async (_root, args, ctx) => {
|
||||||
|
requireRole(ctx, "admin");
|
||||||
|
await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId));
|
||||||
|
if (args.userIds.length > 0) {
|
||||||
|
await ctx.db.insert(video_models).values(
|
||||||
|
args.userIds.map((userId) => ({
|
||||||
|
video_id: args.videoId,
|
||||||
|
user_id: userId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import type {
|
|||||||
UserGamification,
|
UserGamification,
|
||||||
Achievement,
|
Achievement,
|
||||||
} from "@sexy.pivoine.art/types";
|
} from "@sexy.pivoine.art/types";
|
||||||
|
|
||||||
|
type AdminUserDetail = User & { photos: ModelPhoto[] };
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
|
|
||||||
export const FileType = builder.objectRef<MediaFile>("File").implement({
|
export const FileType = builder.objectRef<MediaFile>("File").implement({
|
||||||
@@ -229,13 +231,11 @@ export const VideoPlayResponseType = builder
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoLikeStatusType = builder
|
export const VideoLikeStatusType = builder.objectRef<VideoLikeStatus>("VideoLikeStatus").implement({
|
||||||
.objectRef<VideoLikeStatus>("VideoLikeStatus")
|
|
||||||
.implement({
|
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
liked: t.exposeBoolean("liked"),
|
liked: t.exposeBoolean("liked"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
|
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
|
||||||
fields: (t) => ({
|
fields: (t) => ({
|
||||||
@@ -336,3 +336,33 @@ export const AchievementType = builder.objectRef<Achievement>("Achievement").imp
|
|||||||
points_reward: t.exposeInt("points_reward"),
|
points_reward: t.exposeInt("points_reward"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AdminUserListType = builder
|
||||||
|
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
||||||
|
.implement({
|
||||||
|
fields: (t) => ({
|
||||||
|
items: t.expose("items", { type: [UserType] }),
|
||||||
|
total: t.exposeInt("total"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AdminUserDetailType = builder
|
||||||
|
.objectRef<AdminUserDetail>("AdminUserDetail")
|
||||||
|
.implement({
|
||||||
|
fields: (t) => ({
|
||||||
|
id: t.exposeString("id"),
|
||||||
|
email: t.exposeString("email"),
|
||||||
|
first_name: t.exposeString("first_name", { nullable: true }),
|
||||||
|
last_name: t.exposeString("last_name", { nullable: true }),
|
||||||
|
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||||
|
slug: t.exposeString("slug", { nullable: true }),
|
||||||
|
description: t.exposeString("description", { nullable: true }),
|
||||||
|
tags: t.exposeStringList("tags", { nullable: true }),
|
||||||
|
role: t.exposeString("role"),
|
||||||
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
|
banner: t.exposeString("banner", { nullable: true }),
|
||||||
|
email_verified: t.exposeBoolean("email_verified"),
|
||||||
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
|
photos: t.expose("photos", { type: [ModelPhotoType] }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
20
packages/backend/src/lib/acl.ts
Normal file
20
packages/backend/src/lib/acl.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { GraphQLError } from "graphql";
|
||||||
|
import type { Context } from "../graphql/builder";
|
||||||
|
|
||||||
|
type UserRole = "viewer" | "model" | "admin";
|
||||||
|
|
||||||
|
export function requireAuth(ctx: Context): void {
|
||||||
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(ctx: Context, ...roles: UserRole[]): void {
|
||||||
|
requireAuth(ctx);
|
||||||
|
if (!roles.includes(ctx.currentUser!.role)) throw new GraphQLError("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireOwnerOrAdmin(ctx: Context, ownerId: string): void {
|
||||||
|
requireAuth(ctx);
|
||||||
|
if (ctx.currentUser!.id !== ownerId && ctx.currentUser!.role !== "admin") {
|
||||||
|
throw new GraphQLError("Forbidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,17 @@ function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogL
|
|||||||
message = arg;
|
message = arg;
|
||||||
} else if (arg !== null && typeof arg === "object") {
|
} else if (arg !== null && typeof arg === "object") {
|
||||||
// Pino-style: log(obj, msg?) — strip internal pino keys
|
// Pino-style: log(obj, msg?) — strip internal pino keys
|
||||||
const { msg: m, level: _l, time: _t, pid: _p, hostname: _h, req: _req, res: _res, reqId, ...rest } = arg as Record<string, unknown>;
|
const {
|
||||||
|
msg: m,
|
||||||
|
level: _l,
|
||||||
|
time: _t,
|
||||||
|
pid: _p,
|
||||||
|
hostname: _h,
|
||||||
|
req: _req,
|
||||||
|
res: _res,
|
||||||
|
reqId,
|
||||||
|
...rest
|
||||||
|
} = arg as Record<string, unknown>;
|
||||||
message = msg || (typeof m === "string" ? m : "");
|
message = msg || (typeof m === "string" ? m : "");
|
||||||
if (reqId) meta.reqId = reqId;
|
if (reqId) meta.reqId = reqId;
|
||||||
Object.assign(meta, rest);
|
Object.assign(meta, rest);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-request": "^7.1.2",
|
"graphql-request": "^7.1.2",
|
||||||
"javascript-time-ago": "^2.6.4",
|
"javascript-time-ago": "^2.6.4",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"media-chrome": "^4.18.0",
|
"media-chrome": "^4.18.0",
|
||||||
"svelte-i18n": "^4.0.1"
|
"svelte-i18n": "^4.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,22 @@
|
|||||||
<span class="sr-only">{$_("header.play")}</span>
|
<span class="sr-only">{$_("header.play")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{#if authStatus.user?.role === "admin"}
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="icon"
|
||||||
|
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||||
|
href="/admin/users"
|
||||||
|
title="Admin"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--settings-3-line] h-4 w-4"></span>
|
||||||
|
<span
|
||||||
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/admin" }) ? "w-full" : "group-hover:w-full"}`}
|
||||||
|
></span>
|
||||||
|
<span class="sr-only">Admin</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
|
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
|
||||||
|
|
||||||
<LogoutButton
|
<LogoutButton
|
||||||
@@ -159,7 +175,7 @@
|
|||||||
aria-hidden={!isMobileMenuOpen}
|
aria-hidden={!isMobileMenuOpen}
|
||||||
>
|
>
|
||||||
<!-- Panel header -->
|
<!-- Panel header -->
|
||||||
<div class="flex items-center px-5 py-4 border-b border-border/30">
|
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
||||||
<Logo hideName={true} />
|
<Logo hideName={true} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,7 +187,7 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
|
||||||
<div class="relative flex items-center gap-3">
|
<div class="relative flex items-center gap-3">
|
||||||
<Avatar class="h-12 w-12 ring-2 ring-primary/30">
|
<Avatar class="h-9 w-9 ring-2 ring-primary/30">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getAssetUrl(authStatus.user!.avatar, "mini")}
|
src={getAssetUrl(authStatus.user!.avatar, "mini")}
|
||||||
alt={authStatus.user!.artist_name}
|
alt={authStatus.user!.artist_name}
|
||||||
@@ -182,17 +198,13 @@
|
|||||||
{getUserInitials(authStatus.user!.artist_name)}
|
{getUserInitials(authStatus.user!.artist_name)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="flex flex-1 flex-col gap-0.5 min-w-0">
|
<div class="flex flex-1 flex-col min-w-0">
|
||||||
<p class="text-sm font-semibold text-foreground truncate">
|
<p class="text-sm font-semibold text-foreground truncate">
|
||||||
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
{authStatus.user!.email}
|
{authStatus.user!.email}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-1.5 mt-0.5">
|
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
|
|
||||||
<span class="text-xs text-muted-foreground">Online</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,6 +277,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{#if authStatus.user?.role === "admin"}
|
||||||
|
<a
|
||||||
|
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/admin" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||||
|
href="/admin/users"
|
||||||
|
onclick={closeMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--settings-3-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-0.5">
|
||||||
|
<span class="text-sm font-medium text-foreground">Admin</span>
|
||||||
|
<span class="text-xs text-muted-foreground">Manage content</span>
|
||||||
|
</div>
|
||||||
|
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||||
|
|||||||
@@ -90,7 +90,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#if (recording.device_info?.length ?? 0) > 2}
|
{#if (recording.device_info?.length ?? 0) > 2}
|
||||||
<div class="text-xs text-muted-foreground/60 px-2">
|
<div class="text-xs text-muted-foreground/60 px-2">
|
||||||
+{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ?? 0) - 2 > 1
|
+{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ??
|
||||||
|
0) -
|
||||||
|
2 >
|
||||||
|
1
|
||||||
? "s"
|
? "s"
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils/utils";
|
import { cn } from "$lib/utils";
|
||||||
import UploadIcon from "@lucide/svelte/icons/upload";
|
import UploadIcon from "@lucide/svelte/icons/upload";
|
||||||
import { displaySize } from ".";
|
import { displaySize } from ".";
|
||||||
import { useId } from "bits-ui";
|
import { useId } from "bits-ui";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils/utils";
|
import { cn } from "$lib/utils";
|
||||||
import type { TagsInputProps } from "./types";
|
import type { TagsInputProps } from "./types";
|
||||||
import TagsInputTag from "./tags-input-tag.svelte";
|
import TagsInputTag from "./tags-input-tag.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
|
|||||||
@@ -990,6 +990,541 @@ export async function updateVideoPlay(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Delete comment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DELETE_COMMENT_MUTATION = gql`
|
||||||
|
mutation DeleteComment($id: Int!) {
|
||||||
|
deleteComment(id: $id)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function deleteComment(id: number) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"deleteComment",
|
||||||
|
async () => {
|
||||||
|
await getGraphQLClient().request(DELETE_COMMENT_MUTATION, { id });
|
||||||
|
},
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin: Users ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ADMIN_LIST_USERS_QUERY = gql`
|
||||||
|
query AdminListUsers($role: String, $search: String, $limit: Int, $offset: Int) {
|
||||||
|
adminListUsers(role: $role, search: $search, limit: $limit, offset: $offset) {
|
||||||
|
total
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
role
|
||||||
|
avatar
|
||||||
|
email_verified
|
||||||
|
date_created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminListUsers(
|
||||||
|
opts: { role?: string; search?: string; limit?: number; offset?: number } = {},
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
token?: string,
|
||||||
|
) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"adminListUsers",
|
||||||
|
async () => {
|
||||||
|
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
||||||
|
const data = await client.request<{
|
||||||
|
adminListUsers: { total: number; items: User[] };
|
||||||
|
}>(ADMIN_LIST_USERS_QUERY, opts);
|
||||||
|
return data.adminListUsers;
|
||||||
|
},
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||||
|
mutation AdminUpdateUser(
|
||||||
|
$userId: String!
|
||||||
|
$role: String
|
||||||
|
$firstName: String
|
||||||
|
$lastName: String
|
||||||
|
$artistName: String
|
||||||
|
$avatarId: String
|
||||||
|
$bannerId: String
|
||||||
|
) {
|
||||||
|
adminUpdateUser(
|
||||||
|
userId: $userId
|
||||||
|
role: $role
|
||||||
|
firstName: $firstName
|
||||||
|
lastName: $lastName
|
||||||
|
artistName: $artistName
|
||||||
|
avatarId: $avatarId
|
||||||
|
bannerId: $bannerId
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
artist_name
|
||||||
|
role
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
date_created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminUpdateUser(input: {
|
||||||
|
userId: string;
|
||||||
|
role?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
artistName?: string;
|
||||||
|
avatarId?: string;
|
||||||
|
bannerId?: string;
|
||||||
|
}) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"adminUpdateUser",
|
||||||
|
async () => {
|
||||||
|
const data = await getGraphQLClient().request<{ adminUpdateUser: User | null }>(
|
||||||
|
ADMIN_UPDATE_USER_MUTATION,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return data.adminUpdateUser;
|
||||||
|
},
|
||||||
|
{ userId: input.userId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_DELETE_USER_MUTATION = gql`
|
||||||
|
mutation AdminDeleteUser($userId: String!) {
|
||||||
|
adminDeleteUser(userId: $userId)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminDeleteUser(userId: string) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"adminDeleteUser",
|
||||||
|
async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_DELETE_USER_MUTATION, { userId });
|
||||||
|
},
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_GET_USER_QUERY = gql`
|
||||||
|
query AdminGetUser($userId: String!) {
|
||||||
|
adminGetUser(userId: $userId) {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
role
|
||||||
|
avatar
|
||||||
|
banner
|
||||||
|
description
|
||||||
|
tags
|
||||||
|
email_verified
|
||||||
|
date_created
|
||||||
|
photos {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminGetUser(userId: string, token?: string) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"adminGetUser",
|
||||||
|
async () => {
|
||||||
|
const client = token ? getAuthClient(token) : getGraphQLClient();
|
||||||
|
const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId });
|
||||||
|
return data.adminGetUser;
|
||||||
|
},
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_ADD_USER_PHOTO_MUTATION = gql`
|
||||||
|
mutation AdminAddUserPhoto($userId: String!, $fileId: String!) {
|
||||||
|
adminAddUserPhoto(userId: $userId, fileId: $fileId)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminAddUserPhoto(userId: string, fileId: string) {
|
||||||
|
return loggedApiCall("adminAddUserPhoto", async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_ADD_USER_PHOTO_MUTATION, { userId, fileId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_REMOVE_USER_PHOTO_MUTATION = gql`
|
||||||
|
mutation AdminRemoveUserPhoto($userId: String!, $fileId: String!) {
|
||||||
|
adminRemoveUserPhoto(userId: $userId, fileId: $fileId)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminRemoveUserPhoto(userId: string, fileId: string) {
|
||||||
|
return loggedApiCall("adminRemoveUserPhoto", async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_REMOVE_USER_PHOTO_MUTATION, { userId, fileId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin: Videos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ADMIN_LIST_VIDEOS_QUERY = gql`
|
||||||
|
query AdminListVideos {
|
||||||
|
adminListVideos {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
description
|
||||||
|
image
|
||||||
|
movie
|
||||||
|
tags
|
||||||
|
upload_date
|
||||||
|
premium
|
||||||
|
featured
|
||||||
|
likes_count
|
||||||
|
plays_count
|
||||||
|
models {
|
||||||
|
id
|
||||||
|
artist_name
|
||||||
|
slug
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
movie_file {
|
||||||
|
id
|
||||||
|
filename
|
||||||
|
mime_type
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminListVideos(fetchFn?: typeof globalThis.fetch, token?: string) {
|
||||||
|
return loggedApiCall("adminListVideos", async () => {
|
||||||
|
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
||||||
|
const data = await client.request<{ adminListVideos: Video[] }>(
|
||||||
|
ADMIN_LIST_VIDEOS_QUERY,
|
||||||
|
);
|
||||||
|
return data.adminListVideos;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREATE_VIDEO_MUTATION = gql`
|
||||||
|
mutation CreateVideo(
|
||||||
|
$title: String!
|
||||||
|
$slug: String!
|
||||||
|
$description: String
|
||||||
|
$imageId: String
|
||||||
|
$movieId: String
|
||||||
|
$tags: [String!]
|
||||||
|
$premium: Boolean
|
||||||
|
$featured: Boolean
|
||||||
|
$uploadDate: String
|
||||||
|
) {
|
||||||
|
createVideo(
|
||||||
|
title: $title
|
||||||
|
slug: $slug
|
||||||
|
description: $description
|
||||||
|
imageId: $imageId
|
||||||
|
movieId: $movieId
|
||||||
|
tags: $tags
|
||||||
|
premium: $premium
|
||||||
|
featured: $featured
|
||||||
|
uploadDate: $uploadDate
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function createVideo(input: {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
imageId?: string;
|
||||||
|
movieId?: string;
|
||||||
|
tags?: string[];
|
||||||
|
premium?: boolean;
|
||||||
|
featured?: boolean;
|
||||||
|
uploadDate?: string;
|
||||||
|
}) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"createVideo",
|
||||||
|
async () => {
|
||||||
|
const data = await getGraphQLClient().request<{ createVideo: Video }>(
|
||||||
|
CREATE_VIDEO_MUTATION,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return data.createVideo;
|
||||||
|
},
|
||||||
|
{ title: input.title },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPDATE_VIDEO_MUTATION = gql`
|
||||||
|
mutation UpdateVideo(
|
||||||
|
$id: String!
|
||||||
|
$title: String
|
||||||
|
$slug: String
|
||||||
|
$description: String
|
||||||
|
$imageId: String
|
||||||
|
$movieId: String
|
||||||
|
$tags: [String!]
|
||||||
|
$premium: Boolean
|
||||||
|
$featured: Boolean
|
||||||
|
$uploadDate: String
|
||||||
|
) {
|
||||||
|
updateVideo(
|
||||||
|
id: $id
|
||||||
|
title: $title
|
||||||
|
slug: $slug
|
||||||
|
description: $description
|
||||||
|
imageId: $imageId
|
||||||
|
movieId: $movieId
|
||||||
|
tags: $tags
|
||||||
|
premium: $premium
|
||||||
|
featured: $featured
|
||||||
|
uploadDate: $uploadDate
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function updateVideo(input: {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
imageId?: string;
|
||||||
|
movieId?: string;
|
||||||
|
tags?: string[];
|
||||||
|
premium?: boolean;
|
||||||
|
featured?: boolean;
|
||||||
|
uploadDate?: string;
|
||||||
|
}) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"updateVideo",
|
||||||
|
async () => {
|
||||||
|
const data = await getGraphQLClient().request<{ updateVideo: Video | null }>(
|
||||||
|
UPDATE_VIDEO_MUTATION,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return data.updateVideo;
|
||||||
|
},
|
||||||
|
{ id: input.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELETE_VIDEO_MUTATION = gql`
|
||||||
|
mutation DeleteVideo($id: String!) {
|
||||||
|
deleteVideo(id: $id)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function deleteVideo(id: string) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"deleteVideo",
|
||||||
|
async () => {
|
||||||
|
await getGraphQLClient().request(DELETE_VIDEO_MUTATION, { id });
|
||||||
|
},
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SET_VIDEO_MODELS_MUTATION = gql`
|
||||||
|
mutation SetVideoModels($videoId: String!, $userIds: [String!]!) {
|
||||||
|
setVideoModels(videoId: $videoId, userIds: $userIds)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function setVideoModels(videoId: string, userIds: string[]) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"setVideoModels",
|
||||||
|
async () => {
|
||||||
|
await getGraphQLClient().request(SET_VIDEO_MODELS_MUTATION, { videoId, userIds });
|
||||||
|
},
|
||||||
|
{ videoId, count: userIds.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin: Articles ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ADMIN_LIST_ARTICLES_QUERY = gql`
|
||||||
|
query AdminListArticles {
|
||||||
|
adminListArticles {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
excerpt
|
||||||
|
image
|
||||||
|
tags
|
||||||
|
publish_date
|
||||||
|
category
|
||||||
|
featured
|
||||||
|
content
|
||||||
|
author {
|
||||||
|
first_name
|
||||||
|
last_name
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminListArticles(fetchFn?: typeof globalThis.fetch, token?: string) {
|
||||||
|
return loggedApiCall("adminListArticles", async () => {
|
||||||
|
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
||||||
|
const data = await client.request<{ adminListArticles: Article[] }>(
|
||||||
|
ADMIN_LIST_ARTICLES_QUERY,
|
||||||
|
);
|
||||||
|
return data.adminListArticles;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREATE_ARTICLE_MUTATION = gql`
|
||||||
|
mutation CreateArticle(
|
||||||
|
$title: String!
|
||||||
|
$slug: String!
|
||||||
|
$excerpt: String
|
||||||
|
$content: String
|
||||||
|
$imageId: String
|
||||||
|
$tags: [String!]
|
||||||
|
$category: String
|
||||||
|
$featured: Boolean
|
||||||
|
$publishDate: String
|
||||||
|
) {
|
||||||
|
createArticle(
|
||||||
|
title: $title
|
||||||
|
slug: $slug
|
||||||
|
excerpt: $excerpt
|
||||||
|
content: $content
|
||||||
|
imageId: $imageId
|
||||||
|
tags: $tags
|
||||||
|
category: $category
|
||||||
|
featured: $featured
|
||||||
|
publishDate: $publishDate
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function createArticle(input: {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
excerpt?: string;
|
||||||
|
content?: string;
|
||||||
|
imageId?: string;
|
||||||
|
tags?: string[];
|
||||||
|
category?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
publishDate?: string;
|
||||||
|
}) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"createArticle",
|
||||||
|
async () => {
|
||||||
|
const data = await getGraphQLClient().request<{ createArticle: Article }>(
|
||||||
|
CREATE_ARTICLE_MUTATION,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return data.createArticle;
|
||||||
|
},
|
||||||
|
{ title: input.title },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPDATE_ARTICLE_MUTATION = gql`
|
||||||
|
mutation UpdateArticle(
|
||||||
|
$id: String!
|
||||||
|
$title: String
|
||||||
|
$slug: String
|
||||||
|
$excerpt: String
|
||||||
|
$content: String
|
||||||
|
$imageId: String
|
||||||
|
$tags: [String!]
|
||||||
|
$category: String
|
||||||
|
$featured: Boolean
|
||||||
|
$publishDate: String
|
||||||
|
) {
|
||||||
|
updateArticle(
|
||||||
|
id: $id
|
||||||
|
title: $title
|
||||||
|
slug: $slug
|
||||||
|
excerpt: $excerpt
|
||||||
|
content: $content
|
||||||
|
imageId: $imageId
|
||||||
|
tags: $tags
|
||||||
|
category: $category
|
||||||
|
featured: $featured
|
||||||
|
publishDate: $publishDate
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function updateArticle(input: {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
content?: string;
|
||||||
|
imageId?: string;
|
||||||
|
tags?: string[];
|
||||||
|
category?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
publishDate?: string;
|
||||||
|
}) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"updateArticle",
|
||||||
|
async () => {
|
||||||
|
const data = await getGraphQLClient().request<{ updateArticle: Article | null }>(
|
||||||
|
UPDATE_ARTICLE_MUTATION,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return data.updateArticle;
|
||||||
|
},
|
||||||
|
{ id: input.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELETE_ARTICLE_MUTATION = gql`
|
||||||
|
mutation DeleteArticle($id: String!) {
|
||||||
|
deleteArticle(id: $id)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function deleteArticle(id: string) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"deleteArticle",
|
||||||
|
async () => {
|
||||||
|
await getGraphQLClient().request(DELETE_ARTICLE_MUTATION, { id });
|
||||||
|
},
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Analytics ───────────────────────────────────────────────────────────────
|
// ─── Analytics ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ANALYTICS_QUERY = gql`
|
const ANALYTICS_QUERY = gql`
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/*
|
|
||||||
Installed from @ieedan/shadcn-svelte-extras
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type ClassValue, clsx } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
|
||||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
|
||||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
|
||||||
ref?: U | null;
|
|
||||||
};
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
<!-- Glassmorphism overlay -->
|
<!-- Glassmorphism overlay -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/2 backdrop-blur-[0.5px]"
|
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/2"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
8
packages/frontend/src/routes/admin/+layout.server.ts
Normal file
8
packages/frontend/src/routes/admin/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export async function load({ locals }) {
|
||||||
|
if (!locals.authStatus.authenticated || locals.authStatus.user?.role !== "admin") {
|
||||||
|
throw redirect(302, "/");
|
||||||
|
}
|
||||||
|
return { authStatus: locals.authStatus };
|
||||||
|
}
|
||||||
52
packages/frontend/src/routes/admin/+layout.svelte
Normal file
52
packages/frontend/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const { children } = $props();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ name: "Users", href: "/admin/users", icon: "icon-[ri--team-line]" },
|
||||||
|
{ name: "Videos", href: "/admin/videos", icon: "icon-[ri--film-line]" },
|
||||||
|
{ name: "Articles", href: "/admin/articles", icon: "icon-[ri--article-line]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
return page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex min-h-screen">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-56 shrink-0 flex flex-col">
|
||||||
|
<div class="px-4 py-5 border-b border-border/40">
|
||||||
|
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
← Back to site
|
||||||
|
</a>
|
||||||
|
<h1 class="mt-2 text-base font-bold text-foreground">Admin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
|
{#each navLinks as link (link.href)}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive(link.href)
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class={`${link.icon} h-4 w-4`}></span>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
8
packages/frontend/src/routes/admin/+page.svelte
Normal file
8
packages/frontend/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
goto("/admin/users", { replaceState: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { adminListArticles } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ fetch, cookies }) {
|
||||||
|
const token = cookies.get("session_token") || "";
|
||||||
|
const articles = await adminListArticles(fetch, token).catch(() => []);
|
||||||
|
return { articles };
|
||||||
|
}
|
||||||
137
packages/frontend/src/routes/admin/articles/+page.svelte
Normal file
137
packages/frontend/src/routes/admin/articles/+page.svelte
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { deleteArticle } from "$lib/services";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import type { Article } from "$lib/types";
|
||||||
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
const timeAgo = new TimeAgo("en");
|
||||||
|
|
||||||
|
let deleteTarget: Article | null = $state(null);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
function confirmDelete(article: Article) {
|
||||||
|
deleteTarget = article;
|
||||||
|
deleteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await deleteArticle(deleteTarget.id);
|
||||||
|
toast.success("Article deleted");
|
||||||
|
deleteOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete article");
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Articles</h1>
|
||||||
|
<Button href="/admin/articles/new">
|
||||||
|
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>New article
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/40 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Article</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Category</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Published</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border/30">
|
||||||
|
{#each data.articles as article (article.id)}
|
||||||
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if article.image}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(article.image, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-10 w-16 rounded object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-10 w-16 rounded bg-muted/50 flex items-center justify-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--article-line] h-5 w-5"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{article.title}</p>
|
||||||
|
{#if article.featured}
|
||||||
|
<span
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium"
|
||||||
|
>Featured</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground capitalize">{article.category ?? "—"}</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground">
|
||||||
|
{timeAgo.format(new Date(article.publish_date))}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<Button size="sm" variant="ghost" href="/admin/articles/{article.id}">
|
||||||
|
<span class="icon-[ri--edit-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onclick={() => confirmDelete(article)}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if data.articles.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
No articles yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete article</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Permanently delete <strong>{deleteTarget?.title}</strong>? This cannot be undone.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { adminListArticles } from "$lib/services";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export async function load({ params, fetch, cookies }) {
|
||||||
|
const token = cookies.get("session_token") || "";
|
||||||
|
const articles = await adminListArticles(fetch, token).catch(() => []);
|
||||||
|
const article = articles.find((a) => a.id === params.id);
|
||||||
|
if (!article) throw error(404, "Article not found");
|
||||||
|
return { article };
|
||||||
|
}
|
||||||
152
packages/frontend/src/routes/admin/articles/[id]/+page.svelte
Normal file
152
packages/frontend/src/routes/admin/articles/[id]/+page.svelte
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { updateArticle, uploadFile } from "$lib/services";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let title = $state(data.article.title);
|
||||||
|
let slug = $state(data.article.slug);
|
||||||
|
let excerpt = $state(data.article.excerpt ?? "");
|
||||||
|
let content = $state(data.article.content ?? "");
|
||||||
|
let category = $state(data.article.category ?? "");
|
||||||
|
let tags = $state<string[]>(data.article.tags ?? []);
|
||||||
|
let featured = $state(data.article.featured ?? false);
|
||||||
|
let publishDate = $state(
|
||||||
|
data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "",
|
||||||
|
);
|
||||||
|
let imageId = $state<string | null>(data.article.image ?? null);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
let preview = $derived(content ? (marked.parse(content) as string) : "");
|
||||||
|
|
||||||
|
async function handleImageUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
imageId = res.id;
|
||||||
|
toast.success("Image uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Image upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await updateArticle({
|
||||||
|
id: data.article.id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
excerpt: excerpt || undefined,
|
||||||
|
content: content || undefined,
|
||||||
|
imageId: imageId || undefined,
|
||||||
|
tags,
|
||||||
|
category: category || undefined,
|
||||||
|
featured,
|
||||||
|
publishDate: publishDate || undefined,
|
||||||
|
});
|
||||||
|
toast.success("Article updated");
|
||||||
|
goto("/admin/articles");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Failed to update article");
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<Button variant="ghost" href="/admin/articles" size="sm">
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-2xl font-bold">Edit article</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5 max-w-4xl">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="title">Title *</Label>
|
||||||
|
<Input id="title" bind:value={title} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="slug">Slug *</Label>
|
||||||
|
<Input id="slug" bind:value={slug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="excerpt">Excerpt</Label>
|
||||||
|
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown editor with live preview -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Content (Markdown)</Label>
|
||||||
|
<div class="grid grid-cols-2 gap-4 min-h-96">
|
||||||
|
<Textarea bind:value={content} class="h-full min-h-96 font-mono text-sm resize-none" />
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{#if preview}
|
||||||
|
{@html preview}
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground italic text-sm">Preview will appear here…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Cover image</Label>
|
||||||
|
{#if imageId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(imageId, "thumbnail")}
|
||||||
|
alt=""
|
||||||
|
class="h-24 rounded object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="category">Category</Label>
|
||||||
|
<Input id="category" bind:value={category} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="publishDate">Publish date</Label>
|
||||||
|
<Input id="publishDate" type="datetime-local" bind:value={publishDate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<TagsInput bind:value={tags} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
|
<span class="text-sm">Featured</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<Button onclick={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" href="/admin/articles">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export async function load() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
162
packages/frontend/src/routes/admin/articles/new/+page.svelte
Normal file
162
packages/frontend/src/routes/admin/articles/new/+page.svelte
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { createArticle, uploadFile } from "$lib/services";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
|
||||||
|
let title = $state("");
|
||||||
|
let slug = $state("");
|
||||||
|
let excerpt = $state("");
|
||||||
|
let content = $state("");
|
||||||
|
let category = $state("");
|
||||||
|
let tags = $state<string[]>([]);
|
||||||
|
let featured = $state(false);
|
||||||
|
let publishDate = $state("");
|
||||||
|
let imageId = $state<string | null>(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
let preview = $derived(content ? (marked.parse(content) as string) : "");
|
||||||
|
|
||||||
|
function generateSlug(t: string) {
|
||||||
|
return t
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
imageId = res.id;
|
||||||
|
toast.success("Image uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Image upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!title || !slug) {
|
||||||
|
toast.error("Title and slug are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await createArticle({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
excerpt: excerpt || undefined,
|
||||||
|
content: content || undefined,
|
||||||
|
imageId: imageId || undefined,
|
||||||
|
tags,
|
||||||
|
category: category || undefined,
|
||||||
|
featured,
|
||||||
|
publishDate: publishDate || undefined,
|
||||||
|
});
|
||||||
|
toast.success("Article created");
|
||||||
|
goto("/admin/articles");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Failed to create article");
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<Button variant="ghost" href="/admin/articles" size="sm">
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-2xl font-bold">New article</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5 max-w-4xl">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
bind:value={title}
|
||||||
|
oninput={() => {
|
||||||
|
if (!slug) slug = generateSlug(title);
|
||||||
|
}}
|
||||||
|
placeholder="Article title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="slug">Slug *</Label>
|
||||||
|
<Input id="slug" bind:value={slug} placeholder="article-slug" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="excerpt">Excerpt</Label>
|
||||||
|
<Textarea id="excerpt" bind:value={excerpt} placeholder="Short summary…" rows={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown editor with live preview -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Content (Markdown)</Label>
|
||||||
|
<div class="grid grid-cols-2 gap-4 min-h-96">
|
||||||
|
<Textarea
|
||||||
|
bind:value={content}
|
||||||
|
placeholder="Write in Markdown…"
|
||||||
|
class="h-full min-h-96 font-mono text-sm resize-none"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{#if preview}
|
||||||
|
{@html preview}
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground italic text-sm">Preview will appear here…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Cover image</Label>
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
|
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="category">Category</Label>
|
||||||
|
<Input id="category" bind:value={category} placeholder="e.g. news, tutorial…" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="publishDate">Publish date</Label>
|
||||||
|
<Input id="publishDate" type="datetime-local" bind:value={publishDate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<TagsInput bind:value={tags} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
|
<span class="text-sm">Featured</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<Button onclick={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? "Creating…" : "Create article"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" href="/admin/articles">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
16
packages/frontend/src/routes/admin/users/+page.server.ts
Normal file
16
packages/frontend/src/routes/admin/users/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { adminListUsers } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ fetch, url, cookies }) {
|
||||||
|
const token = cookies.get("session_token") || "";
|
||||||
|
const role = url.searchParams.get("role") || undefined;
|
||||||
|
const search = url.searchParams.get("search") || undefined;
|
||||||
|
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const result = await adminListUsers({ role, search, limit, offset }, fetch, token).catch(() => ({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ...result, role, search, offset, limit };
|
||||||
|
}
|
||||||
250
packages/frontend/src/routes/admin/users/+page.svelte
Normal file
250
packages/frontend/src/routes/admin/users/+page.svelte
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto, invalidateAll } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { SvelteURLSearchParams } from "svelte/reactivity";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { adminUpdateUser, adminDeleteUser } from "$lib/services";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import type { User } from "$lib/types";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let searchValue = $state(data.search ?? "");
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
let deleteTarget: User | null = $state(null);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let updatingId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const currentUserId = page.data.authStatus?.user?.id;
|
||||||
|
|
||||||
|
const roles = ["", "viewer", "model", "admin"] as const;
|
||||||
|
|
||||||
|
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 setRole(role: string) {
|
||||||
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
|
if (role) params.set("role", role);
|
||||||
|
else params.delete("role");
|
||||||
|
params.delete("offset");
|
||||||
|
goto(`?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeUserRole(user: User, newRole: string) {
|
||||||
|
updatingId = user.id;
|
||||||
|
try {
|
||||||
|
await adminUpdateUser({ userId: user.id, role: newRole });
|
||||||
|
toast.success(`Role updated to ${newRole}`);
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update role");
|
||||||
|
} finally {
|
||||||
|
updatingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(user: User) {
|
||||||
|
deleteTarget = user;
|
||||||
|
deleteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await adminDeleteUser(deleteTarget.id);
|
||||||
|
toast.success("User deleted");
|
||||||
|
deleteOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete user");
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string | Date) {
|
||||||
|
return new Date(d).toLocaleDateString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Users</h1>
|
||||||
|
<span class="text-sm text-muted-foreground">{data.total} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search email or name…"
|
||||||
|
class="max-w-xs"
|
||||||
|
value={searchValue}
|
||||||
|
oninput={(e) => {
|
||||||
|
searchValue = (e.target as HTMLInputElement).value;
|
||||||
|
debounceSearch(searchValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#each roles as role (role)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
|
||||||
|
onclick={() => setRole(role)}
|
||||||
|
>
|
||||||
|
{role || "All"}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="rounded-lg border border-border/40 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Role</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Joined</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border/30">
|
||||||
|
{#each data.items as user (user.id)}
|
||||||
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if user.avatar}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(user.avatar, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-semibold text-primary"
|
||||||
|
>
|
||||||
|
{(user.artist_name || user.email)[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium">{user.artist_name || user.first_name || "—"}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground">{user.email}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={user.role}
|
||||||
|
disabled={user.id === currentUserId || updatingId === user.id}
|
||||||
|
onValueChange={(v) => v && changeUserRole(user, v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-24 h-7 text-xs">
|
||||||
|
{user.role}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="viewer">Viewer</SelectItem>
|
||||||
|
<SelectItem value="model">Model</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground">{formatDate(user.date_created)}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<Button size="sm" variant="ghost" href="/admin/users/{user.id}">
|
||||||
|
<span class="icon-[ri--edit-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
disabled={user.id === currentUserId}
|
||||||
|
onclick={() => confirmDelete(user)}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if data.items.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">No users found</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if data.total > data.limit}
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
Showing {data.offset + 1}–{Math.min(data.offset + data.limit, data.total)} of {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()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation dialog -->
|
||||||
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete user</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Are you sure you want to permanently delete <strong
|
||||||
|
>{deleteTarget?.artist_name || deleteTarget?.email}</strong
|
||||||
|
>? This cannot be undone.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { adminGetUser } from "$lib/services";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export async function load({ params, cookies }) {
|
||||||
|
const token = cookies.get("session_token") || "";
|
||||||
|
const user = await adminGetUser(params.id, token).catch(() => null);
|
||||||
|
if (!user) throw error(404, "User not found");
|
||||||
|
return { user };
|
||||||
|
}
|
||||||
190
packages/frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
190
packages/frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
import {
|
||||||
|
adminUpdateUser,
|
||||||
|
adminAddUserPhoto,
|
||||||
|
adminRemoveUserPhoto,
|
||||||
|
uploadFile,
|
||||||
|
} from "$lib/services";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let firstName = $state(data.user.first_name ?? "");
|
||||||
|
let lastName = $state(data.user.last_name ?? "");
|
||||||
|
let artistName = $state(data.user.artist_name ?? "");
|
||||||
|
let avatarId = $state<string | null>(data.user.avatar ?? null);
|
||||||
|
let bannerId = $state<string | null>(data.user.banner ?? null);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
async function handleAvatarUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
avatarId = res.id;
|
||||||
|
toast.success("Avatar uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Avatar upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBannerUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
bannerId = res.id;
|
||||||
|
toast.success("Banner uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Banner upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoUpload(files: File[]) {
|
||||||
|
for (const file of files) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
await adminAddUserPhoto(data.user.id, res.id);
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to upload ${file.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`${files.length} photo${files.length > 1 ? "s" : ""} added`);
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePhoto(fileId: string) {
|
||||||
|
try {
|
||||||
|
await adminRemoveUserPhoto(data.user.id, fileId);
|
||||||
|
toast.success("Photo removed");
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to remove photo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await adminUpdateUser({
|
||||||
|
userId: data.user.id,
|
||||||
|
firstName: firstName || undefined,
|
||||||
|
lastName: lastName || undefined,
|
||||||
|
artistName: artistName || undefined,
|
||||||
|
avatarId: avatarId || undefined,
|
||||||
|
bannerId: bannerId || undefined,
|
||||||
|
});
|
||||||
|
toast.success("Saved");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Save failed");
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6 max-w-2xl">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<Button variant="ghost" href="/admin/users" size="sm">
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<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}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Basic info -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="firstName">First name</Label>
|
||||||
|
<Input id="firstName" bind:value={firstName} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="lastName">Last name</Label>
|
||||||
|
<Input id="lastName" bind:value={lastName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="artistName">Artist name</Label>
|
||||||
|
<Input id="artistName" bind:value={artistName} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Avatar</Label>
|
||||||
|
{#if avatarId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(avatarId, "thumbnail")}
|
||||||
|
alt=""
|
||||||
|
class="h-20 w-20 rounded-full object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Banner -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Banner</Label>
|
||||||
|
{#if bannerId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(bannerId, "preview")}
|
||||||
|
alt=""
|
||||||
|
class="w-full h-24 rounded object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<Button onclick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo gallery -->
|
||||||
|
<div class="space-y-3 pt-4 border-t border-border/40">
|
||||||
|
<Label>Photo gallery</Label>
|
||||||
|
|
||||||
|
{#if data.user.photos && data.user.photos.length > 0}
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each data.user.photos as photo (photo.id)}
|
||||||
|
<div class="relative group">
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(photo.id, "thumbnail")}
|
||||||
|
alt=""
|
||||||
|
class="w-full aspect-square object-cover rounded"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded"
|
||||||
|
onclick={() => removePhoto(photo.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No photos yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { adminListVideos } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ fetch, cookies }) {
|
||||||
|
const token = cookies.get("session_token") || "";
|
||||||
|
const videos = await adminListVideos(fetch, token).catch(() => []);
|
||||||
|
return { videos };
|
||||||
|
}
|
||||||
141
packages/frontend/src/routes/admin/videos/+page.svelte
Normal file
141
packages/frontend/src/routes/admin/videos/+page.svelte
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { deleteVideo } from "$lib/services";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import type { Video } from "$lib/types";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let deleteTarget: Video | null = $state(null);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
function confirmDelete(video: Video) {
|
||||||
|
deleteTarget = video;
|
||||||
|
deleteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await deleteVideo(deleteTarget.id);
|
||||||
|
toast.success("Video deleted");
|
||||||
|
deleteOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete video");
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Videos</h1>
|
||||||
|
<Button href="/admin/videos/new">
|
||||||
|
<span class="icon-[ri--add-line] h-4 w-4 mr-1"></span>New video
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/40 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Video</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Badges</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Plays</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Likes</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border/30">
|
||||||
|
{#each data.videos as video (video.id)}
|
||||||
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if video.image}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(video.image, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-10 w-16 rounded object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-10 w-16 rounded bg-muted/50 flex items-center justify-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--film-line] h-5 w-5"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{video.title}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{video.slug}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#if video.premium}
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-500/10 text-yellow-600"
|
||||||
|
>Premium</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if video.featured}
|
||||||
|
<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary"
|
||||||
|
>Featured</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground">{video.plays_count ?? 0}</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground">{video.likes_count ?? 0}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<Button size="sm" variant="ghost" href="/admin/videos/{video.id}">
|
||||||
|
<span class="icon-[ri--edit-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onclick={() => confirmDelete(video)}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if data.videos.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">No videos yet</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete video</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Permanently delete <strong>{deleteTarget?.title}</strong>? This cannot be undone.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { adminListVideos, getModels } from "$lib/services";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export async function load({ params, fetch, cookies }) {
|
||||||
|
const token = cookies.get("session_token") || "";
|
||||||
|
const [allVideos, models] = await Promise.all([
|
||||||
|
adminListVideos(fetch, token).catch(() => []),
|
||||||
|
getModels(fetch).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const video = allVideos.find((v) => v.id === params.id);
|
||||||
|
if (!video) throw error(404, "Video not found");
|
||||||
|
|
||||||
|
return { video, models };
|
||||||
|
}
|
||||||
185
packages/frontend/src/routes/admin/videos/[id]/+page.svelte
Normal file
185
packages/frontend/src/routes/admin/videos/[id]/+page.svelte
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { updateVideo, setVideoModels, uploadFile } from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let title = $state(data.video.title);
|
||||||
|
let slug = $state(data.video.slug);
|
||||||
|
let description = $state(data.video.description ?? "");
|
||||||
|
let tags = $state<string[]>(data.video.tags ?? []);
|
||||||
|
let premium = $state(data.video.premium ?? false);
|
||||||
|
let featured = $state(data.video.featured ?? false);
|
||||||
|
let uploadDate = $state(
|
||||||
|
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
|
||||||
|
);
|
||||||
|
let imageId = $state<string | null>(data.video.image ?? null);
|
||||||
|
let movieId = $state<string | null>(data.video.movie ?? null);
|
||||||
|
let selectedModelIds = $state<string[]>(
|
||||||
|
data.video.models?.map((m: { id: string }) => m.id) ?? [],
|
||||||
|
);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
async function handleImageUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
imageId = res.id;
|
||||||
|
toast.success("Cover image uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Image upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVideoUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
movieId = res.id;
|
||||||
|
toast.success("Video uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Video upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleModel(id: string) {
|
||||||
|
selectedModelIds = selectedModelIds.includes(id)
|
||||||
|
? selectedModelIds.filter((m) => m !== id)
|
||||||
|
: [...selectedModelIds, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await updateVideo({
|
||||||
|
id: data.video.id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
description: description || undefined,
|
||||||
|
imageId: imageId || undefined,
|
||||||
|
movieId: movieId || undefined,
|
||||||
|
tags,
|
||||||
|
premium,
|
||||||
|
featured,
|
||||||
|
uploadDate: uploadDate || undefined,
|
||||||
|
});
|
||||||
|
await setVideoModels(data.video.id, selectedModelIds);
|
||||||
|
toast.success("Video updated");
|
||||||
|
goto("/admin/videos");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Failed to update video");
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6 max-w-2xl">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<Button variant="ghost" href="/admin/videos" size="sm">
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-2xl font-bold">Edit video</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="title">Title *</Label>
|
||||||
|
<Input id="title" bind:value={title} placeholder="Video title" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="slug">Slug *</Label>
|
||||||
|
<Input id="slug" bind:value={slug} placeholder="video-slug" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="description">Description</Label>
|
||||||
|
<Textarea id="description" bind:value={description} rows={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Cover image</Label>
|
||||||
|
{#if imageId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(imageId, "thumbnail")}
|
||||||
|
alt=""
|
||||||
|
class="h-24 rounded object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Video file</Label>
|
||||||
|
{#if movieId}
|
||||||
|
<p class="text-xs text-muted-foreground mb-1">Current file: {movieId}</p>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<TagsInput bind:value={tags} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="uploadDate">Publish date</Label>
|
||||||
|
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||||
|
<span class="text-sm">Premium</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
|
<span class="text-sm">Featured</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.models.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Models</Label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each data.models as model (model.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
||||||
|
selectedModelIds.includes(model.id)
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border/40 text-muted-foreground hover:border-primary/40"
|
||||||
|
}`}
|
||||||
|
onclick={() => toggleModel(model.id)}
|
||||||
|
>
|
||||||
|
{model.artist_name || model.id}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<Button onclick={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" href="/admin/videos">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { getModels } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
const models = await getModels(fetch).catch(() => []);
|
||||||
|
return { models };
|
||||||
|
}
|
||||||
196
packages/frontend/src/routes/admin/videos/new/+page.svelte
Normal file
196
packages/frontend/src/routes/admin/videos/new/+page.svelte
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { createVideo, setVideoModels, uploadFile } from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let title = $state("");
|
||||||
|
let slug = $state("");
|
||||||
|
let description = $state("");
|
||||||
|
let tags = $state<string[]>([]);
|
||||||
|
let premium = $state(false);
|
||||||
|
let featured = $state(false);
|
||||||
|
let uploadDate = $state("");
|
||||||
|
let imageId = $state<string | null>(null);
|
||||||
|
let movieId = $state<string | null>(null);
|
||||||
|
let selectedModelIds = $state<string[]>([]);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
function generateSlug(t: string) {
|
||||||
|
return t
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
imageId = res.id;
|
||||||
|
toast.success("Cover image uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Image upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVideoUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
movieId = res.id;
|
||||||
|
toast.success("Video uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Video upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleModel(id: string) {
|
||||||
|
selectedModelIds = selectedModelIds.includes(id)
|
||||||
|
? selectedModelIds.filter((m) => m !== id)
|
||||||
|
: [...selectedModelIds, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!title || !slug) {
|
||||||
|
toast.error("Title and slug are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const video = await createVideo({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
description: description || undefined,
|
||||||
|
imageId: imageId || undefined,
|
||||||
|
movieId: movieId || undefined,
|
||||||
|
tags,
|
||||||
|
premium,
|
||||||
|
featured,
|
||||||
|
uploadDate: uploadDate || undefined,
|
||||||
|
});
|
||||||
|
if (selectedModelIds.length > 0) {
|
||||||
|
await setVideoModels(video.id, selectedModelIds);
|
||||||
|
}
|
||||||
|
toast.success("Video created");
|
||||||
|
goto("/admin/videos");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Failed to create video");
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6 max-w-2xl">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<Button variant="ghost" href="/admin/videos" size="sm">
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>Back
|
||||||
|
</Button>
|
||||||
|
<h1 class="text-2xl font-bold">New video</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
bind:value={title}
|
||||||
|
oninput={() => {
|
||||||
|
if (!slug) slug = generateSlug(title);
|
||||||
|
}}
|
||||||
|
placeholder="Video title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="slug">Slug *</Label>
|
||||||
|
<Input id="slug" bind:value={slug} placeholder="video-slug" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Cover image</Label>
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
|
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Video file</Label>
|
||||||
|
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||||
|
{#if movieId}<p class="text-xs text-green-600 mt-1">Video uploaded ✓</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<TagsInput bind:value={tags} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="uploadDate">Publish date</Label>
|
||||||
|
<Input id="uploadDate" type="datetime-local" bind:value={uploadDate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||||
|
<span class="text-sm">Premium</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
|
<span class="text-sm">Featured</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.models.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Models</Label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each data.models as model (model.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
||||||
|
selectedModelIds.includes(model.id)
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border/40 text-muted-foreground hover:border-primary/40"
|
||||||
|
}`}
|
||||||
|
onclick={() => toggleModel(model.id)}
|
||||||
|
>
|
||||||
|
{model.artist_name || model.id}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<Button onclick={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? "Creating…" : "Create video"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" href="/admin/videos">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
import PeonyBackground from "$lib/components/background/peony-background.svelte";
|
||||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
<div
|
<div
|
||||||
class="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground prose-ul:text-muted-foreground"
|
class="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground prose-ul:text-muted-foreground"
|
||||||
>
|
>
|
||||||
{@html data.article.content}
|
{@html marked.parse(data.article.content ?? "")}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export const GET = async () => {
|
|||||||
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
|
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
|
||||||
paramValues: {
|
paramValues: {
|
||||||
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
||||||
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug).filter((s): s is string => s !== null),
|
"/models/[slug]": (await getModels(fetch))
|
||||||
|
.map((a) => a.slug)
|
||||||
|
.filter((s): s is string => s !== null),
|
||||||
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
|
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
|
||||||
},
|
},
|
||||||
defaultChangefreq: "always",
|
defaultChangefreq: "always",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<Meta
|
<Meta
|
||||||
title={displayName}
|
title={displayName}
|
||||||
description={data.user.description || `${displayName}'s profile`}
|
description={data.user.description || `${displayName}'s profile`}
|
||||||
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") ?? undefined : undefined}
|
image={data.user.avatar ? (getAssetUrl(data.user.avatar, "thumbnail") ?? undefined) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||||
@@ -91,8 +91,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if data.user.description}
|
{#if data.user.description}
|
||||||
<p class="text-muted-foreground mb-4">
|
<p class="text-muted-foreground mb-4">
|
||||||
{data.user.description}
|
{data.user.description}
|
||||||
@@ -183,7 +181,7 @@
|
|||||||
{$_("gamification.achievements")} ({data.gamification.achievements.length})
|
{$_("gamification.achievements")} ({data.gamification.achievements.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
{#each (data.gamification?.achievements ?? []) as achievement (achievement.id)}
|
{#each data.gamification?.achievements ?? [] as achievement (achievement.id)}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
|
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
|
||||||
title={achievement.description}
|
title={achievement.description}
|
||||||
@@ -194,7 +192,9 @@
|
|||||||
</span>
|
</span>
|
||||||
{#if achievement.date_unlocked}
|
{#if achievement.date_unlocked}
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
{new Date(achievement.date_unlocked).toLocaleDateString($locale ?? undefined)}
|
{new Date(achievement.date_unlocked).toLocaleDateString(
|
||||||
|
$locale ?? undefined,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -166,6 +166,9 @@ importers:
|
|||||||
javascript-time-ago:
|
javascript-time-ago:
|
||||||
specifier: ^2.6.4
|
specifier: ^2.6.4
|
||||||
version: 2.6.4
|
version: 2.6.4
|
||||||
|
marked:
|
||||||
|
specifier: ^17.0.4
|
||||||
|
version: 17.0.4
|
||||||
media-chrome:
|
media-chrome:
|
||||||
specifier: ^4.18.0
|
specifier: ^4.18.0
|
||||||
version: 4.18.0(react@19.2.0)
|
version: 4.18.0(react@19.2.0)
|
||||||
@@ -2663,6 +2666,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
marked@17.0.4:
|
||||||
|
resolution: {integrity: sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
mdn-data@2.0.28:
|
mdn-data@2.0.28:
|
||||||
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
||||||
|
|
||||||
@@ -5557,6 +5565,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@17.0.4: {}
|
||||||
|
|
||||||
mdn-data@2.0.28: {}
|
mdn-data@2.0.28: {}
|
||||||
|
|
||||||
mdn-data@2.12.2: {}
|
mdn-data@2.12.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user