From c1770ab9c93713f8cd117efce79329d9ff053830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 6 Mar 2026 12:31:33 +0100 Subject: [PATCH] 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 --- .../backend/src/graphql/resolvers/articles.ts | 174 +++++-- .../backend/src/graphql/resolvers/comments.ts | 21 + .../backend/src/graphql/resolvers/users.ts | 98 +++- .../backend/src/graphql/resolvers/videos.ts | 162 +++++- packages/backend/src/graphql/types/index.ts | 9 + packages/backend/src/lib/acl.ts | 20 + packages/frontend/package.json | 1 + .../src/lib/components/header/header.svelte | 37 ++ packages/frontend/src/lib/services.ts | 464 ++++++++++++++++++ .../src/routes/admin/+layout.server.ts | 8 + .../frontend/src/routes/admin/+layout.svelte | 50 ++ .../frontend/src/routes/admin/+page.svelte | 8 + .../src/routes/admin/articles/+page.server.ts | 6 + .../src/routes/admin/articles/+page.svelte | 137 ++++++ .../admin/articles/[id]/+page.server.ts | 9 + .../routes/admin/articles/[id]/+page.svelte | 157 ++++++ .../routes/admin/articles/new/+page.server.ts | 3 + .../routes/admin/articles/new/+page.svelte | 164 +++++++ .../src/routes/admin/users/+page.server.ts | 15 + .../src/routes/admin/users/+page.svelte | 242 +++++++++ .../src/routes/admin/videos/+page.server.ts | 6 + .../src/routes/admin/videos/+page.svelte | 142 ++++++ .../routes/admin/videos/[id]/+page.server.ts | 14 + .../src/routes/admin/videos/[id]/+page.svelte | 191 +++++++ .../routes/admin/videos/new/+page.server.ts | 6 + .../src/routes/admin/videos/new/+page.svelte | 197 ++++++++ .../src/routes/magazine/[slug]/+page.svelte | 3 +- pnpm-lock.yaml | 10 + 28 files changed, 2311 insertions(+), 43 deletions(-) create mode 100644 packages/backend/src/lib/acl.ts create mode 100644 packages/frontend/src/routes/admin/+layout.server.ts create mode 100644 packages/frontend/src/routes/admin/+layout.svelte create mode 100644 packages/frontend/src/routes/admin/+page.svelte create mode 100644 packages/frontend/src/routes/admin/articles/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/articles/+page.svelte create mode 100644 packages/frontend/src/routes/admin/articles/[id]/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/articles/[id]/+page.svelte create mode 100644 packages/frontend/src/routes/admin/articles/new/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/articles/new/+page.svelte create mode 100644 packages/frontend/src/routes/admin/users/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/users/+page.svelte create mode 100644 packages/frontend/src/routes/admin/videos/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/videos/+page.svelte create mode 100644 packages/frontend/src/routes/admin/videos/[id]/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/videos/[id]/+page.svelte create mode 100644 packages/frontend/src/routes/admin/videos/new/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/videos/new/+page.svelte diff --git a/packages/backend/src/graphql/resolvers/articles.ts b/packages/backend/src/graphql/resolvers/articles.ts index 7a5dad5..4c7f5d2 100644 --- a/packages/backend/src/graphql/resolvers/articles.ts +++ b/packages/backend/src/graphql/resolvers/articles.ts @@ -1,7 +1,27 @@ +import { GraphQLError } from "graphql"; 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"; + +async function enrichArticle(db: any, article: any) { + let author = null; + if (article.author) { + const authorUser = await 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.author)) + .limit(1); + author = authorUser[0] || null; + } + return { ...article, author }; +} builder.queryField("articles", (t) => t.field({ @@ -11,10 +31,16 @@ builder.queryField("articles", (t) => 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(lte(articles.publish_date, new Date())) + .where(whereCondition) .orderBy(desc(articles.publish_date)); if (args.limit) { @@ -22,26 +48,7 @@ builder.queryField("articles", (t) => } const articleList = await query; - - return Promise.all( - articleList.map(async (article: any) => { - let author = null; - if (article.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.author)) - .limit(1); - author = authorUser[0] || null; - } - return { ...article, author }; - }), - ); + return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article))); }, }), ); @@ -61,23 +68,114 @@ 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 = { 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; }, }), ); diff --git a/packages/backend/src/graphql/resolvers/comments.ts b/packages/backend/src/graphql/resolvers/comments.ts index 805de0c..3015830 100644 --- a/packages/backend/src/graphql/resolvers/comments.ts +++ b/packages/backend/src/graphql/resolvers/comments.ts @@ -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,23 @@ 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; + }, + }), +); diff --git a/packages/backend/src/graphql/resolvers/users.ts b/packages/backend/src/graphql/resolvers/users.ts index 343f460..e8f6f33 100644 --- a/packages/backend/src/graphql/resolvers/users.ts +++ b/packages/backend/src/graphql/resolvers/users.ts @@ -1,8 +1,9 @@ import { GraphQLError } from "graphql"; import { builder } from "../builder"; -import { CurrentUserType, UserType } from "../types/index"; +import { CurrentUserType, UserType, AdminUserListType } from "../types/index"; import { users } from "../../db/schema/index"; -import { eq } from "drizzle-orm"; +import { eq, ilike, or, count, and } from "drizzle-orm"; +import { requireAuth, requireRole } from "../../lib/acl"; builder.queryField("me", (t) => t.field({ @@ -72,3 +73,96 @@ 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(), + }, + resolve: async (_root, args, ctx) => { + requireRole(ctx, "admin"); + + const updates: Record = { 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; + + 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; + }, + }), +); diff --git a/packages/backend/src/graphql/resolvers/videos.ts b/packages/backend/src/graphql/resolvers/videos.ts index f61ba98..fe474b0 100644 --- a/packages/backend/src/graphql/resolvers/videos.ts +++ b/packages/backend/src/graphql/resolvers/videos.ts @@ -15,6 +15,7 @@ import { files, } from "../../db/schema/index"; import { eq, and, lte, desc, inArray, count } from "drizzle-orm"; +import { requireRole, requireOwnerOrAdmin } 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,132 @@ 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 = {}; + 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; + }, + }), +); diff --git a/packages/backend/src/graphql/types/index.ts b/packages/backend/src/graphql/types/index.ts index 6ad7902..312e691 100644 --- a/packages/backend/src/graphql/types/index.ts +++ b/packages/backend/src/graphql/types/index.ts @@ -336,3 +336,12 @@ export const AchievementType = builder.objectRef("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"), + }), + }); diff --git a/packages/backend/src/lib/acl.ts b/packages/backend/src/lib/acl.ts new file mode 100644 index 0000000..ff3766e --- /dev/null +++ b/packages/backend/src/lib/acl.ts @@ -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"); + } +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2a20ee0..39add90 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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" } diff --git a/packages/frontend/src/lib/components/header/header.svelte b/packages/frontend/src/lib/components/header/header.svelte index eee93dc..e75b1ff 100644 --- a/packages/frontend/src/lib/components/header/header.svelte +++ b/packages/frontend/src/lib/components/header/header.svelte @@ -109,6 +109,22 @@ {$_("header.play")} + {#if authStatus.user?.role === "admin"} + + {/if} + + + {#if authStatus.user?.role === "admin"} + +
+ +
+
+ Admin + Manage content +
+ +
+ {/if} {:else} { + 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, +) { + return loggedApiCall( + "adminListUsers", + async () => { + const data = await getGraphQLClient(fetchFn).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 + ) { + adminUpdateUser( + userId: $userId + role: $role + firstName: $firstName + lastName: $lastName + artistName: $artistName + ) { + id + email + first_name + last_name + artist_name + role + avatar + date_created + } + } +`; + +export async function adminUpdateUser(input: { + userId: string; + role?: string; + firstName?: string; + lastName?: string; + artistName?: 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 }, + ); +} + +// ─── 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) { + return loggedApiCall("adminListVideos", async () => { + const data = await getGraphQLClient(fetchFn).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) { + return loggedApiCall("adminListArticles", async () => { + const data = await getGraphQLClient(fetchFn).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` diff --git a/packages/frontend/src/routes/admin/+layout.server.ts b/packages/frontend/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..89958b7 --- /dev/null +++ b/packages/frontend/src/routes/admin/+layout.server.ts @@ -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 }; +} diff --git a/packages/frontend/src/routes/admin/+layout.svelte b/packages/frontend/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..aaaf7a0 --- /dev/null +++ b/packages/frontend/src/routes/admin/+layout.svelte @@ -0,0 +1,50 @@ + + +
+ + + + +
+ {@render children()} +
+
diff --git a/packages/frontend/src/routes/admin/+page.svelte b/packages/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..709b106 --- /dev/null +++ b/packages/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,8 @@ + diff --git a/packages/frontend/src/routes/admin/articles/+page.server.ts b/packages/frontend/src/routes/admin/articles/+page.server.ts new file mode 100644 index 0000000..e3bf579 --- /dev/null +++ b/packages/frontend/src/routes/admin/articles/+page.server.ts @@ -0,0 +1,6 @@ +import { adminListArticles } from "$lib/services"; + +export async function load({ fetch }) { + const articles = await adminListArticles(fetch).catch(() => []); + return { articles }; +} diff --git a/packages/frontend/src/routes/admin/articles/+page.svelte b/packages/frontend/src/routes/admin/articles/+page.svelte new file mode 100644 index 0000000..55dd27c --- /dev/null +++ b/packages/frontend/src/routes/admin/articles/+page.svelte @@ -0,0 +1,137 @@ + + +
+
+

