Compare commits

...

10 Commits

Author SHA1 Message Date
a7fafaf7c5 refactor: replace native select with shadcn Select for user role in admin
All checks were successful
Build and Push Backend Image / build (push) Successful in 1m10s
Build and Push Frontend Image / build (push) Successful in 5m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:34:39 +01:00
b71d7dc559 refactor: remove duplicate utils/utils.ts, consolidate into utils.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:31:35 +01:00
f764e27d59 fix: shrink flyout account card, remove online indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:29:33 +01:00
d7eb2acc6c fix: match mobile flyout header height to main header (h-16)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:27:31 +01:00
fb38d6b9a9 fix: constrain admin layout to container width matching rest of site
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:22:15 +01:00
d021acaf0b feat: add admin user edit page with avatar, banner, and photo gallery
- Backend: adminGetUser query returns user + photos; adminUpdateUser now
  accepts avatarId/bannerId; new adminAddUserPhoto and adminRemoveUserPhoto
  mutations; AdminUserDetailType added to GraphQL schema
- Frontend: /admin/users/[id] page for editing name, avatar, banner, and
  managing the model photo gallery (upload multiple, delete individually)
- Admin users list: edit button per row linking to the detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:18:43 +01:00
e06a1915f2 fix: remove backdrop-blur overlay causing blurry text site-wide
The full-screen glassmorphism overlay had backdrop-blur-[0.5px] which
triggered GPU compositing on the entire viewport, degrading subpixel
text rendering inconsistently. Also use globalThis.fetch (not SvelteKit
fetch) when forwarding session token in admin SSR calls to avoid header
stripping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:11:54 +01:00
ebab3405b1 fix: forward session token in admin SSR load functions
Admin list queries (users, videos, articles) were using getGraphQLClient
without auth credentials, causing silent 403s on server-side loads. Now
extract session_token cookie and pass it to getAuthClient so the backend
sees the admin session on SSR requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:56:47 +01:00
ad7ceee5f8 fix: resolve lint errors from ACL/admin implementation
- Remove unused requireOwnerOrAdmin import from videos.ts
- Remove unused requireAuth import from users.ts
- Remove unused GraphQLError import from articles.ts
- Replace URLSearchParams with SvelteURLSearchParams in admin users page
- Apply prettier formatting to all changed files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:35:11 +01:00
c1770ab9c9 feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query

Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
39 changed files with 2727 additions and 126 deletions

View File

@@ -76,7 +76,7 @@ Points + achievements system tracked in `user_points` and `user_stats` tables. L
## Environment Variables (Backend)
| Variable | Purpose |
|----------|---------|
| --------------------------- | ---------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string |
| `COOKIE_SECRET` | Session cookie signing |

View File

@@ -2,32 +2,12 @@ import { builder } from "../builder";
import { ArticleType } from "../types/index";
import { articles, users } from "../../db/schema/index";
import { eq, and, lte, desc } from "drizzle-orm";
import { requireRole } from "../../lib/acl";
builder.queryField("articles", (t) =>
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) => {
async function enrichArticle(db: any, article: any) {
let author = null;
if (article.author) {
const authorUser = await ctx.db
const authorUser = await db
.select({
first_name: users.first_name,
last_name: users.last_name,
@@ -40,8 +20,34 @@ builder.queryField("articles", (t) =>
author = authorUser[0] || null;
}
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);
if (!article[0]) return null;
let author = null;
if (article[0].author) {
const authorUser = await ctx.db
.select({
first_name: users.first_name,
last_name: users.last_name,
avatar: users.avatar,
description: users.description,
})
.from(users)
.where(eq(users.id, article[0].author))
.limit(1);
author = authorUser[0] || null;
}
return { ...article[0], author };
return enrichArticle(ctx.db, article[0]);
},
}),
);
// ─── Admin queries & mutations ────────────────────────────────────────────────
builder.queryField("adminListArticles", (t) =>
t.field({
type: [ArticleType],
resolve: async (_root, _args, ctx) => {
requireRole(ctx, "admin");
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
},
}),
);
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;
},
}),
);

