From 754a236e51db34df6b0aa367ab9cfab0aae3160f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 7 Mar 2026 11:29:48 +0100 Subject: [PATCH] feat: add admin tables for comments and recordings Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/src/graphql/context.ts | 6 +- .../backend/src/graphql/resolvers/articles.ts | 6 +- .../backend/src/graphql/resolvers/comments.ts | 55 +++- .../src/graphql/resolvers/recordings.ts | 56 ++++- packages/backend/src/graphql/types/index.ts | 18 ++ packages/frontend/src/lib/i18n/locales/en.ts | 30 +++ packages/frontend/src/lib/services.ts | 86 +++++++ .../frontend/src/routes/admin/+layout.svelte | 6 + .../src/routes/admin/comments/+page.server.ts | 15 ++ .../src/routes/admin/comments/+page.svelte | 204 +++++++++++++++ .../routes/admin/recordings/+page.server.ts | 15 ++ .../src/routes/admin/recordings/+page.svelte | 235 ++++++++++++++++++ 12 files changed, 720 insertions(+), 12 deletions(-) create mode 100644 packages/frontend/src/routes/admin/comments/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/comments/+page.svelte create mode 100644 packages/frontend/src/routes/admin/recordings/+page.server.ts create mode 100644 packages/frontend/src/routes/admin/recordings/+page.svelte diff --git a/packages/backend/src/graphql/context.ts b/packages/backend/src/graphql/context.ts index 4e1edf3..5501345 100644 --- a/packages/backend/src/graphql/context.ts +++ b/packages/backend/src/graphql/context.ts @@ -33,7 +33,11 @@ export async function buildContext(ctx: YogaInitialContext & ServerContext): Pro const session = await getSession(token); // also slides TTL if (session) { const dbInstance = ctx.db || db; - const [dbUser] = await dbInstance.select().from(users).where(eq(users.id, session.id)).limit(1); + const [dbUser] = await dbInstance + .select() + .from(users) + .where(eq(users.id, session.id)) + .limit(1); if (dbUser) { currentUser = { id: dbUser.id, diff --git a/packages/backend/src/graphql/resolvers/articles.ts b/packages/backend/src/graphql/resolvers/articles.ts index 6105b11..1d6cbab 100644 --- a/packages/backend/src/graphql/resolvers/articles.ts +++ b/packages/backend/src/graphql/resolvers/articles.ts @@ -105,11 +105,7 @@ builder.queryField("adminGetArticle", (t) => }, resolve: async (_root, args, ctx) => { requireAdmin(ctx); - const article = await ctx.db - .select() - .from(articles) - .where(eq(articles.id, args.id)) - .limit(1); + const article = await ctx.db.select().from(articles).where(eq(articles.id, args.id)).limit(1); if (!article[0]) return null; return enrichArticle(ctx.db, article[0]); }, diff --git a/packages/backend/src/graphql/resolvers/comments.ts b/packages/backend/src/graphql/resolvers/comments.ts index ade2c26..0445bec 100644 --- a/packages/backend/src/graphql/resolvers/comments.ts +++ b/packages/backend/src/graphql/resolvers/comments.ts @@ -1,10 +1,10 @@ import { GraphQLError } from "graphql"; import { builder } from "../builder"; -import { CommentType } from "../types/index"; +import { CommentType, AdminCommentListType } from "../types/index"; import { comments, users } from "../../db/schema/index"; -import { eq, and, desc } from "drizzle-orm"; +import { eq, and, desc, ilike, or, count } from "drizzle-orm"; import { awardPoints, checkAchievements } from "../../lib/gamification"; -import { requireOwnerOrAdmin } from "../../lib/acl"; +import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl"; builder.queryField("commentsForVideo", (t) => t.field({ @@ -96,3 +96,52 @@ builder.mutationField("deleteComment", (t) => }, }), ); + +builder.queryField("adminListComments", (t) => + t.field({ + type: AdminCommentListType, + args: { + search: t.arg.string(), + limit: t.arg.int(), + offset: t.arg.int(), + }, + resolve: async (_root, args, ctx) => { + requireAdmin(ctx); + const limit = args.limit ?? 50; + const offset = args.offset ?? 0; + + const conditions = args.search ? [ilike(comments.comment, `%${args.search}%`)] : []; + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [commentList, totalRows] = await Promise.all([ + ctx.db + .select() + .from(comments) + .where(where) + .orderBy(desc(comments.date_created)) + .limit(limit) + .offset(offset), + ctx.db.select({ total: count() }).from(comments).where(where), + ]); + + const items = await Promise.all( + commentList.map(async (c: any) => { + const user = await ctx.db + .select({ + id: users.id, + first_name: users.first_name, + last_name: users.last_name, + artist_name: users.artist_name, + avatar: users.avatar, + }) + .from(users) + .where(eq(users.id, c.user_id)) + .limit(1); + return { ...c, user: user[0] || null }; + }), + ); + + return { items, total: totalRows[0]?.total ?? 0 }; + }, + }), +); diff --git a/packages/backend/src/graphql/resolvers/recordings.ts b/packages/backend/src/graphql/resolvers/recordings.ts index 8015f75..d51c09e 100644 --- a/packages/backend/src/graphql/resolvers/recordings.ts +++ b/packages/backend/src/graphql/resolvers/recordings.ts @@ -1,10 +1,11 @@ import { GraphQLError } from "graphql"; import { builder } from "../builder"; -import { RecordingType } from "../types/index"; -import { recordings, recording_plays } from "../../db/schema/index"; -import { eq, and, desc, ne } from "drizzle-orm"; +import { RecordingType, AdminRecordingListType } from "../types/index"; +import { recordings, recording_plays, users } from "../../db/schema/index"; +import { eq, and, desc, ne, ilike, count } from "drizzle-orm"; import { slugify } from "../../lib/slugify"; import { awardPoints, checkAchievements } from "../../lib/gamification"; +import { requireAdmin } from "../../lib/acl"; builder.queryField("recordings", (t) => t.field({ @@ -340,3 +341,52 @@ builder.mutationField("updateRecordingPlay", (t) => }, }), ); + +builder.queryField("adminListRecordings", (t) => + t.field({ + type: AdminRecordingListType, + args: { + search: t.arg.string(), + status: t.arg.string(), + limit: t.arg.int(), + offset: t.arg.int(), + }, + resolve: async (_root, args, ctx) => { + requireAdmin(ctx); + const limit = args.limit ?? 50; + const offset = args.offset ?? 0; + + const conditions: any[] = []; + if (args.search) conditions.push(ilike(recordings.title, `%${args.search}%`)); + if (args.status) conditions.push(eq(recordings.status, args.status as any)); + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [rows, totalRows] = await Promise.all([ + ctx.db + .select() + .from(recordings) + .where(where) + .orderBy(desc(recordings.date_created)) + .limit(limit) + .offset(offset), + ctx.db.select({ total: count() }).from(recordings).where(where), + ]); + + return { items: rows, total: totalRows[0]?.total ?? 0 }; + }, + }), +); + +builder.mutationField("adminDeleteRecording", (t) => + t.field({ + type: "Boolean", + args: { + id: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + requireAdmin(ctx); + await ctx.db.delete(recordings).where(eq(recordings.id, args.id)); + return true; + }, + }), +); diff --git a/packages/backend/src/graphql/types/index.ts b/packages/backend/src/graphql/types/index.ts index 4ec566b..2eafc19 100644 --- a/packages/backend/src/graphql/types/index.ts +++ b/packages/backend/src/graphql/types/index.ts @@ -374,6 +374,24 @@ export const AdminArticleListType = builder }), }); +export const AdminCommentListType = builder + .objectRef<{ items: Comment[]; total: number }>("AdminCommentList") + .implement({ + fields: (t) => ({ + items: t.expose("items", { type: [CommentType] }), + total: t.exposeInt("total"), + }), + }); + +export const AdminRecordingListType = builder + .objectRef<{ items: Recording[]; total: number }>("AdminRecordingList") + .implement({ + fields: (t) => ({ + items: t.expose("items", { type: [RecordingType] }), + total: t.exposeInt("total"), + }), + }); + export const AdminUserListType = builder .objectRef<{ items: User[]; total: number }>("AdminUserList") .implement({ diff --git a/packages/frontend/src/lib/i18n/locales/en.ts b/packages/frontend/src/lib/i18n/locales/en.ts index 58694db..a638b4b 100644 --- a/packages/frontend/src/lib/i18n/locales/en.ts +++ b/packages/frontend/src/lib/i18n/locales/en.ts @@ -912,6 +912,8 @@ export default { users: "Users", videos: "Videos", articles: "Articles", + comments: "Comments", + recordings: "Recordings", }, common: { save_changes: "Save changes", @@ -1024,6 +1026,34 @@ export default { delete_success: "Article deleted", delete_error: "Failed to delete article", }, + comments: { + title: "Comments", + search_placeholder: "Search comments…", + col_user: "User", + col_comment: "Comment", + col_on: "On", + col_date: "Date", + no_results: "No comments found", + delete_title: "Delete comment", + delete_success: "Comment deleted", + delete_error: "Failed to delete comment", + }, + recordings: { + title: "Recordings", + search_placeholder: "Search recordings…", + col_title: "Title", + col_status: "Status", + col_duration: "Duration", + col_date: "Date", + no_results: "No recordings found", + published: "Published", + draft: "Draft", + public: "Public", + delete_title: "Delete recording", + delete_description: 'Permanently delete "{title}"? This cannot be undone.', + delete_success: "Recording deleted", + delete_error: "Failed to delete recording", + }, article_form: { new_title: "New article", edit_title: "Edit article", diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 94afa2f..1eebf6d 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -3,6 +3,7 @@ import { apiUrl, getGraphQLClient } from "$lib/api"; import type { Analytics, Article, + Comment, CurrentUser, Model, Recording, @@ -1776,3 +1777,88 @@ export async function getAnalytics(fetchFn?: typeof globalThis.fetch) { {}, ); } + +// ─── Admin: Comments ────────────────────────────────────────────────────────── + +const ADMIN_LIST_COMMENTS_QUERY = gql` + query AdminListComments($search: String, $limit: Int, $offset: Int) { + adminListComments(search: $search, limit: $limit, offset: $offset) { + items { + id + collection + item_id + comment + user_id + date_created + user { + id + artist_name + avatar + } + } + total + } + } +`; + +export async function adminListComments( + opts: { search?: string; limit?: number; offset?: number } = {}, + fetchFn?: typeof globalThis.fetch, + token?: string, +): Promise<{ items: Comment[]; total: number }> { + return loggedApiCall("adminListComments", async () => { + const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn); + const data = await client.request<{ adminListComments: { items: Comment[]; total: number } }>( + ADMIN_LIST_COMMENTS_QUERY, + opts, + ); + return data.adminListComments; + }); +} + +// ─── Admin: Recordings ──────────────────────────────────────────────────────── + +const ADMIN_LIST_RECORDINGS_QUERY = gql` + query AdminListRecordings($search: String, $status: String, $limit: Int, $offset: Int) { + adminListRecordings(search: $search, status: $status, limit: $limit, offset: $offset) { + items { + id + title + slug + status + duration + public + featured + user_id + date_created + } + total + } + } +`; + +export async function adminListRecordings( + opts: { search?: string; status?: string; limit?: number; offset?: number } = {}, + fetchFn?: typeof globalThis.fetch, + token?: string, +): Promise<{ items: Recording[]; total: number }> { + return loggedApiCall("adminListRecordings", async () => { + const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn); + const data = await client.request<{ + adminListRecordings: { items: Recording[]; total: number }; + }>(ADMIN_LIST_RECORDINGS_QUERY, opts); + return data.adminListRecordings; + }); +} + +const ADMIN_DELETE_RECORDING_MUTATION = gql` + mutation AdminDeleteRecording($id: String!) { + adminDeleteRecording(id: $id) + } +`; + +export async function adminDeleteRecording(id: string): Promise { + return loggedApiCall("adminDeleteRecording", async () => { + await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id }); + }); +} diff --git a/packages/frontend/src/routes/admin/+layout.svelte b/packages/frontend/src/routes/admin/+layout.svelte index ae1e628..b44f45e 100644 --- a/packages/frontend/src/routes/admin/+layout.svelte +++ b/packages/frontend/src/routes/admin/+layout.svelte @@ -8,6 +8,12 @@ { name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" }, { name: $_("admin.nav.videos"), href: "/admin/videos", icon: "icon-[ri--film-line]" }, { name: $_("admin.nav.articles"), href: "/admin/articles", icon: "icon-[ri--article-line]" }, + { name: $_("admin.nav.comments"), href: "/admin/comments", icon: "icon-[ri--message-line]" }, + { + name: $_("admin.nav.recordings"), + href: "/admin/recordings", + icon: "icon-[ri--record-circle-line]", + }, ]); function isActive(href: string) { diff --git a/packages/frontend/src/routes/admin/comments/+page.server.ts b/packages/frontend/src/routes/admin/comments/+page.server.ts new file mode 100644 index 0000000..17c1139 --- /dev/null +++ b/packages/frontend/src/routes/admin/comments/+page.server.ts @@ -0,0 +1,15 @@ +import { adminListComments } from "$lib/services"; + +export async function load({ fetch, url, cookies }) { + const token = cookies.get("session_token") || ""; + const search = url.searchParams.get("search") || undefined; + const offset = parseInt(url.searchParams.get("offset") || "0", 10); + const limit = 50; + + const result = await adminListComments({ search, limit, offset }, fetch, token).catch(() => ({ + items: [], + total: 0, + })); + + return { ...result, search, offset, limit }; +} diff --git a/packages/frontend/src/routes/admin/comments/+page.svelte b/packages/frontend/src/routes/admin/comments/+page.svelte new file mode 100644 index 0000000..6db2d93 --- /dev/null +++ b/packages/frontend/src/routes/admin/comments/+page.svelte @@ -0,0 +1,204 @@ + + +
+
+

