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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
83
frontend/src/lib/components/CommanderListSection.svelte
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 106 KiB |
@@ -1 +1 @@
|
||||
2.4.0
|
||||
2.4.1
|
||||
|
||||