feat: add admin tables for comments and recordings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,11 @@ export async function buildContext(ctx: YogaInitialContext & ServerContext): Pro
|
|||||||
const session = await getSession(token); // also slides TTL
|
const session = await getSession(token); // also slides TTL
|
||||||
if (session) {
|
if (session) {
|
||||||
const dbInstance = ctx.db || db;
|
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) {
|
if (dbUser) {
|
||||||
currentUser = {
|
currentUser = {
|
||||||
id: dbUser.id,
|
id: dbUser.id,
|
||||||
|
|||||||
@@ -105,11 +105,7 @@ builder.queryField("adminGetArticle", (t) =>
|
|||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
requireAdmin(ctx);
|
requireAdmin(ctx);
|
||||||
const article = await ctx.db
|
const article = await ctx.db.select().from(articles).where(eq(articles.id, args.id)).limit(1);
|
||||||
.select()
|
|
||||||
.from(articles)
|
|
||||||
.where(eq(articles.id, args.id))
|
|
||||||
.limit(1);
|
|
||||||
if (!article[0]) return null;
|
if (!article[0]) return null;
|
||||||
return enrichArticle(ctx.db, article[0]);
|
return enrichArticle(ctx.db, article[0]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { GraphQLError } from "graphql";
|
import { GraphQLError } from "graphql";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { CommentType } from "../types/index";
|
import { CommentType, AdminCommentListType } from "../types/index";
|
||||||
import { comments, users } from "../../db/schema/index";
|
import { comments, users } from "../../db/schema/index";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc, ilike, or, count } from "drizzle-orm";
|
||||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
||||||
import { requireOwnerOrAdmin } from "../../lib/acl";
|
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
||||||
|
|
||||||
builder.queryField("commentsForVideo", (t) =>
|
builder.queryField("commentsForVideo", (t) =>
|
||||||
t.field({
|
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 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { GraphQLError } from "graphql";
|
import { GraphQLError } from "graphql";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { RecordingType } from "../types/index";
|
import { RecordingType, AdminRecordingListType } from "../types/index";
|
||||||
import { recordings, recording_plays } from "../../db/schema/index";
|
import { recordings, recording_plays, users } from "../../db/schema/index";
|
||||||
import { eq, and, desc, ne } from "drizzle-orm";
|
import { eq, and, desc, ne, ilike, count } from "drizzle-orm";
|
||||||
import { slugify } from "../../lib/slugify";
|
import { slugify } from "../../lib/slugify";
|
||||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
||||||
|
import { requireAdmin } from "../../lib/acl";
|
||||||
|
|
||||||
builder.queryField("recordings", (t) =>
|
builder.queryField("recordings", (t) =>
|
||||||
t.field({
|
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;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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
|
export const AdminUserListType = builder
|
||||||
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
||||||
.implement({
|
.implement({
|
||||||
|
|||||||
@@ -912,6 +912,8 @@ export default {
|
|||||||
users: "Users",
|
users: "Users",
|
||||||
videos: "Videos",
|
videos: "Videos",
|
||||||
articles: "Articles",
|
articles: "Articles",
|
||||||
|
comments: "Comments",
|
||||||
|
recordings: "Recordings",
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
save_changes: "Save changes",
|
save_changes: "Save changes",
|
||||||
@@ -1024,6 +1026,34 @@ export default {
|
|||||||
delete_success: "Article deleted",
|
delete_success: "Article deleted",
|
||||||
delete_error: "Failed to delete article",
|
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: {
|
article_form: {
|
||||||
new_title: "New article",
|
new_title: "New article",
|
||||||
edit_title: "Edit article",
|
edit_title: "Edit article",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiUrl, getGraphQLClient } from "$lib/api";
|
|||||||
import type {
|
import type {
|
||||||
Analytics,
|
Analytics,
|
||||||
Article,
|
Article,
|
||||||
|
Comment,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
Model,
|
Model,
|
||||||
Recording,
|
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<void> {
|
||||||
|
return loggedApiCall("adminDeleteRecording", async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
|
{ 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.videos"), href: "/admin/videos", icon: "icon-[ri--film-line]" },
|
||||||
{ name: $_("admin.nav.articles"), href: "/admin/articles", icon: "icon-[ri--article-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) {
|
function isActive(href: string) {
|
||||||
|
|||||||
15
packages/frontend/src/routes/admin/comments/+page.server.ts
Normal file
15
packages/frontend/src/routes/admin/comments/+page.server.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
204
packages/frontend/src/routes/admin/comments/+page.svelte
Normal file
204
packages/frontend/src/routes/admin/comments/+page.svelte
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<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 { _ } from "svelte-i18n";
|
||||||
|
import { deleteComment } from "$lib/services";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
const timeAgo = new TimeAgo("en");
|
||||||
|
|
||||||
|
let deleteTarget: { id: number; comment: string } | null = $state(null);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let searchValue = $state(data.search ?? "");
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
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 confirmDelete(id: number, comment: string) {
|
||||||
|
deleteTarget = { id, comment };
|
||||||
|
deleteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await deleteComment(deleteTarget.id);
|
||||||
|
toast.success($_("admin.comments.delete_success"));
|
||||||
|
deleteOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error($_("admin.comments.delete_error"));
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 sm:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
||||||
|
<Input
|
||||||
|
placeholder={$_("admin.comments.search_placeholder")}
|
||||||
|
class="max-w-xs"
|
||||||
|
value={searchValue}
|
||||||
|
oninput={(e) => {
|
||||||
|
searchValue = (e.target as HTMLInputElement).value;
|
||||||
|
debounceSearch(searchValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||||
|
<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"
|
||||||
|
>{$_("admin.comments.col_user")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.comments.col_comment")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
|
||||||
|
>{$_("admin.comments.col_on")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
|
||||||
|
>{$_("admin.comments.col_date")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.users.col_actions")}</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border/30">
|
||||||
|
{#each data.items as comment (comment.id)}
|
||||||
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if comment.user?.avatar}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(comment.user.avatar, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-7 w-7 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-7 w-7 rounded-full bg-muted/50 flex items-center justify-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--user-line] h-4 w-4"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium text-sm">{comment.user?.artist_name ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 max-w-sm">
|
||||||
|
<p class="truncate text-sm">{comment.comment}</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell capitalize text-sm">
|
||||||
|
{comment.collection} /
|
||||||
|
<span class="font-mono text-xs">{comment.item_id.slice(0, 8)}…</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell text-sm">
|
||||||
|
{timeAgo.format(new Date(comment.date_created))}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onclick={() => confirmDelete(comment.id, comment.comment)}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if data.items.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
{$_("admin.comments.no_results")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.total > data.limit}
|
||||||
|
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{$_("admin.users.showing", {
|
||||||
|
values: {
|
||||||
|
start: data.offset + 1,
|
||||||
|
end: Math.min(data.offset + data.limit, data.total),
|
||||||
|
total: 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()}`);
|
||||||
|
}}>{$_("common.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()}`);
|
||||||
|
}}>{$_("common.next")}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$_("admin.comments.delete_title")}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
"{deleteTarget?.comment.slice(0, 80)}{(deleteTarget?.comment.length ?? 0) > 80 ? "…" : ""}"
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = false)}>{$_("common.cancel")}</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||||
|
{deleting ? $_("admin.common.deleting") : $_("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
235
packages/frontend/src/routes/admin/recordings/+page.svelte
Normal file
235
packages/frontend/src/routes/admin/recordings/+page.svelte
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<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 { _ } from "svelte-i18n";
|
||||||
|
import { adminDeleteRecording } from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import type { Recording } from "$lib/types";
|
||||||
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
const timeAgo = new TimeAgo("en");
|
||||||
|
|
||||||
|
let deleteTarget: Recording | null = $state(null);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let searchValue = $state(data.search ?? "");
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
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 setFilter(key: string, value: string | null) {
|
||||||
|
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||||
|
if (value !== null) params.set(key, value);
|
||||||
|
else params.delete(key);
|
||||||
|
params.delete("offset");
|
||||||
|
goto(`?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await adminDeleteRecording(deleteTarget.id);
|
||||||
|
toast.success($_("admin.recordings.delete_success"));
|
||||||
|
deleteOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error($_("admin.recordings.delete_error"));
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 sm:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
||||||
|
<Input
|
||||||
|
placeholder={$_("admin.recordings.search_placeholder")}
|
||||||
|
class="max-w-xs"
|
||||||
|
value={searchValue}
|
||||||
|
oninput={(e) => {
|
||||||
|
searchValue = (e.target as HTMLInputElement).value;
|
||||||
|
debounceSearch(searchValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={data.status === undefined ? "default" : "outline"}
|
||||||
|
onclick={() => setFilter("status", null)}>{$_("admin.common.all")}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={data.status === "published" ? "default" : "outline"}
|
||||||
|
onclick={() => setFilter("status", "published")}>{$_("admin.recordings.published")}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={data.status === "draft" ? "default" : "outline"}
|
||||||
|
onclick={() => setFilter("status", "draft")}>{$_("admin.recordings.draft")}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||||
|
<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"
|
||||||
|
>{$_("admin.recordings.col_title")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell"
|
||||||
|
>{$_("admin.recordings.col_status")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
|
||||||
|
>{$_("admin.recordings.col_duration")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell"
|
||||||
|
>{$_("admin.recordings.col_date")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.users.col_actions")}</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border/30">
|
||||||
|
{#each data.items as recording (recording.id)}
|
||||||
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<p class="font-medium">{recording.title}</p>
|
||||||
|
<p class="text-xs text-muted-foreground font-mono">{recording.slug}</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Badge
|
||||||
|
variant={recording.status === "published" ? "default" : "outline"}
|
||||||
|
class={recording.status === "draft"
|
||||||
|
? "text-muted-foreground"
|
||||||
|
: recording.status === "archived"
|
||||||
|
? "text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
|
{recording.status}
|
||||||
|
</Badge>
|
||||||
|
{#if recording.public}
|
||||||
|
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
|
||||||
|
>{$_("admin.recordings.public")}</Badge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">
|
||||||
|
{formatDuration(recording.duration ?? 0)}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">
|
||||||
|
{timeAgo.format(new Date(recording.date_created))}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onclick={() => {
|
||||||
|
deleteTarget = recording;
|
||||||
|
deleteOpen = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if data.items.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
{$_("admin.recordings.no_results")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.total > data.limit}
|
||||||
|
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{$_("admin.users.showing", {
|
||||||
|
values: {
|
||||||
|
start: data.offset + 1,
|
||||||
|
end: Math.min(data.offset + data.limit, data.total),
|
||||||
|
total: 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()}`);
|
||||||
|
}}>{$_("common.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()}`);
|
||||||
|
}}>{$_("common.next")}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$_("admin.recordings.delete_title")}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{$_("admin.recordings.delete_description", { values: { title: deleteTarget?.title } })}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = false)}>{$_("common.cancel")}</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||||
|
{deleting ? $_("admin.common.deleting") : $_("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
Reference in New Issue
Block a user