Compare commits

..

7 Commits

Author SHA1 Message Date
b842106e44 fix: match pagination button size to admin filter buttons (default size)
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:07:44 +01:00
9abcd715d7 feat: add subtitles to /play/buttplug and /play/recordings page headers
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:04:24 +01:00
ab0af9a773 feat: extract Pagination component and use it on all paginated pages
All checks were successful
Build and Push Frontend Image / build (push) Successful in 1m13s
- New lib/components/pagination/pagination.svelte with numbered pages,
  ellipsis for large ranges, and prev/next buttons
- All 6 admin pages (users, articles, videos, recordings, comments,
  queues) now show enumerated page numbers next to the "Showing X–Y of Z"
  label; offset is derived from page number * limit
- Public pages (videos, models, magazine) replace their inline
  totalPages/pageNumbers derived state with the shared component
- Removes ~80 lines of duplicated pagination logic across 9 files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:01:13 +01:00
fbd2efa994 feat: server-side pagination and filtering for admin queues page
- Move queue, status, and offset to URL search params (?queue=&status=&offset=)
- Load jobs server-side in +page.server.ts with auth token (matches other admin pages)
- Derive total from adminQueues counts (waiting+active+completed+failed+delayed)
  so pagination knows total without an extra query
- Add fetchFn/token params to getAdminQueueJobs for server-side use
- Retry/remove/pause/resume actions now use invalidateAll() instead of local state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:49:50 +01:00
79932157bf fix: revoke points when a comment is deleted
All checks were successful
Build and Push Backend Image / build (push) Successful in 43s
- revokePoints now accepts optional recordingId; when absent it deletes
  one matching row (for actions like COMMENT_CREATE that have no recording)
- deleteComment queues revokePoints + checkAchievements so leaderboard
  and social achievements stay in sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:16:18 +01:00
04b0ec1a71 fix: revoke gamification points on recording delete + fix comment collection
- deleteRecording now queues revokePoints for RECORDING_CREATE (and
  RECORDING_FEATURED if applicable) before deleting a published recording,
  so leaderboard points are correctly removed
- Fix comment stat/achievement queries using collection "recordings" instead
  of "videos" — comments are stored under collection "videos", so the count
  was always 0, breaking COMMENT_CREATE stats and social achievements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:13:01 +01:00
cc693d8be7 fix: prevent achievement points from being re-awarded on republish
All checks were successful
Build and Push Backend Image / build (push) Successful in 1m2s
Once an achievement is unlocked, preserve date_unlocked permanently
instead of clearing it to null when the user drops below the threshold
(e.g. on unpublish). This prevents the wasUnlocked check from returning
false on republish, which was causing achievement points to be re-awarded
on every publish/unpublish cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:04:20 +01:00
19 changed files with 342 additions and 393 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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