Articles

+ +
+ +
+ + + + + + + + + + + {#each data.articles as article (article.id)} + + + + + + + {/each} + + {#if data.articles.length === 0} + + + + {/if} + +
ArticleCategoryPublishedActions
+
+ {#if article.image} + + {:else} +
+ +
+ {/if} +
+

{article.title}

+ {#if article.featured} + Featured + {/if} +
+
+
{article.category ?? "—"} + {timeAgo.format(new Date(article.publish_date))} + +
+ + +
+
+ No articles yet +
+
+
+ + + + + Delete article + + Permanently delete {deleteTarget?.title}? This cannot be undone. + + + + + + + + diff --git a/packages/frontend/src/routes/admin/articles/[id]/+page.server.ts b/packages/frontend/src/routes/admin/articles/[id]/+page.server.ts new file mode 100644 index 0000000..c9de57b --- /dev/null +++ b/packages/frontend/src/routes/admin/articles/[id]/+page.server.ts @@ -0,0 +1,9 @@ +import { adminListArticles } from "$lib/services"; +import { error } from "@sveltejs/kit"; + +export async function load({ params, fetch }) { + const articles = await adminListArticles(fetch).catch(() => []); + const article = articles.find((a) => a.id === params.id); + if (!article) throw error(404, "Article not found"); + return { article }; +} diff --git a/packages/frontend/src/routes/admin/articles/[id]/+page.svelte b/packages/frontend/src/routes/admin/articles/[id]/+page.svelte new file mode 100644 index 0000000..95b573b --- /dev/null +++ b/packages/frontend/src/routes/admin/articles/[id]/+page.svelte @@ -0,0 +1,157 @@ + + +
+
+ +

Edit article

+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ +