Compare commits
7 Commits
52aa00dd13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b842106e44 | |||
| 9abcd715d7 | |||
| ab0af9a773 | |||
| fbd2efa994 | |||
| 79932157bf | |||
| 04b0ec1a71 | |||
| cc693d8be7 |
@@ -98,6 +98,18 @@ builder.mutationField("deleteComment", (t) =>
|
||||
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));
|
||||
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: comment[0].user_id,
|
||||
action: "COMMENT_CREATE",
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: comment[0].user_id,
|
||||
category: "social",
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -251,6 +251,28 @@ builder.mutationField("deleteRecording", (t) =>
|
||||
if (!existing[0]) throw new GraphQLError("Recording not found");
|
||||
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
||||
|
||||
if (existing[0].status === "published") {
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: args.id,
|
||||
});
|
||||
if (existing[0].featured) {
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_FEATURED",
|
||||
recordingId: args.id,
|
||||
});
|
||||
}
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "content",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
|
||||
import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
|
||||
import type { DB } from "../db/connection";
|
||||
import {
|
||||
user_points,
|
||||
@@ -45,17 +45,33 @@ export async function revokePoints(
|
||||
db: DB,
|
||||
userId: string,
|
||||
action: keyof typeof POINT_VALUES,
|
||||
recordingId: string,
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.delete(user_points)
|
||||
.where(
|
||||
and(
|
||||
eq(user_points.user_id, userId),
|
||||
eq(user_points.action, action),
|
||||
eq(user_points.recording_id, recordingId),
|
||||
),
|
||||
);
|
||||
const recordingCondition = recordingId
|
||||
? eq(user_points.recording_id, recordingId)
|
||||
: isNull(user_points.recording_id);
|
||||
|
||||
// When no recordingId (e.g. COMMENT_CREATE), delete only one row so each
|
||||
// revoke undoes exactly one prior award.
|
||||
if (!recordingId) {
|
||||
const row = await db
|
||||
.select({ id: user_points.id })
|
||||
.from(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
)
|
||||
.limit(1);
|
||||
if (row[0]) {
|
||||
await db.delete(user_points).where(eq(user_points.id, row[0].id));
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.delete(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
);
|
||||
}
|
||||
|
||||
await updateUserStats(db, userId);
|
||||
}
|
||||
|
||||
@@ -116,7 +132,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
const commentsResult = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
|
||||
const commentsCount = commentsResult[0]?.count || 0;
|
||||
|
||||
const achievementsResult = await db
|
||||
@@ -195,7 +211,9 @@ export async function checkAchievements(db: DB, userId: string, category?: strin
|
||||
.update(user_achievements)
|
||||
.set({
|
||||
progress,
|
||||
date_unlocked: isUnlocked ? existing[0].date_unlocked || new Date() : null,
|
||||
date_unlocked: isUnlocked
|
||||
? (existing[0].date_unlocked ?? new Date())
|
||||
: existing[0].date_unlocked,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
@@ -277,7 +295,7 @@ async function getAchievementProgress(
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const log = logger.child({ component: "gamification-worker" });
|
||||
|
||||
export type GamificationJobData =
|
||||
| { job: "awardPoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
|
||||
| { job: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId: string }
|
||||
| { job: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
|
||||
| { job: "checkAchievements"; userId: string; category?: string };
|
||||
|
||||
export function startGamificationWorker(): Worker {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
let { currentPage, totalPages, onPageChange }: Props = $props();
|
||||
|
||||
const pageNumbers = $derived(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push(-1);
|
||||
for (
|
||||
let i = Math.max(2, currentPage - 1);
|
||||
i <= Math.min(totalPages - 1, currentPage + 1);
|
||||
i++
|
||||
)
|
||||
pages.push(i);
|
||||
if (currentPage < totalPages - 2) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={currentPage <= 1}
|
||||
onclick={() => onPageChange(currentPage - 1)}
|
||||
>
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
{#each pageNumbers() as p, i (i)}
|
||||
{#if p === -1}
|
||||
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === currentPage ? "default" : "outline"}
|
||||
class="min-w-9"
|
||||
onclick={() => onPageChange(p)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={currentPage >= totalPages}
|
||||
onclick={() => onPageChange(currentPage + 1)}
|
||||
>
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -823,7 +823,7 @@ export default {
|
||||
},
|
||||
play: {
|
||||
title: "Play",
|
||||
description: "Bring your toys.",
|
||||
description: "Connect and control your Bluetooth toys.",
|
||||
scan: "Start Scan",
|
||||
scanning: "Scanning...",
|
||||
no_results: "No devices found",
|
||||
|
||||
@@ -1981,12 +1981,17 @@ export async function getAdminQueueJobs(
|
||||
status?: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
fetchFn?: typeof globalThis.fetch,
|
||||
token?: string,
|
||||
): Promise<Job[]> {
|
||||
return loggedApiCall("getAdminQueueJobs", async () => {
|
||||
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>(
|
||||
ADMIN_QUEUE_JOBS_QUERY,
|
||||
{ queue, status, limit, offset },
|
||||
);
|
||||
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
|
||||
const data = await client.request<{ adminQueueJobs: Job[] }>(ADMIN_QUEUE_JOBS_QUERY, {
|
||||
queue,
|
||||
status,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
return data.adminQueueJobs;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { Article } from "$lib/types";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -204,7 +205,7 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -214,32 +215,15 @@
|
||||
},
|
||||
})}
|
||||
</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>
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const timeAgo = new TimeAgo("en");
|
||||
@@ -153,7 +154,7 @@
|
||||
</div>
|
||||
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -163,28 +164,15 @@
|
||||
},
|
||||
})}
|
||||
</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>
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import { getAdminQueues } from "$lib/services";
|
||||
import { getAdminQueues, getAdminQueueJobs } from "$lib/services";
|
||||
|
||||
export async function load({ fetch, cookies }) {
|
||||
const LIMIT = 25;
|
||||
|
||||
export async function load({ fetch, cookies, url }) {
|
||||
const token = cookies.get("session_token") || "";
|
||||
const queues = await getAdminQueues(fetch, token).catch(() => []);
|
||||
return { queues };
|
||||
|
||||
const queueParam = url.searchParams.get("queue") ?? queues[0]?.name ?? null;
|
||||
const status = url.searchParams.get("status") ?? null;
|
||||
const offset = parseInt(url.searchParams.get("offset") ?? "0") || 0;
|
||||
|
||||
let jobs: Awaited<ReturnType<typeof getAdminQueueJobs>> = [];
|
||||
let total = 0;
|
||||
|
||||
if (queueParam) {
|
||||
jobs = await getAdminQueueJobs(
|
||||
queueParam,
|
||||
status ?? undefined,
|
||||
LIMIT,
|
||||
offset,
|
||||
fetch,
|
||||
token,
|
||||
).catch(() => []);
|
||||
|
||||
const queueInfo = queues.find((q) => q.name === queueParam);
|
||||
if (queueInfo) {
|
||||
const { waiting, active, completed, failed, delayed } = queueInfo.counts;
|
||||
const counts: Record<string, number> = { waiting, active, completed, failed, delayed };
|
||||
total = status ? (counts[status] ?? 0) : Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return { queues, queue: queueParam, status, jobs, total, offset, limit: LIMIT };
|
||||
}
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
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 {
|
||||
getAdminQueueJobs,
|
||||
adminRetryJob,
|
||||
adminRemoveJob,
|
||||
adminPauseQueue,
|
||||
adminResumeQueue,
|
||||
} from "$lib/services";
|
||||
import { adminRetryJob, adminRemoveJob, adminPauseQueue, adminResumeQueue } from "$lib/services";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import type { Job } from "$lib/services";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const queues = $derived(data.queues);
|
||||
|
||||
// null means "user hasn't picked yet" — fall back to first queue
|
||||
let selectedQueueOverride = $state<string | null>(null);
|
||||
const selectedQueue = $derived(selectedQueueOverride ?? queues[0]?.name ?? null);
|
||||
let selectedStatus = $state<string | null>(null);
|
||||
let jobs = $state<Job[]>([]);
|
||||
let loadingJobs = $state(false);
|
||||
let togglingQueue = $state<string | null>(null);
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
@@ -35,33 +24,28 @@
|
||||
{ value: "delayed", label: $_("admin.queues.status_delayed") },
|
||||
];
|
||||
|
||||
async function loadJobs() {
|
||||
if (!selectedQueue) return;
|
||||
loadingJobs = true;
|
||||
try {
|
||||
jobs = await getAdminQueueJobs(selectedQueue, selectedStatus ?? undefined, 50, 0);
|
||||
} finally {
|
||||
loadingJobs = false;
|
||||
function navigate(overrides: Record<string, string | null>) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v === null) params.delete(k);
|
||||
else params.set(k, v);
|
||||
}
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
async function selectQueue(name: string) {
|
||||
selectedQueueOverride = name;
|
||||
selectedStatus = null;
|
||||
await loadJobs();
|
||||
function selectQueue(name: string) {
|
||||
navigate({ queue: name, status: null, offset: null });
|
||||
}
|
||||
|
||||
async function selectStatus(status: string | null) {
|
||||
selectedStatus = status;
|
||||
await loadJobs();
|
||||
function selectStatus(status: string | null) {
|
||||
navigate({ status, offset: null });
|
||||
}
|
||||
|
||||
async function retryJob(job: Job) {
|
||||
try {
|
||||
await adminRetryJob(job.queue, job.id);
|
||||
toast.success($_("admin.queues.retry_success"));
|
||||
await loadJobs();
|
||||
await refreshCounts();
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error($_("admin.queues.retry_error"));
|
||||
}
|
||||
@@ -71,8 +55,7 @@
|
||||
try {
|
||||
await adminRemoveJob(job.queue, job.id);
|
||||
toast.success($_("admin.queues.remove_success"));
|
||||
jobs = jobs.filter((j) => j.id !== job.id);
|
||||
await refreshCounts();
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error($_("admin.queues.remove_error"));
|
||||
}
|
||||
@@ -88,7 +71,7 @@
|
||||
await adminPauseQueue(queueName);
|
||||
toast.success($_("admin.queues.pause_success"));
|
||||
}
|
||||
await refreshCounts();
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
|
||||
} finally {
|
||||
@@ -96,14 +79,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCounts() {
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selectedQueue) loadJobs();
|
||||
});
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
@@ -130,12 +105,17 @@
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
|
||||
{#if data.queue && data.total > 0}
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.total", { values: { total: data.total } })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Queue cards -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
{#each queues as queue (queue.name)}
|
||||
{@const isSelected = selectedQueue === queue.name}
|
||||
{#each data.queues as queue (queue.name)}
|
||||
{@const isSelected = data.queue === queue.name}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -194,12 +174,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedQueue}
|
||||
{#if data.queue}
|
||||
<!-- Status filter tabs -->
|
||||
<div class="flex gap-1 mb-4 flex-wrap">
|
||||
{#each STATUS_FILTERS as f (f.value ?? "all")}
|
||||
<Button
|
||||
variant={selectedStatus === f.value ? "default" : "outline"}
|
||||
variant={data.status === f.value ? "default" : "outline"}
|
||||
onclick={() => selectStatus(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
@@ -233,70 +213,81 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/30">
|
||||
{#if loadingJobs}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||
>{$_("common.loading")}</td
|
||||
{#each data.jobs as job (job.id)}
|
||||
<tr class="hover:bg-muted/10 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div>
|
||||
<p class="font-medium">{job.name}</p>
|
||||
{#if job.failedReason}
|
||||
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
|
||||
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
|
||||
>{job.attemptsMade}</td
|
||||
>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each jobs as job (job.id)}
|
||||
<tr class="hover:bg-muted/10 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div>
|
||||
<p class="font-medium">{job.name}</p>
|
||||
{#if job.failedReason}
|
||||
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
|
||||
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
|
||||
>{job.attemptsMade}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
|
||||
>{formatDate(job.createdAt)}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
{#if job.status === "failed"}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={$_("admin.queues.retry")}
|
||||
onclick={() => retryJob(job)}
|
||||
>
|
||||
<span class="icon-[ri--restart-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
{/if}
|
||||
<td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
|
||||
>{formatDate(job.createdAt)}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
{#if job.status === "failed"}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={$_("admin.queues.remove")}
|
||||
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onclick={() => removeJob(job)}
|
||||
aria-label={$_("admin.queues.retry")}
|
||||
onclick={() => retryJob(job)}
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||
<span class="icon-[ri--restart-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if jobs.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||
>{$_("admin.queues.no_jobs")}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={$_("admin.queues.remove")}
|
||||
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onclick={() => removeJob(job)}
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if data.jobs.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||
>{$_("admin.queues.no_jobs")}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<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>
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => navigate({ offset: String((p - 1) * data.limit) })}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { Recording } from "$lib/types";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const timeAgo = new TimeAgo("en");
|
||||
@@ -179,7 +180,7 @@
|
||||
</div>
|
||||
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -189,28 +190,15 @@
|
||||
},
|
||||
})}
|
||||
</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>
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { User } from "$lib/types";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -228,7 +229,7 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -238,32 +239,15 @@
|
||||
},
|
||||
})}
|
||||
</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>
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { Video } from "$lib/types";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -209,7 +210,7 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -219,32 +220,15 @@
|
||||
},
|
||||
})}
|
||||
</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>
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import SexyBackground from "$lib/components/background/background.svelte";
|
||||
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
const { data } = $props();
|
||||
@@ -50,22 +51,6 @@
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||
|
||||
const pageNumbers = $derived(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (data.page > 3) pages.push(-1);
|
||||
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||
pages.push(i);
|
||||
if (data.page < totalPages - 2) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
||||
@@ -308,38 +293,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
{#if Math.ceil(data.total / data.limit) > 1}
|
||||
<div class="flex flex-col items-center gap-3 mt-10">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => goToPage(data.page - 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
|
||||
>
|
||||
{#each pageNumbers() as p, i (i)}
|
||||
{#if p === -1}
|
||||
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === data.page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => goToPage(p)}
|
||||
class={p === data.page
|
||||
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= totalPages}
|
||||
onclick={() => goToPage(data.page + 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
|
||||
>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</p>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import SexyBackground from "$lib/components/background/background.svelte";
|
||||
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -43,22 +44,6 @@
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||
|
||||
const pageNumbers = $derived(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (data.page > 3) pages.push(-1);
|
||||
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||
pages.push(i);
|
||||
if (data.page < totalPages - 2) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("models.title")} description={$_("models.description")} />
|
||||
@@ -196,38 +181,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
{#if Math.ceil(data.total / data.limit) > 1}
|
||||
<div class="flex flex-col items-center gap-3 mt-10">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => goToPage(data.page - 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
|
||||
>
|
||||
{#each pageNumbers() as p, i (i)}
|
||||
{#if p === -1}
|
||||
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === data.page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => goToPage(p)}
|
||||
class={p === data.page
|
||||
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= totalPages}
|
||||
onclick={() => goToPage(data.page + 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
|
||||
>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</p>
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("play.title")}</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_("play.description")}</p>
|
||||
</div>
|
||||
|
||||
<!-- Recording controls (only when devices are connected) -->
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_("me.recordings.description")}</p>
|
||||
</div>
|
||||
|
||||
{#if recordings.length === 0}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import SexyBackground from "$lib/components/background/background.svelte";
|
||||
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import { formatVideoDuration } from "$lib/utils";
|
||||
|
||||
@@ -46,22 +47,6 @@
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||
|
||||
const pageNumbers = $derived(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (data.page > 3) pages.push(-1);
|
||||
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||
pages.push(i);
|
||||
if (data.page < totalPages - 2) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("videos.title")} description={$_("videos.description")} />
|
||||
@@ -256,38 +241,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
{#if Math.ceil(data.total / data.limit) > 1}
|
||||
<div class="flex flex-col items-center gap-3 mt-10">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => goToPage(data.page - 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
|
||||
>
|
||||
{#each pageNumbers() as p, i (i)}
|
||||
{#if p === -1}
|
||||
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === data.page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => goToPage(p)}
|
||||
class={p === data.page
|
||||
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= totalPages}
|
||||
onclick={() => goToPage(data.page + 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
|
||||
>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user