Compare commits

1 Commits

Author SHA1 Message Date
8171db0985 Add inactive flag to commanders and update queries
- Add inactive BOOLEAN column to commanders with default FALSE
  (idempotent)
- Filter inactive in stats, views, and related queries
- Return and persist inactive flag in Commander data
- Add API to toggle inactive and frontend toggle control
- Show INACTIVE badge and allow deactivation in UI
- Rename Top Commanders to Top Played Commanders
- Bump frontend version to 2.1.9
2026-02-13 14:27:50 +01:00
9 changed files with 509 additions and 321 deletions

View File

@@ -41,6 +41,17 @@ CREATE TABLE IF NOT EXISTS games (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Add inactive column for archiving commanders (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'commanders' AND column_name = 'inactive'
) THEN
ALTER TABLE commanders ADD COLUMN inactive BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
END $$;
-- Performance indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_commanders_user_id ON commanders(user_id);
CREATE INDEX IF NOT EXISTS idx_games_user_id ON games(user_id);
@@ -48,6 +59,7 @@ CREATE INDEX IF NOT EXISTS idx_games_commander_id ON games(commander_id);
CREATE INDEX IF NOT EXISTS idx_games_date ON games(date);
CREATE INDEX IF NOT EXISTS idx_games_user_commander ON games(user_id, commander_id);
CREATE INDEX IF NOT EXISTS idx_games_user_date ON games(user_id, date);
CREATE INDEX IF NOT EXISTS idx_commanders_user_inactive ON commanders(user_id, inactive);
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_timestamp()
@@ -83,18 +95,18 @@ CREATE VIEW user_stats AS
SELECT
u.id as user_id,
u.username,
(SELECT COUNT(DISTINCT id) FROM commanders WHERE user_id = u.id) as total_commanders,
(SELECT COUNT(*) FROM games WHERE user_id = u.id) as total_games,
(SELECT COUNT(*) FROM games WHERE user_id = u.id AND won = TRUE) as total_wins,
(SELECT COUNT(DISTINCT id) FROM commanders WHERE user_id = u.id AND inactive = FALSE) as total_commanders,
(SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as total_games,
(SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND g.won = TRUE AND c.inactive = FALSE) as total_wins,
ROUND(
CASE
WHEN (SELECT COUNT(*) FROM games WHERE user_id = u.id) > 0
THEN ((SELECT COUNT(*) FROM games WHERE user_id = u.id AND won = TRUE)::NUMERIC * 100.0 / (SELECT COUNT(*) FROM games WHERE user_id = u.id))
WHEN (SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) > 0
THEN ((SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND g.won = TRUE AND c.inactive = FALSE)::NUMERIC * 100.0 / (SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE))
ELSE 0
END, 2
) as win_rate,
(SELECT AVG(rounds) FROM games WHERE user_id = u.id) as avg_rounds,
(SELECT MAX(date) FROM games WHERE user_id = u.id) as last_game_date
(SELECT AVG(g.rounds) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as avg_rounds,
(SELECT MAX(g.date) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as last_game_date
FROM users u
GROUP BY u.id, u.username;
@@ -105,6 +117,7 @@ SELECT
c.name,
c.colors,
c.user_id,
c.inactive,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE) as total_wins,
ROUND(
@@ -118,4 +131,5 @@ SELECT
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND starting_player_won = TRUE) as starting_player_wins,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND sol_ring_turn_one_won = TRUE) as sol_ring_wins,
(SELECT MAX(date) FROM games WHERE commander_id = c.id) as last_played
FROM commanders c;
FROM commanders c
WHERE c.inactive = FALSE;

View File

@@ -16,7 +16,7 @@ export class CommanderRepository extends Repository {
`
INSERT INTO ${this.tableName} (name, colors, user_id)
VALUES ($1, $2, $3)
RETURNING id, name, colors, user_id, created_at, updated_at
RETURNING id, name, colors, user_id, inactive, created_at, updated_at
`,
[name, colors, userId]
)
@@ -63,6 +63,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.inactive,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -102,6 +103,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.inactive,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -133,6 +135,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.inactive,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -141,7 +144,7 @@ export class CommanderRepository extends Repository {
(SELECT ROUND(AVG(rounds)::NUMERIC, 2) FROM games WHERE commander_id = c.id) as avg_rounds,
(SELECT MAX(date) FROM games WHERE commander_id = c.id) as last_played
FROM ${this.tableName} c
WHERE c.user_id = $1 AND (SELECT COUNT(*) FROM games WHERE commander_id = c.id) >= 5
WHERE c.user_id = $1 AND c.inactive = FALSE AND (SELECT COUNT(*) FROM games WHERE commander_id = c.id) >= 5
ORDER BY win_rate DESC, c.name ASC
LIMIT $2
`
@@ -163,6 +166,7 @@ export class CommanderRepository extends Repository {
c.id,
c.name,
c.colors,
c.inactive,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE) as total_wins,
ROUND((SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE)::NUMERIC * 100.0 / NULLIF((SELECT COUNT(*) FROM games WHERE commander_id = c.id), 0), 2) as win_rate,
@@ -248,6 +252,24 @@ export class CommanderRepository extends Repository {
}
}
/**
* Toggle a commander's inactive status
*/
async toggleInactive(commanderId, userId, inactive) {
// Verify ownership
const existing = await this.findById(commanderId)
if (!existing || existing.user_id !== userId) {
throw new Error('Commander not found or access denied')
}
const result = await dbManager.query(
`UPDATE ${this.tableName} SET inactive = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3 RETURNING *`,
[inactive, commanderId, userId]
)
return result.rows[0]
}
/**
* Delete a commander
*/

View File

@@ -16,11 +16,13 @@ const createCommanderSchema = z.object({
.refine((val) => /^[a-zA-Z0-9\s,.\'-]+$/.test(val), {
message: 'Commander name contains invalid characters'
}),
colors: z
.array(
z.enum(['W', 'U', 'B', 'R', 'G'], {
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
errorMap: () => ({
message: 'Invalid color (must be W, U, B, R, or G)'
})
}),
{
errorMap: () => ({ message: 'Colors must be an array' })
@@ -43,11 +45,13 @@ const updateCommanderSchema = z.object({
message: 'Commander name contains invalid characters'
})
.optional(),
colors: z
.array(
z.enum(['W', 'U', 'B', 'R', 'G'], {
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
errorMap: () => ({
message: 'Invalid color (must be W, U, B, R, or G)'
})
})
)
.min(1, 'Select at least one color')
@@ -64,15 +68,13 @@ const commanderQuerySchema = z.object({
.min(1, 'Search query cannot be empty')
.max(50, 'Search query limited to 50 characters')
.optional(),
limit: z
.coerce
limit: z.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 commander per page')
.max(50, 'Maximum 50 commanders per page')
.default(20),
offset: z
.coerce
offset: z.coerce
.number('Offset must be a number')
.int('Offset must be a whole number')
.min(0, 'Offset cannot be negative')
@@ -81,15 +83,15 @@ const commanderQuerySchema = z.object({
.enum(['created_at', 'updated_at', 'name', 'total_games'])
.default('created_at')
.optional(),
sortOrder: z
.enum(['ASC', 'DESC'])
.default('DESC')
.optional()
sortOrder: z.enum(['ASC', 'DESC']).default('DESC').optional()
})
const toggleInactiveSchema = z.object({
inactive: z.boolean()
})
const popularCommandersQuerySchema = z.object({
limit: z
.coerce
limit: z.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 commander')
@@ -104,6 +106,7 @@ function transformCommander(cmd) {
name: cmd.name,
colors: cmd.colors || [],
userId: cmd.user_id,
inactive: cmd.inactive || false,
totalGames: parseInt(cmd.total_games) || 0,
totalWins: parseInt(cmd.total_wins) || 0,
winRate: cmd.win_rate ? parseFloat(cmd.win_rate) : 0,
@@ -137,15 +140,27 @@ export default async function commanderRoutes(fastify, options) {
},
async (request, reply) => {
try {
const { q, limit, offset, sortBy, sortOrder } = commanderQuerySchema.parse(request.query)
const userId = request.user.id
const { q, limit, offset, sortBy, sortOrder } =
commanderQuerySchema.parse(request.query)
const userId = request.user.id
let commanders
if (q) {
commanders = await commanderRepo.searchCommandersByName(userId, q, limit, offset)
} else {
commanders = await commanderRepo.getCommandersByUserId(userId, limit, offset, sortBy, sortOrder)
}
let commanders
if (q) {
commanders = await commanderRepo.searchCommandersByName(
userId,
q,
limit,
offset
)
} else {
commanders = await commanderRepo.getCommandersByUserId(
userId,
limit,
offset,
sortBy,
sortOrder
)
}
reply.send({
commanders: commanders.map(transformCommander),
@@ -191,36 +206,36 @@ export default async function commanderRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
try {
const { id } = request.params
const userId = request.user.id
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const commander = await commanderRepo.findById(commanderId)
const commander = await commanderRepo.findById(commanderId)
if (!commander || commander.user_id !== userId) {
reply.code(404).send({
error: 'Not Found',
message: 'Commander not found'
})
return
}
if (!commander || commander.user_id !== userId) {
reply.code(404).send({
error: 'Not Found',
message: 'Commander not found'
})
return
}
reply.send({
commander: {
...commander,
colors: commander.colors || []
}
})
reply.send({
commander: {
...commander,
colors: commander.colors || []
}
})
} catch (error) {
fastify.log.error('Get commander error:', error)
reply.code(500).send({
@@ -250,36 +265,41 @@ export default async function commanderRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const userId = request.user.id
// LAYER 1: Schema validation
const validatedData = createCommanderSchema.parse(request.body)
try {
const userId = request.user.id
// LAYER 2: Business logic validation
// Check if user has reached max commander limit (100)
const commanderCount = await commanderRepo.countCommandersByUserId(userId)
if (commanderCount >= 100) {
return reply.code(409).send({
error: 'Conflict',
message: 'Commander limit reached',
details: ['You have reached the maximum of 100 commanders. Delete some to add more.']
})
}
// LAYER 1: Schema validation
const validatedData = createCommanderSchema.parse(request.body)
// Check for duplicate commander name (case-insensitive)
const existing = await commanderRepo.findByNameAndUserId(
validatedData.name.toLowerCase(),
userId
)
if (existing) {
return reply.code(409).send({
error: 'Conflict',
message: 'Commander already exists',
details: [`You already have a commander named "${validatedData.name}"`]
})
}
// LAYER 2: Business logic validation
// Check if user has reached max commander limit (100)
const commanderCount =
await commanderRepo.countCommandersByUserId(userId)
if (commanderCount >= 100) {
return reply.code(409).send({
error: 'Conflict',
message: 'Commander limit reached',
details: [
'You have reached the maximum of 100 commanders. Delete some to add more.'
]
})
}
// Check for duplicate commander name (case-insensitive)
const existing = await commanderRepo.findByNameAndUserId(
validatedData.name.toLowerCase(),
userId
)
if (existing) {
return reply.code(409).send({
error: 'Conflict',
message: 'Commander already exists',
details: [
`You already have a commander named "${validatedData.name}"`
]
})
}
// Convert colors array to JSON string for storage
const colorsJson = JSON.stringify(validatedData.colors)
@@ -289,27 +309,27 @@ export default async function commanderRoutes(fastify, options) {
colorsJson
)
reply.code(201).send({
message: 'Commander created successfully',
commander: transformCommander(commander)
})
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = formatValidationErrors(error)
const firstError = formattedErrors[0]?.message || 'Invalid input data'
return reply.code(400).send({
error: 'Validation Error',
message: firstError,
details: formattedErrors
})
} else {
fastify.log.error('Create commander error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to create commander'
})
}
}
reply.code(201).send({
message: 'Commander created successfully',
commander: transformCommander(commander)
})
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = formatValidationErrors(error)
const firstError = formattedErrors[0]?.message || 'Invalid input data'
return reply.code(400).send({
error: 'Validation Error',
message: firstError,
details: formattedErrors
})
} else {
fastify.log.error('Create commander error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to create commander'
})
}
}
}
)
@@ -332,61 +352,133 @@ export default async function commanderRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
try {
const { id } = request.params
const userId = request.user.id
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const updateData = updateCommanderSchema.parse(request.body)
const updateData = updateCommanderSchema.parse(request.body)
// Convert colors array to JSON if provided
const updatePayload = { ...updateData }
if (updatePayload.colors) {
updatePayload.colors = JSON.stringify(updatePayload.colors)
}
// Convert colors array to JSON if provided
const updatePayload = { ...updateData }
if (updatePayload.colors) {
updatePayload.colors = JSON.stringify(updatePayload.colors)
}
const updated = await commanderRepo.updateCommander(commanderId, userId, updatePayload)
const updated = await commanderRepo.updateCommander(
commanderId,
userId,
updatePayload
)
if (!updated) {
reply.code(400).send({
error: 'Update Failed',
message: 'No valid fields to update or commander not found'
})
return
}
if (!updated) {
reply.code(400).send({
error: 'Update Failed',
message: 'No valid fields to update or commander not found'
})
return
}
const commander = await commanderRepo.findById(commanderId)
const commander = await commanderRepo.findById(commanderId)
reply.send({
message: 'Commander updated successfully',
commander: transformCommander(commander)
})
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = formatValidationErrors(error)
const firstError = formattedErrors[0]?.message || 'Invalid input data'
reply.code(400).send({
error: 'Validation Error',
message: firstError,
details: formattedErrors
})
} else {
fastify.log.error('Update commander error:', error.message || error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update commander'
})
}
}
reply.send({
message: 'Commander updated successfully',
commander: transformCommander(commander)
})
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = formatValidationErrors(error)
const firstError = formattedErrors[0]?.message || 'Invalid input data'
reply.code(400).send({
error: 'Validation Error',
message: firstError,
details: formattedErrors
})
} else {
fastify.log.error('Update commander error:', error.message || error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update commander'
})
}
}
}
)
// Toggle commander inactive status
fastify.patch(
'/:id/inactive',
{
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
preHandler: [
async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}
]
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const { inactive } = toggleInactiveSchema.parse(request.body)
const updated = await commanderRepo.toggleInactive(
commanderId,
userId,
inactive
)
if (!updated) {
return reply.code(404).send({
error: 'Not Found',
message: 'Commander not found'
})
}
reply.send({
message: `Commander ${inactive ? 'deactivated' : 'reactivated'} successfully`,
commander: transformCommander(updated)
})
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({
error: 'Validation Error',
message: 'Invalid request body',
details: formatValidationErrors(error)
})
} else {
fastify.log.error('Toggle inactive error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update commander status'
})
}
}
}
)
@@ -409,29 +501,29 @@ export default async function commanderRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
try {
const { id } = request.params
const userId = request.user.id
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const deleted = await commanderRepo.deleteCommander(commanderId, userId)
const deleted = await commanderRepo.deleteCommander(commanderId, userId)
if (!deleted) {
reply.code(404).send({
error: 'Not Found',
message: 'Commander not found'
})
return
}
if (!deleted) {
reply.code(404).send({
error: 'Not Found',
message: 'Commander not found'
})
return
}
reply.send({
message: 'Commander deleted successfully'
@@ -464,29 +556,29 @@ export default async function commanderRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
try {
const { id } = request.params
const userId = request.user.id
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const stats = await commanderRepo.getCommanderStats(commanderId, userId)
const stats = await commanderRepo.getCommanderStats(commanderId, userId)
reply.send({
stats: {
...stats,
win_rate: Math.round(stats.win_rate || 0),
avg_rounds: Math.round(stats.avg_rounds || 0)
}
})
reply.send({
stats: {
...stats,
win_rate: Math.round(stats.win_rate || 0),
avg_rounds: Math.round(stats.avg_rounds || 0)
}
})
} catch (error) {
fastify.log.error('Get commander stats error:', error)
reply.code(500).send({
@@ -515,18 +607,21 @@ export default async function commanderRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const { limit } = popularCommandersQuerySchema.parse(request.query)
const userId = request.user.id
const commanders = await commanderRepo.getPopularCommandersByUserId(userId, limit)
try {
const { limit } = popularCommandersQuerySchema.parse(request.query)
const userId = request.user.id
const commanders = await commanderRepo.getPopularCommandersByUserId(
userId,
limit
)
reply.send({
commanders: commanders.map(transformCommander),
pagination: {
total: commanders.length,
limit
}
})
reply.send({
commanders: commanders.map(transformCommander),
pagination: {
total: commanders.length,
limit
}
})
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({

View File

@@ -118,13 +118,14 @@ export default async function statsRoutes(fastify, options) {
const playerCountStats = await dbManager.all(
`
SELECT
player_count,
g.player_count,
COUNT(*) as total,
SUM(CASE WHEN won = TRUE THEN 1 ELSE 0 END) as wins
FROM games
WHERE user_id = $1
GROUP BY player_count
ORDER BY player_count
SUM(CASE WHEN g.won = TRUE THEN 1 ELSE 0 END) as wins
FROM games g
JOIN commanders c ON g.commander_id = c.id
WHERE g.user_id = $1 AND c.inactive = FALSE
GROUP BY g.player_count
ORDER BY g.player_count
`,
[userId]
)
@@ -139,7 +140,7 @@ export default async function statsRoutes(fastify, options) {
SUM(CASE WHEN g.won = TRUE THEN 1 ELSE 0 END) as wins
FROM games g
JOIN commanders c ON g.commander_id = c.id
WHERE g.user_id = $1
WHERE g.user_id = $1 AND c.inactive = FALSE
GROUP BY c.colors
`,
[userId]

View File

@@ -230,14 +230,17 @@
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<template x-for="commander in commanders" :key="commander.id">
<div class="card hover:shadow-lg transition-shadow">
<div class="card hover:shadow-lg transition-shadow" :class="{ 'opacity-60': commander.inactive }">
<!-- Commander Header -->
<div class="flex justify-between items-start mb-4">
<div>
<h3
class="text-lg font-semibold text-gray-900"
x-text="commander.name"
></h3>
<div class="flex items-center gap-2">
<h3
class="text-lg font-semibold text-gray-900"
x-text="commander.name"
></h3>
<span x-show="commander.inactive" class="px-2 py-0.5 rounded text-xs font-bold bg-gray-200 text-gray-600">INACTIVE</span>
</div>
<div class="flex space-x-1 mt-1">
<template x-for="color in commander.colors" :key="color">
<div
@@ -477,6 +480,21 @@
</div>
</div>
<!-- Inactive Toggle -->
<div class="mb-6 flex items-center justify-between p-3 rounded-lg" :class="editingCommander.inactive ? 'bg-gray-100' : 'bg-gray-50'">
<div>
<p class="text-sm font-medium text-gray-700">Commander Status</p>
<p class="text-xs text-gray-500" x-text="editingCommander.inactive ? 'This commander is excluded from stats and dropdowns.' : 'This commander is active and included in stats.'"></p>
</div>
<button
type="button"
@click="toggleInactive(editingCommander)"
class="px-3 py-1.5 rounded text-sm font-medium transition-colors"
:class="editingCommander.inactive ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'"
x-text="editingCommander.inactive ? 'Reactivate' : 'Deactivate'"
></button>
</div>
<!-- Form Actions -->
<div class="flex justify-end space-x-4">
<button

View File

@@ -256,7 +256,7 @@
<!-- Top Commanders -->
<div class="card">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">Top Commanders</h2>
<h2 class="text-xl font-semibold">Top Played Commanders</h2>
<a
href="/commanders.html"
class="text-edh-accent hover:text-edh-primary"

View File

@@ -9,7 +9,7 @@
content="Log and track your Magic: The Gathering EDH/Commander games"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body class="h-full" x-data="gameManager()">
@@ -36,9 +36,9 @@
<div class="mb-8 flex justify-between items-center">
<h2 class="text-2xl font-semibold text-gray-900">Recent Games</h2>
<div class="flex space-x-4">
<button @click="exportGames()" class="btn btn-secondary">
<!--<button @click="exportGames()" class="btn btn-secondary">
Export JSON
</button>
</button>-->
<button @click="showLogForm = !showLogForm" class="btn btn-primary">
<span x-show="!showLogForm">Log New Game</span>
<span x-show="showLogForm">Cancel</span>
@@ -79,10 +79,10 @@
required
>
<option value="">Select a commander...</option>
<template x-for="commander in commanders" :key="commander.id">
<template x-for="commander in commanders.filter(c => !c.inactive || (editingGame && editingGame.commanderId == c.id))" :key="commander.id">
<option
:value="commander.id"
x-text="commander.name"
x-text="commander.name + (commander.inactive ? ' (inactive)' : '')"
></option>
</template>
</select>
@@ -171,16 +171,16 @@
</label>
</div>
<!-- Notes -->
<div class="col-span-1 md:col-span-2 w-full">
<label class="form-label">Game Notes</label>
<textarea
x-model="formData.notes"
rows="5"
class="form-textarea w-full"
placeholder="Any memorable moments, combos, or reasons for winning/losing..."
></textarea>
</div>
<!-- Notes -->
<div class="col-span-1 md:col-span-2 w-full">
<label class="form-label">Game Notes</label>
<textarea
x-model="formData.notes"
rows="5"
class="form-textarea w-full"
placeholder="Any memorable moments, combos, or reasons for winning/losing..."
></textarea>
</div>
</div>
<!-- Error Message -->
@@ -337,94 +337,98 @@
</div>
</template>
<!-- Load More -->
<div x-show="pagination.hasMore" class="flex justify-center pt-8">
<button
@click="loadMore()"
:disabled="pagination.isLoadingMore"
:class="{ 'opacity-50 cursor-not-allowed': pagination.isLoadingMore }"
class="btn btn-secondary flex justify-center items-center space-x-2"
<!-- Load More -->
<div x-show="pagination.hasMore" class="flex justify-center pt-8">
<button
@click="loadMore()"
:disabled="pagination.isLoadingMore"
:class="{ 'opacity-50 cursor-not-allowed': pagination.isLoadingMore }"
class="btn btn-secondary flex justify-center items-center space-x-2"
>
<svg
x-show="!pagination.isLoadingMore"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
x-show="!pagination.isLoadingMore"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
></path>
</svg>
<svg
x-show="pagination.isLoadingMore"
class="animate-spin h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span x-text="pagination.isLoadingMore ? 'Loading...' : 'Load More Games'"></span>
</button>
</div>
</div>
</main>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
></path>
</svg>
<svg
x-show="pagination.isLoadingMore"
class="animate-spin h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span
x-text="pagination.isLoadingMore ? 'Loading...' : 'Load More Games'"
></span>
</button>
</div>
</div>
</main>
<!-- Delete Confirmation Modal -->
<div
x-show="deleteConfirm.show"
x-cloak
x-transition
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="deleteConfirm.show = false"
>
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Game</h3>
<p class="text-gray-600 mb-6">
Are you sure you want to delete this game record? This action cannot be undone.
</p>
<div class="flex justify-end space-x-3">
<button
@click="deleteConfirm.show = false"
class="btn btn-secondary"
>
Cancel
</button>
<button
@click="confirmDelete()"
:disabled="deleteConfirm.deleting"
class="btn btn-primary bg-red-600 hover:bg-red-700"
>
<span x-show="!deleteConfirm.deleting">Delete Game</span>
<span x-show="deleteConfirm.deleting">Deleting...</span>
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div
x-show="deleteConfirm.show"
x-cloak
x-transition
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="deleteConfirm.show = false"
>
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Game</h3>
<p class="text-gray-600 mb-6">
Are you sure you want to delete this game record? This action cannot
be undone.
</p>
<div class="flex justify-end space-x-3">
<button
@click="deleteConfirm.show = false"
class="btn btn-secondary"
>
Cancel
</button>
<button
@click="confirmDelete()"
:disabled="deleteConfirm.deleting"
class="btn btn-primary bg-red-600 hover:bg-red-700"
>
<span x-show="!deleteConfirm.deleting">Delete Game</span>
<span x-show="deleteConfirm.deleting">Deleting...</span>
</button>
</div>
</div>
</div>
</div>
<!-- Scripts --> <script
defer
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="/js/auth-guard.js"></script>
<script src="/js/games.js"></script>
<script src="/js/footer-loader.js"></script>
<!-- Scripts -->
<script
defer
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="/js/auth-guard.js"></script>
<script src="/js/games.js"></script>
<script src="/js/footer-loader.js"></script>
</body>
</html>

View File

@@ -230,6 +230,40 @@ function commanderManager() {
}
},
async toggleInactive(commander) {
const newInactive = !commander.inactive
commander.inactive = newInactive
try {
const token =
localStorage.getItem('edh-stats-token') ||
sessionStorage.getItem('edh-stats-token')
const response = await fetch(`/api/commanders/${commander.id}/inactive`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ inactive: newInactive })
})
if (response.ok) {
const data = await response.json()
const index = this.commanders.findIndex((c) => c.id === commander.id)
if (index !== -1) {
this.commanders.splice(index, 1, data.commander)
}
} else {
commander.inactive = !newInactive
this.serverError = 'Failed to update commander status'
}
} catch (error) {
commander.inactive = !newInactive
console.error('Toggle inactive error:', error)
this.serverError = 'Network error occurred'
}
},
async deleteCommander(commander) {
if (!confirm(`Are you sure you want to delete "${commander.name}"?`))
return

View File

@@ -1 +1 @@
2.1.8
2.1.9