feat: implement pagination and query filter for commanders list
Some checks failed
Build and Publish Docker Images / Build and Push Docker Images (push) Failing after 2m15s

This commit is contained in:
2026-04-13 09:18:17 +02:00
parent f5b0caf194
commit e541b10f3f
14 changed files with 239 additions and 122 deletions

View File

@@ -242,6 +242,22 @@ export class CommanderRepository extends Repository {
}
}
/**
* Count commanders for a user filtered by name query
*/
async countCommandersByUserIdAndQuery(userId, query) {
try {
const searchQuery = `%${query}%`
const result = await dbManager.query(
`SELECT COUNT(*) as count FROM ${this.tableName} WHERE user_id = $1 AND name ILIKE $2`,
[userId, searchQuery]
)
return parseInt(result.rows[0].count, 10) || 0
} catch (error) {
throw new Error('Failed to count commanders by query')
}
}
/**
* Find commander by name and user
*/

View File

@@ -148,14 +148,19 @@ export default async function commanderRoutes(fastify, options) {
commanders = await commanderRepo.getCommandersByUserId(userId, limit, offset, sortBy, sortOrder)
}
reply.send({
commanders: commanders.map(transformCommander),
pagination: {
total: commanders.length,
limit,
offset
}
})
const totalCount = q
? await commanderRepo.countCommandersByUserIdAndQuery(userId, q)
: await commanderRepo.countCommandersByUserId(userId)
reply.send({
commanders: commanders.map(transformCommander),
pagination: {
total: totalCount,
limit,
offset,
hasMore: offset + commanders.length < totalCount
}
})
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({

View File

@@ -0,0 +1,83 @@
<script>
export let commanders = [];
export let archived = false;
export let getColorIcons;
export let formatDate;
export let onEdit = () => {};
export let onDelete = () => {};
</script>
{#if commanders.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each commanders as commander}
<div
class="card hover:shadow-lg transition-shadow {archived ? 'opacity-80' : ''}"
>
<div class="flex items-start justify-between mb-4">
<div>
<div class="flex items-center gap-2">
<h3 class="text-xl font-bold text-gray-900">{commander.name}</h3>
{#if archived}
<span
class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700"
>
Archived
</span>
{/if}
</div>
</div>
<div class="flex gap-2">
<button
on:click={() => onEdit(commander)}
class="text-indigo-600 hover:text-indigo-800 text-xl font-medium opacity-100"
>
Edit
</button>
<button
on:click={() => onDelete(commander)}
class="text-red-600 hover:text-red-800 text-xl font-medium opacity-100"
>
Delete
</button>
</div>
</div>
<div class="color-pill">
{#each getColorIcons(commander.colors) as icon}
<img
src={icon.src}
alt={`${icon.id} color icon`}
class="color-icon"
loading="lazy"
/>
{/each}
</div>
<div class="grid grid-cols-2 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{commander.totalGames || 0}
</div>
<div class="text-sm text-gray-600 mt-1">Games Played</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{Number(commander.winRate || 0).toFixed(1)}%
</div>
<div class="text-sm text-gray-600 mt-1">Win Rate</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{Number(commander.avgRounds || 0).toFixed(1)}
</div>
<div class="text-sm text-gray-600 mt-1">Avg Rounds</div>
</div>
<div class="text-center">
<div class="text-sm text-gray-500 mt-2">Added</div>
<div class="text-sm text-gray-700">{formatDate(commander.createdAt)}</div>
</div>
</div>
</div>
{/each}
</div>
{/if}

View File

@@ -16,7 +16,7 @@
});
</script>
<footer class="bg-white border-t border-gray-200 mt-12">
<footer class="bg-white border-t border-gray-200 mt-12 w-full">
<div class="container mx-auto px-4 py-6 text-center text-sm text-gray-600">
<p>EDH Stats • Track your Commander games</p>
{#if version}

View File

@@ -4,14 +4,19 @@
import NavBar from "$components/NavBar.svelte";
import ProtectedRoute from "$components/ProtectedRoute.svelte";
import Footer from "$components/Footer.svelte";
import CommanderListSection from "$components/CommanderListSection.svelte";
let showAddForm = false;
let commanders = [];
let loading = false;
let loadingMore = false;
let submitting = false;
let serverError = "";
let editingCommander = null;
let formElement;
let limit = 20;
let offset = 0;
let hasMore = false;
let newCommander = {
name: "",
@@ -20,7 +25,8 @@
};
$: formData = editingCommander || newCommander;
$: hasArchivedCommanders = commanders.some((cmd) => cmd.archived);
$: activeCommanders = commanders.filter((cmd) => !cmd.archived);
$: archivedCommanders = commanders.filter((cmd) => cmd.archived);
const mtgColors = [
{ id: "W", name: "White", hex: "#F0E6D2" },
@@ -43,16 +49,30 @@
await loadCommanders();
});
async function loadCommanders() {
loading = true;
async function loadCommanders({ append = false } = {}) {
if (append) {
if (loadingMore || !hasMore) return;
loadingMore = true;
} else {
loading = true;
offset = 0;
hasMore = false;
}
try {
// Load all commanders (not just ones with stats)
const response = await authenticatedFetch("/api/commanders");
// Load commanders with pagination
const queryOffset = append ? offset : 0;
const params = new URLSearchParams({
limit: limit.toString(),
offset: queryOffset.toString(),
});
const response = await authenticatedFetch(
`/api/commanders?${params.toString()}`,
);
if (response.ok) {
const data = await response.json();
const commandersList = data.commanders || [];
commanders = commandersList.map((cmd) => ({
const mappedCommanders = commandersList.map((cmd) => ({
...cmd,
commanderId: cmd.id,
totalGames: cmd.totalGames || 0,
@@ -61,12 +81,31 @@
wins: cmd.totalWins || 0,
archived: cmd.archived ?? false,
}));
if (append) {
const existingIds = new Set(
commanders.map((cmd) => cmd.id || cmd.commanderId),
);
const deduped = mappedCommanders.filter(
(cmd) => !existingIds.has(cmd.id || cmd.commanderId),
);
commanders = [...commanders, ...deduped];
} else {
commanders = mappedCommanders;
}
hasMore = data.pagination?.hasMore ?? false;
offset = queryOffset + commandersList.length;
}
} catch (error) {
console.error("Load commanders error:", error);
serverError = "Failed to load commanders";
} finally {
loading = false;
if (append) {
loadingMore = false;
} else {
loading = false;
}
}
}
@@ -274,6 +313,10 @@
deleteConfirm.deleting = false;
}
}
async function loadMoreCommanders() {
await loadCommanders({ append: true });
}
</script>
<svelte:head>
@@ -282,10 +325,10 @@
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8">
<main class="container mx-auto px-4 py-8 flex-1">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Commanders</h1>
<button
@@ -306,15 +349,6 @@
</button>
</div>
{#if hasArchivedCommanders}
<div
class="mb-6 rounded-md border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800"
>
Archived commanders remain in your history but will not appear when
logging new games.
</div>
{/if}
<!-- Add Commander Form -->
{#if showAddForm}
<div class="card mb-8">
@@ -439,90 +473,63 @@
</button>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each commanders as commander}
<div
class="card hover:shadow-lg transition-shadow {commander.archived
? 'opacity-80'
: ''}"
>
<!-- Header with name and actions -->
<div class="flex items-start justify-between mb-4">
<div>
<div class="flex items-center gap-2">
<h3 class="text-xl font-bold text-gray-900">
{commander.name}
</h3>
{#if commander.archived}
<span
class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700"
>
Archived
</span>
{/if}
</div>
</div>
<div class="flex gap-2">
<button
on:click={() => startEdit(commander)}
class="text-indigo-600 hover:text-indigo-800 text-xl font-medium opacity-100"
>
Edit
</button>
<button
on:click={() =>
showDeleteConfirm(
commander.id || commander.commanderId,
commander.name,
)}
class="text-red-600 hover:text-red-800 text-xl font-medium opacity-100"
>
Delete
</button>
</div>
</div>
<div class="space-y-12">
{#if activeCommanders.length > 0}
<CommanderListSection
commanders={activeCommanders}
{getColorIcons}
{formatDate}
onEdit={startEdit}
onDelete={(commander) =>
showDeleteConfirm(
commander.id || commander.commanderId,
commander.name,
)}
/>
{/if}
<!-- Color badges -->
<div class="color-pill">
{#each getColorIcons(commander.colors) as icon}
<img
src={icon.src}
alt={`${icon.id} color icon`}
class="color-icon"
loading="lazy"
/>
{/each}
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{commander.totalGames || 0}
</div>
<div class="text-sm text-gray-600 mt-1">Games Played</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{Number(commander.winRate || 0).toFixed(1)}%
</div>
<div class="text-sm text-gray-600 mt-1">Win Rate</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{Number(commander.avgRounds || 0).toFixed(1)}
</div>
<div class="text-sm text-gray-600 mt-1">Avg Rounds</div>
</div>
<div class="text-center">
<div class="text-sm text-gray-500 mt-2">Added</div>
<div class="text-sm text-gray-700">
{formatDate(commander.createdAt)}
</div>
</div>
{#if archivedCommanders.length > 0}
<div class="space-y-4">
<div
class="rounded-md border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800"
>
Archived commanders remain in your history but will not appear
when logging new games.
</div>
<CommanderListSection
commanders={archivedCommanders}
archived={true}
{getColorIcons}
{formatDate}
onEdit={startEdit}
onDelete={(commander) =>
showDeleteConfirm(
commander.id || commander.commanderId,
commander.name,
)}
/>
</div>
{/each}
{/if}
{#if hasMore}
<div class="flex justify-center pt-6">
<button
on:click={loadMoreCommanders}
class="btn btn-secondary"
disabled={loadingMore}
>
{#if loadingMore}
<div class="loading-spinner w-5 h-5"></div>
{:else}
Load More
{/if}
</button>
</div>
{:else if hasMore && commanders.length > 0}
<p class="text-center text-gray-500 pt-6">
You have reached the end of the line.
</p>
{/if}
</div>
{/if}
@@ -577,7 +584,7 @@
</ProtectedRoute>
<style>
.color-icon {
:global(.color-icon) {
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
@@ -590,7 +597,7 @@
box-shadow: none;
}
.color-pill {
:global(.color-pill) {
display: inline-flex;
gap: 0.35rem;
align-items: center;

View File

@@ -197,10 +197,10 @@
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8">
<main class="container mx-auto px-4 py-8 flex-1">
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="loading-spinner w-12 h-12"></div>

View File

@@ -381,10 +381,10 @@
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8">
<main class="container mx-auto px-4 py-8 flex-1">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Game Log</h1>
<button
@@ -410,7 +410,11 @@
</h2>
{#key editingGame?.id || "new"}
<form on:submit={handleLogGame} class="space-y-4" bind:this={logFormElement}>
<form
on:submit={handleLogGame}
class="space-y-4"
bind:this={logFormElement}
>
<!-- Date and Commander Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -445,7 +449,9 @@
<option value="">Select a commander</option>
{#each commanders as commander}
<option value={commander.id}>
{commander.name}{commander.archived ? " (Archived)" : ""}
{commander.name}{commander.archived
? " (Archived)"
: ""}
</option>
{/each}
</select>
@@ -671,7 +677,7 @@
{/if}
</button>
</div>
{:else if games.length > 0}
{:else if hasMore && games.length > 0}
<p class="text-center text-gray-500 pt-6">
You have reached the end of the line.
</p>

View File

@@ -194,10 +194,10 @@
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8 max-w-2xl">
<main class="container mx-auto px-4 py-8 max-w-2xl flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-6">Profile Settings</h1>
<!-- Global Success Messages -->

View File

@@ -168,10 +168,10 @@
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8">
<main class="container mx-auto px-4 py-8 flex-1">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold text-gray-900 mb-8 text-center">
Round Counter

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1 +1 @@
2.4.0
2.4.1