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:
2026-01-19 01:55:01 +01:00
parent 94335ecd12
commit e60a8df4f0
26 changed files with 59218 additions and 315 deletions

View 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));
}
}

View 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 {}

View 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' });
}
}