{$_("admin.comments.title")}

+ {$_("admin.users.total", { values: { total: data.total } })} +
+ +
+ { + searchValue = (e.target as HTMLInputElement).value; + debounceSearch(searchValue); + }} + /> +
+ +
+ + + + + + + + + + + + {#each data.items as comment (comment.id)} + + + + + + + + {/each} + + {#if data.items.length === 0} + + + + {/if} + +
{$_("admin.comments.col_user")}{$_("admin.comments.col_comment")}{$_("admin.users.col_actions")}
+
+ {#if comment.user?.avatar} + + {:else} +
+ +
+ {/if} + {comment.user?.artist_name ?? "—"} +
+
+

{comment.comment}

+
+ +
+ {$_("admin.comments.no_results")} +
+
+ + {#if data.total > data.limit} +
+ + {$_("admin.users.showing", { + values: { + start: data.offset + 1, + end: Math.min(data.offset + data.limit, data.total), + total: data.total, + }, + })} + +
+ + +
+
+ {/if} +
+ + + + + {$_("admin.comments.delete_title")} + + "{deleteTarget?.comment.slice(0, 80)}{(deleteTarget?.comment.length ?? 0) > 80 ? "…" : ""}" + + + + + + + + diff --git a/packages/frontend/src/routes/admin/recordings/+page.server.ts b/packages/frontend/src/routes/admin/recordings/+page.server.ts new file mode 100644 index 0000000..8e489b2 --- /dev/null +++ b/packages/frontend/src/routes/admin/recordings/+page.server.ts @@ -0,0 +1,15 @@ +import { adminListRecordings } from "$lib/services"; + +export async function load({ fetch, url, cookies }) { + const token = cookies.get("session_token") || ""; + const search = url.searchParams.get("search") || undefined; + const status = url.searchParams.get("status") || undefined; + const offset = parseInt(url.searchParams.get("offset") || "0", 10); + const limit = 50; + + const result = await adminListRecordings({ search, status, limit, offset }, fetch, token).catch( + () => ({ items: [], total: 0 }), + ); + + return { ...result, search, status, offset, limit }; +} diff --git a/packages/frontend/src/routes/admin/recordings/+page.svelte b/packages/frontend/src/routes/admin/recordings/+page.svelte new file mode 100644 index 0000000..d24ccb4 --- /dev/null +++ b/packages/frontend/src/routes/admin/recordings/+page.svelte @@ -0,0 +1,235 @@ + + +
+
+

{$_("admin.recordings.title")}

+ {$_("admin.users.total", { values: { total: data.total } })} +
+ +
+ { + searchValue = (e.target as HTMLInputElement).value; + debounceSearch(searchValue); + }} + /> +
+ + + +
+
+ +
+ + + + + + + + + + + + {#each data.items as recording (recording.id)} + + + + + + + + {/each} + + {#if data.items.length === 0} + + + + {/if} + +
{$_("admin.recordings.col_title")}{$_("admin.users.col_actions")}
+

{recording.title}

+

{recording.slug}

+
+ +
+ {$_("admin.recordings.no_results")} +
+
+ + {#if data.total > data.limit} +
+ + {$_("admin.users.showing", { + values: { + start: data.offset + 1, + end: Math.min(data.offset + data.limit, data.total), + total: data.total, + }, + })} + +
+ + +
+
+ {/if} +
+ + + + + {$_("admin.recordings.delete_title")} + + {$_("admin.recordings.delete_description", { values: { title: deleteTarget?.title } })} + + + + + + + +