View File

@@ -4,6 +4,7 @@ import { CommentType } from "../types/index";
import { comments, users } from "../../db/schema/index";
import { eq, and, desc } from "drizzle-orm";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireOwnerOrAdmin } from "../../lib/acl";
builder.queryField("commentsForVideo", (t) =>
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;
},
}),
);

View File

@@ -1,8 +1,9 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CurrentUserType, UserType } from "../types/index";
import { users } from "../../db/schema/index";
import { eq } from "drizzle-orm";
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm";
import { requireRole } from "../../lib/acl";
builder.queryField("me", (t) =>
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;
},
}),
);

View File

@@ -15,6 +15,7 @@ import {
files,
} from "../../db/schema/index";
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
import { requireRole } from "../../lib/acl";
async function enrichVideo(db: any, video: any) {
// Fetch models
@@ -64,10 +65,13 @@ builder.queryField("videos", (t) =>
limit: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
// Unauthenticated users cannot see premium videos
const premiumFilter = !ctx.currentUser ? eq(videos.premium, false) : undefined;
let query = ctx.db
.select({ v: videos })
.from(videos)
.where(lte(videos.upload_date, new Date()))
.where(and(lte(videos.upload_date, new Date()), premiumFilter))
.orderBy(desc(videos.upload_date));
if (args.modelId) {
@@ -84,6 +88,7 @@ builder.queryField("videos", (t) =>
.where(
and(
lte(videos.upload_date, new Date()),
premiumFilter,
inArray(
videos.id,
videoIds.map((v: any) => v.video_id),
@@ -97,7 +102,13 @@ builder.queryField("videos", (t) =>
query = ctx.db
.select({ v: 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));
}
@@ -127,6 +138,11 @@ builder.queryField("video", (t) =>
.limit(1);
if (!video[0]) return null;
if (video[0].premium && !ctx.currentUser) {
throw new GraphQLError("Unauthorized");
}
return enrichVideo(ctx.db, video[0]);
},
}),
@@ -295,6 +311,19 @@ builder.mutationField("updateVideoPlay", (t) =>
completed: t.arg.boolean({ required: true }),
},
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
.update(video_plays)
.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;
},
}),
);

View File

@@ -24,6 +24,8 @@ import type {
UserGamification,
Achievement,
} from "@sexy.pivoine.art/types";
type AdminUserDetail = User & { photos: ModelPhoto[] };
import { builder } from "../builder";
export const FileType = builder.objectRef<MediaFile>("File").implement({
@@ -229,9 +231,7 @@ export const VideoPlayResponseType = builder
}),
});
export const VideoLikeStatusType = builder
.objectRef<VideoLikeStatus>("VideoLikeStatus")
.implement({
export const VideoLikeStatusType = builder.objectRef<VideoLikeStatus>("VideoLikeStatus").implement({
fields: (t) => ({
liked: t.exposeBoolean("liked"),
}),
@@ -336,3 +336,33 @@ export const AchievementType = builder.objectRef<Achievement>("Achievement").imp
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] }),
}),
});

View 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");
}
}

View File

@@ -26,7 +26,17 @@ function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogL
message = arg;
} else if (arg !== null && typeof arg === "object") {
// 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 : "");
if (reqId) meta.reqId = reqId;
Object.assign(meta, rest);

View File

@@ -47,6 +47,7 @@
"graphql": "^16.11.0",
"graphql-request": "^7.1.2",
"javascript-time-ago": "^2.6.4",
"marked": "^17.0.4",
"media-chrome": "^4.18.0",
"svelte-i18n": "^4.0.1"
}

View File

