feat: add admin tables for comments and recordings
All checks were successful
Build and Push Backend Image / build (push) Successful in 44s
Build and Push Frontend Image / build (push) Successful in 4m20s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 11:29:48 +01:00
parent dfe49b5882
commit 754a236e51
12 changed files with 720 additions and 12 deletions

View File

@@ -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,

View File

@@ -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]);
},

View File

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

View File

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

View File

@@ -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({

View File

@@ -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",

View File

@@ -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<void> {
return loggedApiCall("adminDeleteRecording", async () => {
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
});
}

View File

@@ -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) {

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

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

View File

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

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