Expand game query filters and update UI assets

- Backend: extend filters with dateFrom/dateTo, won, roundsMin/Max and
  colors; implement color identity using jsonb and order by date with
  limit/offset.
- Frontend: adjust slideshow spacing in index.html and include new
  images; bump version to 2.0.5.
This commit is contained in:
2026-01-18 14:07:44 +01:00
parent bb0fb42631
commit cc4ecd0ce1
9 changed files with 179 additions and 43 deletions

View File

@@ -97,16 +97,43 @@ export class GameRepository extends Repository {
paramCount++
}
if (filters.won !== undefined) {
query += ` AND g.won = $${paramCount}`
params.push(filters.won)
paramCount++
}
if (filters.won !== undefined) {
query += ` AND g.won = $${paramCount}`
params.push(filters.won)
paramCount++
}
query += ` ORDER BY g.date DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`
params.push(limit, offset)
if (filters.roundsMin !== undefined) {
query += ` AND g.rounds >= $${paramCount}`
params.push(filters.roundsMin)
paramCount++
}
return dbManager.all(query, params)
if (filters.roundsMax !== undefined) {
query += ` AND g.rounds <= $${paramCount}`
params.push(filters.roundsMax)
paramCount++
}
if (filters.colors && filters.colors.length > 0) {
// Filter by commander color identity - checks if colors array contains any of the specified colors
const colorConditions = filters.colors.map(() => {
const condition = `cmdr.colors @> $${paramCount}::jsonb`
paramCount++
return condition
})
query += ` AND (${colorConditions.join(' OR ')})`
filters.colors.forEach(color => {
params.push(JSON.stringify([color]))
})
paramCount -= filters.colors.length
paramCount += filters.colors.length
}
query += ` ORDER BY g.date DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`
params.push(limit, offset)
return dbManager.all(query, params)
}
/**
@@ -161,15 +188,48 @@ export class GameRepository extends Repository {
paramCount++
}
if (filters.dateTo) {
query += ` AND g.date <= $${paramCount}`
params.push(filters.dateTo)
paramCount++
}
if (filters.dateTo) {
query += ` AND g.date <= $${paramCount}`
params.push(filters.dateTo)
paramCount++
}
query += ` ORDER BY g.date DESC`
if (filters.won !== undefined) {
query += ` AND g.won = $${paramCount}`
params.push(filters.won)
paramCount++
}
return dbManager.all(query, params)
if (filters.roundsMin !== undefined) {
query += ` AND g.rounds >= $${paramCount}`
params.push(filters.roundsMin)
paramCount++
}
if (filters.roundsMax !== undefined) {
query += ` AND g.rounds <= $${paramCount}`
params.push(filters.roundsMax)
paramCount++
}
if (filters.colors && filters.colors.length > 0) {
// Filter by commander color identity
const colorConditions = filters.colors.map(() => {
const condition = `cmdr.colors @> $${paramCount}::jsonb`
paramCount++
return condition
})
query += ` AND (${colorConditions.join(' OR ')})`
filters.colors.forEach(color => {
params.push(JSON.stringify([color]))
})
paramCount -= filters.colors.length
paramCount += filters.colors.length
}
query += ` ORDER BY g.date DESC`
return dbManager.all(query, params)
}
/**

View File

@@ -100,25 +100,79 @@ const updateGameSchema = z.object({
})
const gameQuerySchema = z.object({
q: z
.string('Search query must be a string')
.min(1, 'Search query cannot be empty')
.max(50, 'Search query limited to 50 characters')
.optional(),
limit: z
.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 game per page')
.max(100, 'Maximum 100 games per page')
.default(50),
offset: z
.coerce
.number('Offset must be a number')
.int('Offset must be a whole number')
.min(0, 'Offset cannot be negative')
.default(0)
})
q: z
.string('Search query must be a string')
.min(1, 'Search query cannot be empty')
.max(50, 'Search query limited to 50 characters')
.optional(),
limit: z
.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 game per page')
.max(100, 'Maximum 100 games per page')
.default(50),
offset: z
.coerce
.number('Offset must be a number')
.int('Offset must be a whole number')
.min(0, 'Offset cannot be negative')
.default(0),
// Date range filters
dateFrom: z
.string('Date from must be a string')
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid date format (use YYYY-MM-DD)'
})
.optional(),
dateTo: z
.string('Date to must be a string')
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid date format (use YYYY-MM-DD)'
})
.optional(),
// Player count filter
playerCount: z
.coerce
.number('Player count must be a number')
.int('Player count must be a whole number')
.min(2, 'Minimum 2 players')
.max(8, 'Maximum 8 players')
.optional(),
// Commander ID filter
commanderId: z
.coerce
.number('Commander ID must be a number')
.int('Commander ID must be a whole number')
.positive('Commander ID must be positive')
.optional(),
// Win/Loss filter
won: z
.enum(['true', 'false'])
.transform(val => val === 'true')
.optional(),
// Rounds range filters
roundsMin: z
.coerce
.number('Min rounds must be a number')
.int('Min rounds must be a whole number')
.min(1, 'Minimum 1 round')
.optional(),
roundsMax: z
.coerce
.number('Max rounds must be a number')
.int('Max rounds must be a whole number')
.max(50, 'Maximum 50 rounds')
.optional(),
// Color filters (comma-separated: W,U,B,R,G)
colors: z
.string('Colors must be a string')
.transform(val => val.split(',').filter(c => c.trim()))
.refine(colors => colors.every(c => /^[WUBRG]$/.test(c)), {
message: 'Invalid color format (use W,U,B,R,G)'
})
.optional()
})
export default async function gameRoutes(fastify, options) {
// Initialize repositories

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -44,9 +44,11 @@
x-data="slideshow()"
>
<!-- Slides Container with Arrow Navigation -->
<div class="relative bg-gray-100 pt-12 pb-12 overflow-hidden">
<div class="relative bg-gray-100 pt-9 pb-9 overflow-hidden">
<!-- Image Container -->
<div class="relative aspect-video flex items-center justify-center">
<div
class="relative aspect-video flex items-center justify-center"
>
<template x-for="(slide, index) in slides" :key="index">
<img
:src="slide.src"
@@ -71,22 +73,42 @@
<!-- Left Arrow Button -->
<button
@click="previousSlide()"
class="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-full p-2 transition-colors"
class="absolute left-2 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-full p-2 transition-colors"
title="Previous slide"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<!-- Right Arrow Button -->
<button
@click="nextSlide()"
class="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-full p-2 transition-colors"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-full p-2 transition-colors"
title="Next slide"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>

View File

@@ -1 +1 @@
2.0.4
2.0.5