@@ -109,6 +109,22 @@
<span class="sr-only">{$_("header.play")}</span>
</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" />
<LogoutButton
@@ -159,7 +175,7 @@
aria-hidden={!isMobileMenuOpen}
>
<!-- 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} />
</div>
@@ -171,7 +187,7 @@
>
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"></div>
<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
src={getAssetUrl(authStatus.user!.avatar, "mini")}
alt={authStatus.user!.artist_name}
@@ -182,17 +198,13 @@
{getUserInitials(authStatus.user!.artist_name)}
</AvatarFallback>
</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">
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
</p>
<p class="text-xs text-muted-foreground truncate">
{authStatus.user!.email}
</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>
@@ -265,6 +277,27 @@
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</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}
<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"}`}

View File

@@ -90,7 +90,10 @@
{/each}
{#if (recording.device_info?.length ?? 0) > 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"
: ""}
</div>

View File

@@ -3,7 +3,7 @@
-->
<script lang="ts">
import { cn } from "$lib/utils/utils";
import { cn } from "$lib/utils";
import UploadIcon from "@lucide/svelte/icons/upload";
import { displaySize } from ".";
import { useId } from "bits-ui";

View File

@@ -3,7 +3,7 @@
-->
<script lang="ts">
import { cn } from "$lib/utils/utils";
import { cn } from "$lib/utils";
import type { TagsInputProps } from "./types";
import TagsInputTag from "./tags-input-tag.svelte";
import { untrack } from "svelte";

View File

@@ -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 ───────────────────────────────────────────────────────────────
const ANALYTICS_QUERY = gql`

View File

@@ -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;
};

View File

@@ -58,7 +58,7 @@
<!-- Glassmorphism overlay -->
<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>
<!-- Header -->

View 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 };
}

View 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>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { onMount } from "svelte";
onMount(() => {
goto("/admin/users", { replaceState: true });
});
</script>

View File

@@ -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 };
}

View 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>

View File

@@ -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 };
}

View 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>

View File

@@ -0,0 +1,3 @@
export async function load() {
return {};
}

View 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>

View 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 };
}

View 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>

View File

@@ -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 };
}

View 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>

View File

@@ -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 };
}

View 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>

View File

@@ -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 };
}

View 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>

View File

@@ -0,0 +1,6 @@
import { getModels } from "$lib/services";
export async function load({ fetch }) {
const models = await getModels(fetch).catch(() => []);
return { models };
}

View 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>

View File

@@ -9,6 +9,7 @@
import Meta from "$lib/components/meta/meta.svelte";
import PeonyBackground from "$lib/components/background/peony-background.svelte";
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
import { marked } from "marked";
const { data } = $props();
@@ -115,7 +116,7 @@
<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"
>
{@html data.article.content}
{@html marked.parse(data.article.content ?? "")}
</div>
</CardContent>
</Card>

View File

@@ -7,7 +7,9 @@ export const GET = async () => {
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
paramValues: {
"/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),
},
defaultChangefreq: "always",

View File

@@ -27,7 +27,7 @@
<Meta
title={displayName}
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">
@@ -91,8 +91,6 @@
>
</div>
{#if data.user.description}
<p class="text-muted-foreground mb-4">
{data.user.description}
@@ -183,7 +181,7 @@
{$_("gamification.achievements")} ({data.gamification.achievements.length})
</h3>
<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
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}
@@ -194,7 +192,9 @@
</span>
{#if achievement.date_unlocked}
<span class="text-xs text-muted-foreground">
{new Date(achievement.date_unlocked).toLocaleDateString($locale ?? undefined)}
{new Date(achievement.date_unlocked).toLocaleDateString(
$locale ?? undefined,
)}
</span>
{/if}
</div>

10
pnpm-lock.yaml generated
View File

@@ -166,6 +166,9 @@ importers:
javascript-time-ago:
specifier: ^2.6.4
version: 2.6.4
marked:
specifier: ^17.0.4
version: 17.0.4
media-chrome:
specifier: ^4.18.0
version: 4.18.0(react@19.2.0)
@@ -2663,6 +2666,11 @@ packages:
magic-string@0.30.21:
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:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
@@ -5557,6 +5565,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.4: {}
mdn-data@2.0.28: {}
mdn-data@2.12.2: {}