Implement complete inventory system with equipment database
Features: - HP Control component with damage/heal/direct modes (mobile-optimized) - Conditions system with PF2e condition database - Equipment database with 5,482 items from PF2e (weapons, armor, equipment) - AddItemModal with search, category filters, and pagination - Bulk tracking with encumbered/overburdened status display - Item management (add, remove, toggle equipped) Backend: - Equipment module with search/filter endpoints - Prisma migration for equipment detail fields - Equipment seed script importing from JSON data files - Extended Equipment model (damage, hands, AC, etc.) Frontend: - New components: HpControl, AddConditionModal, AddItemModal - Improved character sheet with tabbed interface - API methods for equipment search and item management Documentation: - CLAUDE.md with project philosophy and architecture decisions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
128
server/src/modules/equipment/equipment.controller.ts
Normal file
128
server/src/modules/equipment/equipment.controller.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard.js';
|
||||
import { EquipmentService } from './equipment.service.js';
|
||||
|
||||
@ApiTags('Equipment')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('equipment')
|
||||
export class EquipmentController {
|
||||
constructor(private readonly equipmentService: EquipmentService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Search and browse equipment' })
|
||||
@ApiQuery({ name: 'query', required: false, description: 'Search term for name' })
|
||||
@ApiQuery({ name: 'category', required: false, description: 'Filter by category (Weapons, Armor, Consumables, etc.)' })
|
||||
@ApiQuery({ name: 'subcategory', required: false, description: 'Filter by subcategory' })
|
||||
@ApiQuery({ name: 'minLevel', required: false, type: Number })
|
||||
@ApiQuery({ name: 'maxLevel', required: false, type: Number })
|
||||
@ApiQuery({ name: 'traits', required: false, description: 'Comma-separated list of traits' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 50)' })
|
||||
async search(
|
||||
@Query('query') query?: string,
|
||||
@Query('category') category?: string,
|
||||
@Query('subcategory') subcategory?: string,
|
||||
@Query('minLevel') minLevel?: string,
|
||||
@Query('maxLevel') maxLevel?: string,
|
||||
@Query('traits') traits?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.equipmentService.search({
|
||||
query,
|
||||
category,
|
||||
subcategory,
|
||||
minLevel: minLevel ? parseInt(minLevel, 10) : undefined,
|
||||
maxLevel: maxLevel ? parseInt(maxLevel, 10) : undefined,
|
||||
traits: traits ? traits.split(',').map(t => t.trim()) : undefined,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: 'Get all equipment categories' })
|
||||
async getCategories() {
|
||||
return this.equipmentService.getCategories();
|
||||
}
|
||||
|
||||
@Get('categories/:category/subcategories')
|
||||
@ApiOperation({ summary: 'Get subcategories for a category' })
|
||||
async getSubcategories(@Param('category') category: string) {
|
||||
return this.equipmentService.getSubcategories(category);
|
||||
}
|
||||
|
||||
@Get('weapons')
|
||||
@ApiOperation({ summary: 'Browse weapons' })
|
||||
@ApiQuery({ name: 'query', required: false })
|
||||
@ApiQuery({ name: 'subcategory', required: false })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getWeapons(
|
||||
@Query('query') query?: string,
|
||||
@Query('subcategory') subcategory?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.equipmentService.getWeapons({
|
||||
query,
|
||||
subcategory,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('armor')
|
||||
@ApiOperation({ summary: 'Browse armor' })
|
||||
@ApiQuery({ name: 'query', required: false })
|
||||
@ApiQuery({ name: 'subcategory', required: false })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getArmor(
|
||||
@Query('query') query?: string,
|
||||
@Query('subcategory') subcategory?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.equipmentService.getArmor({
|
||||
query,
|
||||
subcategory,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('consumables')
|
||||
@ApiOperation({ summary: 'Browse consumables' })
|
||||
@ApiQuery({ name: 'query', required: false })
|
||||
@ApiQuery({ name: 'subcategory', required: false })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getConsumables(
|
||||
@Query('query') query?: string,
|
||||
@Query('subcategory') subcategory?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.equipmentService.getConsumables({
|
||||
query,
|
||||
subcategory,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get equipment by ID' })
|
||||
async getById(@Param('id') id: string) {
|
||||
return this.equipmentService.getById(id);
|
||||
}
|
||||
|
||||
@Get('by-name/:name')
|
||||
@ApiOperation({ summary: 'Get equipment by exact name' })
|
||||
async getByName(@Param('name') name: string) {
|
||||
return this.equipmentService.getByName(decodeURIComponent(name));
|
||||
}
|
||||
}
|
||||
12
server/src/modules/equipment/equipment.module.ts
Normal file
12
server/src/modules/equipment/equipment.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EquipmentController } from './equipment.controller.js';
|
||||
import { EquipmentService } from './equipment.service.js';
|
||||
import { PrismaModule } from '../../prisma/prisma.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [EquipmentController],
|
||||
providers: [EquipmentService],
|
||||
exports: [EquipmentService],
|
||||
})
|
||||
export class EquipmentModule {}
|
||||
153
server/src/modules/equipment/equipment.service.ts
Normal file
153
server/src/modules/equipment/equipment.service.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service.js';
|
||||
|
||||
export interface EquipmentSearchParams {
|
||||
query?: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
minLevel?: number;
|
||||
maxLevel?: number;
|
||||
traits?: string[];
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface EquipmentSearchResult {
|
||||
items: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EquipmentService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async search(params: EquipmentSearchParams): Promise<EquipmentSearchResult> {
|
||||
const {
|
||||
query,
|
||||
category,
|
||||
subcategory,
|
||||
minLevel,
|
||||
maxLevel,
|
||||
traits,
|
||||
page = 1,
|
||||
limit = 50,
|
||||
} = params;
|
||||
|
||||
// Build where clause
|
||||
const where: any = {};
|
||||
|
||||
// Text search on name
|
||||
if (query && query.trim()) {
|
||||
where.name = {
|
||||
contains: query.trim(),
|
||||
mode: 'insensitive',
|
||||
};
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (category) {
|
||||
where.itemCategory = category;
|
||||
}
|
||||
|
||||
// Subcategory filter
|
||||
if (subcategory) {
|
||||
where.itemSubcategory = subcategory;
|
||||
}
|
||||
|
||||
// Level range
|
||||
if (minLevel !== undefined || maxLevel !== undefined) {
|
||||
where.level = {};
|
||||
if (minLevel !== undefined) {
|
||||
where.level.gte = minLevel;
|
||||
}
|
||||
if (maxLevel !== undefined) {
|
||||
where.level.lte = maxLevel;
|
||||
}
|
||||
}
|
||||
|
||||
// Traits filter (has any of the specified traits)
|
||||
if (traits && traits.length > 0) {
|
||||
where.traits = {
|
||||
hasSome: traits,
|
||||
};
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await this.prisma.equipment.count({ where });
|
||||
|
||||
// Get paginated results
|
||||
const items = await this.prisma.equipment.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ level: 'asc' },
|
||||
{ name: 'asc' },
|
||||
],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// Get all unique categories for filter UI
|
||||
const categoriesResult = await this.prisma.equipment.groupBy({
|
||||
by: ['itemCategory'],
|
||||
orderBy: { itemCategory: 'asc' },
|
||||
});
|
||||
const categories = categoriesResult.map(c => c.itemCategory);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
categories,
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
return this.prisma.equipment.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async getByName(name: string) {
|
||||
return this.prisma.equipment.findUnique({
|
||||
where: { name },
|
||||
});
|
||||
}
|
||||
|
||||
async getCategories(): Promise<string[]> {
|
||||
const result = await this.prisma.equipment.groupBy({
|
||||
by: ['itemCategory'],
|
||||
orderBy: { itemCategory: 'asc' },
|
||||
});
|
||||
return result.map(c => c.itemCategory);
|
||||
}
|
||||
|
||||
async getSubcategories(category: string): Promise<string[]> {
|
||||
const result = await this.prisma.equipment.groupBy({
|
||||
by: ['itemSubcategory'],
|
||||
where: {
|
||||
itemCategory: category,
|
||||
itemSubcategory: { not: null },
|
||||
},
|
||||
orderBy: { itemSubcategory: 'asc' },
|
||||
});
|
||||
return result.map(c => c.itemSubcategory).filter((s): s is string => s !== null);
|
||||
}
|
||||
|
||||
async getWeapons(params: Omit<EquipmentSearchParams, 'category'>) {
|
||||
return this.search({ ...params, category: 'Weapons' });
|
||||
}
|
||||
|
||||
async getArmor(params: Omit<EquipmentSearchParams, 'category'>) {
|
||||
return this.search({ ...params, category: 'Armor' });
|
||||
}
|
||||
|
||||
async getConsumables(params: Omit<EquipmentSearchParams, 'category'>) {
|
||||
return this.search({ ...params, category: 'Consumables' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user