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:
134
CLAUDE.md
Normal file
134
CLAUDE.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Dimension47 - TTRPG Campaign Management Platform
|
||||
|
||||
## Projekt-Philosophie
|
||||
|
||||
**Qualität vor Geschwindigkeit**: Es ist egal, wie lange die Implementierung dauert - das System soll am Ende gut funktionieren. Keine Shortcuts oder Quick-Fixes, die später Probleme verursachen.
|
||||
|
||||
**Lieber langsam und richtig**: Immer den sauberen Weg wählen, auch wenn er länger dauert. Beispiele:
|
||||
- Prisma Migrations statt `db push` verwenden
|
||||
- Daten in Datenbank statt direkt aus JSON-Dateien lesen
|
||||
- Proper Error Handling statt try/catch mit console.log
|
||||
- TypeScript strict mode, keine `any` Types
|
||||
|
||||
## Architektur-Entscheidungen
|
||||
|
||||
### Daten-Management
|
||||
- **Equipment/Items in Datenbank**: Alle Pathfinder 2e Daten (Waffen, Rüstungen, Ausrüstung, Zauber, Talente) werden in die PostgreSQL-Datenbank importiert, NICHT direkt aus JSON-Dateien gelesen.
|
||||
- **Prisma Seed Scripts**: JSON-Dateien werden via `prisma db seed` in die Datenbank importiert.
|
||||
- **Übersetzungen gecacht**: Deutsche Übersetzungen werden on-demand via Claude API generiert und in der Translation-Tabelle gespeichert.
|
||||
|
||||
### Vorteile des Database-Ansatzes
|
||||
- Bessere Such- und Filtermöglichkeiten
|
||||
- Konsistente Datenstruktur
|
||||
- Eigene Items können hinzugefügt werden
|
||||
- Relationale Verknüpfungen möglich
|
||||
- Performance durch Indizes
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
- React 19 + TypeScript
|
||||
- Vite als Build Tool
|
||||
- Tailwind CSS v4
|
||||
- shadcn/ui Komponenten (selbst gebaut)
|
||||
- Zustand für Client State
|
||||
|
||||
### Backend
|
||||
- NestJS mit TypeScript
|
||||
- Prisma ORM
|
||||
- PostgreSQL
|
||||
- JWT Authentication
|
||||
- Socket.io für WebSockets
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```
|
||||
dimension47/
|
||||
├── client/ # React Frontend
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # App-Config, Router
|
||||
│ │ ├── features/ # Feature-Module (auth, campaigns, characters, etc.)
|
||||
│ │ │ └── characters/components/
|
||||
│ │ │ ├── character-sheet-page.tsx # Hauptseite mit Tabs
|
||||
│ │ │ ├── hp-control.tsx # HP-Management Komponente
|
||||
│ │ │ ├── add-condition-modal.tsx # Zustand hinzufügen
|
||||
│ │ │ └── add-item-modal.tsx # Item aus DB hinzufügen
|
||||
│ │ ├── shared/ # Geteilte Komponenten, Hooks, Types
|
||||
│ │ └── assets/
|
||||
│ └── public/ # Statische Dateien, JSON-Datenbanken
|
||||
│
|
||||
└── server/ # NestJS Backend
|
||||
├── src/
|
||||
│ ├── modules/ # Feature-Module
|
||||
│ │ ├── auth/ # Authentifizierung
|
||||
│ │ ├── campaigns/ # Kampagnenverwaltung
|
||||
│ │ ├── characters/# Charakterverwaltung
|
||||
│ │ └── equipment/ # Equipment-Datenbank (NEU)
|
||||
│ ├── common/ # Shared Utilities
|
||||
│ └── prisma/ # Prisma Service
|
||||
└── prisma/
|
||||
├── schema.prisma # Datenbank-Schema
|
||||
├── migrations/ # Prisma Migrations
|
||||
├── seed.ts # Basis-Seed
|
||||
├── seed-equipment.ts # Equipment-Import (5.482 Items)
|
||||
└── data/ # JSON-Quelldaten für Equipment
|
||||
```
|
||||
|
||||
## Implementierte Features
|
||||
|
||||
### Auth
|
||||
- JWT-basierte Authentifizierung
|
||||
- Login/Register/Logout
|
||||
- Rollen: ADMIN, GM, PLAYER
|
||||
|
||||
### Kampagnen
|
||||
- CRUD für Kampagnen
|
||||
- Mitgliederverwaltung
|
||||
- GM-Berechtigungen
|
||||
|
||||
### Charaktere
|
||||
- Pathbuilder 2e Import
|
||||
- HP-Management (mobile-optimiert, Schaden/Heilung/Direkt)
|
||||
- Zustände (Conditions) mit PF2e-Datenbank
|
||||
- Fertigkeiten mit deutschen Namen
|
||||
- Rettungswürfe
|
||||
- Inventar-System mit vollständiger Equipment-Datenbank (5.482 Items)
|
||||
- Bulk-Tracking mit Belastungs-Anzeige
|
||||
- Item-Suche mit Kategoriefilter und Pagination
|
||||
|
||||
### Equipment-Datenbank
|
||||
- **5.482 Items** aus Pathfinder 2e importiert
|
||||
- Waffen mit Schaden, Schadentyp, Reichweite, Eigenschaften
|
||||
- Rüstungen mit RK, DEX-Cap, Penalties
|
||||
- Verbrauchsgüter und allgemeine Ausrüstung
|
||||
- Durchsuchbar nach Name, Kategorie, Level, Eigenschaften
|
||||
- API-Endpunkte: `/equipment`, `/equipment/categories`, `/equipment/weapons`, etc.
|
||||
|
||||
## Design-Prinzipien
|
||||
|
||||
- **Mobile-First**: Touch-optimiert mit 44px+ Touch-Targets
|
||||
- **Dark Mode**: Primärfarbe #c26dbc (Magenta)
|
||||
- **Deutsch**: Alle UI-Texte auf Deutsch
|
||||
- **Keine Emojis**: Nur Lucide Icons
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
# Backend starten (Port 3001)
|
||||
cd server && npm run start:dev
|
||||
|
||||
# Frontend starten (Port 5173)
|
||||
cd client && npm run dev
|
||||
|
||||
# Prisma Migrations (IMMER Migrations verwenden, NIEMALS db push!)
|
||||
cd server && npm run db:migrate:dev # Neue Migration erstellen & anwenden
|
||||
cd server && npm run db:migrate:deploy # Migrations in Produktion anwenden
|
||||
cd server && npm run db:migrate:status # Status der Migrations prüfen
|
||||
cd server && npm run db:migrate:reset # DB zurücksetzen (Dev only!)
|
||||
|
||||
# Prisma Sonstiges
|
||||
cd server && npm run db:studio # DB Browser
|
||||
cd server && npm run db:generate # Prisma Client generieren
|
||||
cd server && npm run db:seed # Basis-Seed-Daten laden
|
||||
cd server && npm run db:seed:equipment # Equipment-Datenbank laden
|
||||
```
|
||||
298
client/public/pathfinder_conditions.json
Normal file
298
client/public/pathfinder_conditions.json
Normal file
@@ -0,0 +1,298 @@
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"name": "Blinded",
|
||||
"name_german": "Geblendet",
|
||||
"description": "You can't see. All normal terrain is difficult terrain to you. You can't detect anything using vision. You automatically critically fail Perception checks that require you to be able to see, and if vision is your only precise sense, you take a –4 status penalty to Perception checks. You are immune to visual effects. Blinded overrides dazzled.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 442"
|
||||
},
|
||||
{
|
||||
"name": "Broken",
|
||||
"name_german": "Kaputt",
|
||||
"description": "Broken is a condition that affects only objects. An object is broken when damage has reduced its Hit Points to equal or less than its Broken Threshold. A broken object can't be used for its normal function, nor does it grant bonuses—with the exception of armor. Broken armor still grants its item bonus to AC, but it also imparts a status penalty to AC depending on its category: –1 for broken light armor, –2 for broken medium armor, or –3 for broken heavy armor. A broken item still imposes penalties and limitations normally incurred by carrying, holding, or wearing it. For example, broken armor would still impose its Dexterity modifier cap, check penalty, and so forth. If an effect makes an item broken automatically and the item has more HP than its Broken Threshold, that effect also reduces the item's current HP to the Broken Threshold.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 442"
|
||||
},
|
||||
{
|
||||
"name": "Clumsy",
|
||||
"name_german": "Tollpatschig",
|
||||
"description": "Your movements become clumsy and inexact. Clumsy always includes a value. You take a status penalty equal to the condition value to Dexterity-based rolls and DCs, including AC, Reflex saves, ranged attack rolls, and skill checks using Acrobatics, Stealth, and Thievery.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 442"
|
||||
},
|
||||
{
|
||||
"name": "Concealed",
|
||||
"name_german": "Verborgen",
|
||||
"description": "While you are concealed from a creature, such as in a thick fog, you are difficult for that creature to see. You can still be observed, but you're tougher to target. A creature that you're concealed from must succeed at a DC 5 flat check when targeting you with an attack, spell, or other effect. If the check fails, the attack, spell, or effect doesn't affect you. Area effects aren't subject to this flat check. An item you're wearing or carrying can't be concealed from a creature by your concealment.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 442"
|
||||
},
|
||||
{
|
||||
"name": "Confused",
|
||||
"name_german": "Verwirrt",
|
||||
"description": "You don't have your wits about you, and you attack unpredictably. You can't use actions with the concentrate trait unless they or their intended consequences are extremely simple. You can Seek and use basic actions with the manipulate trait, but you can't use most manipulate actions such as those to activate an item. When you use an action with the attack trait, you must attempt a DC 11 flat check. On a failure, you use the action but don't attack your intended target. Instead, you attack a random creature within your reach, making a melee attack, or a random creature within range of the first weapon you're wielding, making a ranged attack. If no creatures are in range, you attack a random square. The GM determines any random targets or squares. When confused, you can still use actions that don't have the attack or concentrate traits, but you must succeed at a DC 11 flat check or the action fails and is wasted. Each time you attempt an attack while confused, the confused condition's value decreases by 1 (minimum 0); this decrease occurs after resolving the attack and flat check.",
|
||||
"traits": ["mental"],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Controlled",
|
||||
"name_german": "Kontrolliert",
|
||||
"description": "Someone else is making your decisions for you, usually because you're being commanded or magically dominated. The controller dictates how you act and can make you use any of your actions, including attacks, reactions, or even Delay. The controller usually does not have to spend their own actions when controlling you.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Dazzled",
|
||||
"name_german": "Benommen",
|
||||
"description": "Your eyes are overstimulated. If vision is your only precise sense, all creatures and objects are concealed from you.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Deafened",
|
||||
"name_german": "Taub",
|
||||
"description": "You can't hear. You automatically critically fail Perception checks that require you to be able to hear. You take a –2 status penalty to Perception checks for initiative and checks that involve sound but also rely on other senses. You are immune to auditory effects.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Doomed",
|
||||
"name_german": "Dem Tode geweiht",
|
||||
"description": "Your soul is being sucked away to the realm of the dead. Doomed always includes a value. The maximum number of Hero Points in your pool is reduced by the doomed value. If your maximum Hero Points are ever reduced to 0 by the doomed condition, you die immediately. When you die, you're no longer doomed. Doomed decreases by 1 at the end of a full night's rest.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Drained",
|
||||
"name_german": "Entkräftet",
|
||||
"description": "Your blood, vitality, or life force has been sapped from you. Drained always includes a value. You take a status penalty equal to your drained value on Constitution-based rolls and DCs, including Fortitude saves and Constitution-based rolls to recover from being sickened. You also lose a number of Hit Points equal to your level times your drained value, and your maximum Hit Points are reduced by the same amount. When the drained value would increase, you lose additional Hit Points equal to your level, and when it would decrease, you regain Hit Points equal to your level. Drained is removed by certain spells. A single effect that restores Hit Points can't increase your Hit Points or maximum Hit Points above what they would be if you weren't drained.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Dying",
|
||||
"name_german": "Sterbend",
|
||||
"description": "You are bleeding out or otherwise at death's door. While you have this condition, you are unconscious. Dying always includes a value, and if it ever reaches dying 4, you die. If you're dying, you must attempt a recovery check at the start of your turn each round to determine whether you get better or worse. Your dying condition increases by 1 if you take damage while dying, or by 2 if you take damage from an enemy's critical hit or a critical failure on your save against a damaging effect. The dying value decreases by 1 if you're successfully treated with Treat Wounds, or if you're magically healed while unconscious. It decreases to 0 if someone successfully restores Hit Points to you while you're at 0 Hit Points. If your dying condition is removed, you become wounded 1 (or increase wounded by 1 if you already have that condition). If you would become dying again while you are wounded, your dying value increases by your wounded value. The wounded condition is removed if someone successfully restores Hit Points to you to your maximum Hit Points while you're wounded, or after 10 minutes of rest while you have at least 1 Hit Point.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Encumbered",
|
||||
"name_german": "Belastet",
|
||||
"description": "You are carrying more weight than you can manage. While you're encumbered, you're clumsy 1 and take a 10-foot penalty to all your Speeds. If you're wearing armor, you still calculate your armor's check penalty, Speed penalty, and Dexterity modifier cap as normal.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Enfeebled",
|
||||
"name_german": "Geschwächt",
|
||||
"description": "You're physically weakened. Enfeebled always includes a value. You take a status penalty equal to this value on Strength-based rolls and DCs, including Strength-based melee attack rolls, Strength-based damage rolls, and Athletics checks.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Fascinated",
|
||||
"name_german": "Fasziniert",
|
||||
"description": "You are compelled to focus your attention on something, distracting you from whatever else is going on around you. You take a –2 status penalty to Perception and skill checks, and you can't use actions with the concentrate trait unless they or their intended consequences are related to the subject of your fascination (as determined by the GM). For instance, you might be able to Seek and Recall Knowledge about the subject, but you likely couldn't cast a spell targeting a different creature. This condition ends if a creature uses hostile actions against you or your allies.",
|
||||
"traits": ["mental"],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Fatigued",
|
||||
"name_german": "Erschöpft",
|
||||
"description": "You're tired and can't summon much energy. You take a –1 status penalty to AC and saving throws. You can't use exploration activities performed while traveling, such as those that let you cover ground quickly. You recover from fatigue after a full night's rest.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 443"
|
||||
},
|
||||
{
|
||||
"name": "Fleeing",
|
||||
"name_german": "Fliehend",
|
||||
"description": "You're forced to run away due to fear or some other compulsion. On your turn, you must spend each of your actions trying to escape the source of the fleeing condition as expediently as possible (such as by using move actions to flee, or opening doors barring your escape). The condition ends once you are out of line of sight of the source of your fleeing condition.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Friendly",
|
||||
"name_german": "Freundlich",
|
||||
"description": "This condition reflects a creature's disposition toward a particular character, and it affects only creatures that are not player characters. A creature that is friendly to a character likes that character. The character can attempt to make a Request of a friendly creature, and the friendly creature is likely to agree to a simple and safe request that doesn't cost it much to fulfill. If the character or their allies use hostile actions against the creature, the creature gains a worse attitude condition depending on the severity of the hostile action, as determined by the GM.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Frightened",
|
||||
"name_german": "Verängstigt",
|
||||
"description": "You're gripped by fear and struggle to control your nerves. The frightened condition always includes a value. You take a status penalty equal to this value to all your checks and DCs. Unless specified otherwise, at the end of each of your turns, the value of your frightened condition decreases by 1.",
|
||||
"traits": ["mental"],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Grabbed",
|
||||
"name_german": "Gepackt",
|
||||
"description": "A creature, object, or magic holds you in place. You can't move while you have the grabbed condition. If you attempt a manipulate action while grabbed, you must succeed at a DC 5 flat check or the action fails and is wasted. While you're grabbed, attempting to move away from the creature or object that grabbed you automatically ends the grabbed condition. The condition also ends if the creature that grabbed you moves away from you, uses another action that requires the use of the limb that's grabbing you, or if effects would move you to a space where the creature can't grab you.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Helpful",
|
||||
"name_german": "Hilfsbereit",
|
||||
"description": "This condition reflects a creature's disposition toward a particular character, and it affects only creatures that are not player characters. A creature that is helpful to a character wishes to actively aid that character. It will accept reasonable Requests from that character, as long as such requests aren't at the expense of the helpful creature's goals or quality of life. If the character or their allies use hostile actions against the creature, the creature gains a worse attitude condition depending on the severity of the hostile action, as determined by the GM.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Hidden",
|
||||
"name_german": "Versteckt",
|
||||
"description": "While you are hidden from a creature, that creature knows the space you're in but can't tell precisely where you are. You typically become hidden by using to Hide. When Seeking a creature using only imprecise senses, it remains hidden, rather than observed. A creature you're hidden from is off-guard to you, and it must succeed at a DC 11 flat check when targeting you with an attack, spell, or other effect or the attack, spell, or effect fails to affect you. Area effects aren't subject to this flat check. A creature might be able to use the Seek action to try to observe you.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Hostile",
|
||||
"name_german": "Feindlich",
|
||||
"description": "This condition reflects a creature's disposition toward a particular character, and it affects only creatures that are not player characters. A hostile creature actively seeks to harm the characters it's hostile to. It doesn't necessarily attack, but it won't accept Requests from those characters.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Immobilized",
|
||||
"name_german": "Bewegungsunfähig",
|
||||
"description": "You can't use actions with the move trait. If you're immobilized by something holding you in place and an external force would move you out of your space, the force must succeed at a check against either the DC of the effect holding you in place or the relevant defense (usually Fortitude DC) of the creature holding you in place.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Indifferent",
|
||||
"name_german": "Gleichgültig",
|
||||
"description": "This condition reflects a creature's disposition toward a particular character, and it affects only creatures that are not player characters. A creature that is indifferent to a character doesn't really care one way or the other about that character. Assume a creature is indifferent if it has no other attitude condition. The character can attempt to make a Request of an indifferent creature, but the creature is unlikely to agree unless the request benefits it in some way.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Invisible",
|
||||
"name_german": "Unsichtbar",
|
||||
"description": "While invisible, you can't be seen. You're undetected to everyone. Creatures can Seek to attempt to detect you; if a creature succeeds at its Perception check against your Stealth DC, you become hidden to that creature until you Sneak successfully again. If you become invisible while someone can already see you, you start out hidden to the observer (instead of undetected) until you successfully Sneak. You can't become observed while invisible except via special abilities or magic.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Off-Guard",
|
||||
"name_german": "Unvorbereitet",
|
||||
"description": "You're distracted or otherwise unable to focus your defenses. You take a –2 circumstance penalty to AC.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Observed",
|
||||
"name_german": "Beobachtet",
|
||||
"description": "Anything in plain view is observed by you. If a creature takes measures to avoid detection, such as by using Stealth to Hide, it can become hidden or undetected instead of observed. If you have another precise sense instead of or in addition to sight, you might be able to observe a creature or object using that sense instead. You can observe a creature only with precise senses.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Paralyzed",
|
||||
"name_german": "Gelähmt",
|
||||
"description": "Your body is frozen in place. You have the flat-footed and immobilized conditions, and you can't act except to Recall Knowledge and use actions that require only your mind (as determined by the GM). Your senses still function, but only in the most limited way—you can't Seek while paralyzed.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Persistent Damage",
|
||||
"name_german": "Andauernder Schaden",
|
||||
"description": "Instead of taking damage immediately, you take damage at the end of each of your turns as long as you have the condition, rolling any damage dice anew each time. After you take persistent damage, roll a DC 15 flat check to see if you recover from the persistent damage. If you succeed, the condition ends. If you fail, you continue to take the persistent damage and must attempt another flat check at the end of the next turn. The condition might also end due to other circumstances, such as the one noted in the acid example above. You can be simultaneously affected by multiple persistent damage conditions so long as they have different damage types. If you would gain another persistent damage condition with the same damage type, the higher amount of damage overrides the lower amount. The damage type and amount of damage from the condition is given after the name (such as \"persistent damage 1d4 fire\").",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Petrified",
|
||||
"name_german": "Versteinert",
|
||||
"description": "You have been turned to stone. You can't act, nor can you sense anything. You become an object with a Hardness equal to that of the stone you've been turned into (typically 8 for most stone, but as determined by the GM). Because you're not alive, you're immune to anything that requires a living creature or a creature with the living trait, and you don't require food, water, or sleep. You can't recover Hit Points while petrified. All gear you're wearing and carrying becomes part of the stone along with you; this gear can't be removed and doesn't function. You can be Repaired like other stone objects, gaining Hit Points instead of being healed.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 444"
|
||||
},
|
||||
{
|
||||
"name": "Prone",
|
||||
"name_german": "Liegend",
|
||||
"description": "You're lying on the ground. You are off-guard and take a –2 circumstance penalty to attack rolls. The only move actions you can use while you're prone are Crawl and Stand. Standing up ends the prone condition. You can Take Cover while prone to hunker down and gain greater cover against ranged attacks, though you remain off-guard.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Quickened",
|
||||
"name_german": "Beschleunigt",
|
||||
"description": "You gain 1 additional action at the start of your turn each round. Many effects that make you quickened specify the types of actions you can use with this additional action. If you become quickened from multiple sources, you can use the extra action you've been granted for any of the listed types of actions. For example, if you're quickened by a haste spell and a quicken spell, you could use the extra action from haste for a Strike, and the extra action from quicken spell for a Stride. The quickened condition ends at the end of the complete turn after the one in which you became quickened.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Restrained",
|
||||
"name_german": "Festgehalten",
|
||||
"description": "You're tied up and can barely move, or a grappling creature has you pinned. You have the off-guard and immobilized conditions, and you can't use any actions with the attack or manipulate traits except to attempt to Escape or Force Open your bonds. Restrained overrides grabbed.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Sickened",
|
||||
"name_german": "Übel",
|
||||
"description": "You feel ill. Sickened always includes a value. You take a status penalty equal to this value on all your checks and DCs. You can't willingly ingest anything—including elixirs and potions—while sickened. You can spend a single action retching in an attempt to recover, which lets you immediately attempt a Fortitude save against the DC of the effect that made you sickened. On a success, you reduce your sickened value by 1 (or by 2 on a critical success).",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Slowed",
|
||||
"name_german": "Verlangsamt",
|
||||
"description": "You have fewer actions. Slowed always includes a value. When you regain your actions at the start of your turn, reduce the number of actions you regain by your slowed value. Because slowed has its effect at the start of your turn, you don't immediately lose actions if you become slowed during your turn.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Stunned",
|
||||
"name_german": "Betäubt",
|
||||
"description": "You've become senseless. You can't act while stunned. Stunned usually includes a value, which indicates how many total actions you lose, possibly over multiple turns, from being stunned. Each time you regain actions (such as at the start of your turn), reduce the number you regain by your stunned value, then reduce your stunned value by the number of actions you lost. For example, if you were stunned 4, you would lose all 3 of your actions on your turn, reducing you to stunned 1; on your next turn, you would lose 1 more action, leaving you with 2 actions that turn and reducing you to stunned 0. Some abilities, instead of lasting a certain number of actions, last until the end of your turn or the start of your next turn.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Stupefied",
|
||||
"name_german": "Benommen",
|
||||
"description": "Your thoughts and instincts are clouded. Stupefied always includes a value. You take a status penalty equal to this value on Intelligence-, Wisdom-, and Charisma-based checks and DCs, including Will saving throws, spell attack rolls, spell DCs, and skill checks that use these ability scores. Any time you attempt to Cast a Spell while stupefied, the spell is disrupted unless you succeed at a flat check with a DC equal to 5 + your stupefied value.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Unconscious",
|
||||
"name_german": "Bewusstlos",
|
||||
"description": "You're sleeping or have been knocked out. You can't act. You take a –4 status penalty to AC, Perception, and Reflex saves, and you have the blinded and off-guard conditions. When you gain this condition, you fall prone and drop items you are wielding or holding unless the effect states otherwise or the GM determines you're in a position in which you wouldn't.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Undetected",
|
||||
"name_german": "Unentdeckt",
|
||||
"description": "When you are undetected by a creature, that creature cannot see you at all, has no idea what space you occupy, and can't target you, though the creature can still affect you with area effects. When you're undetected by a creature, that creature is off-guard to you. A creature you're undetected by can guess which square you're in to try targeting you. It must pick a square and attempt an attack. This works like targeting a hidden creature, but the flat check and attack roll are both rolled in secret by the GM. The GM won't tell you what the result was. If a creature is entirely unaware you're there, you're unnoticed by that creature instead of undetected.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Unfriendly",
|
||||
"name_german": "Unfreundlich",
|
||||
"description": "This condition reflects a creature's disposition toward a particular character, and it affects only creatures that are not player characters. A creature that is unfriendly to a character dislikes and specifically distrusts that character. The creature becomes hostile if it's attacked by that character or witnesses the character attacking someone unless the creature is already more threatened by a different enemy. It's hard to convince an unfriendly creature to do anything on your behalf with a Request.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Unnoticed",
|
||||
"name_german": "Unbemerkt",
|
||||
"description": "If you are unnoticed by a creature, that creature has no idea you are there at all. When unnoticed, you're also undetected by the creature. This condition matters for abilities that can be used only against targets totally unaware of your presence.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
},
|
||||
{
|
||||
"name": "Wounded",
|
||||
"name_german": "Verwundet",
|
||||
"description": "You have been seriously injured. If you lose the dying condition and are still at 0 Hit Points, you become wounded 1. If you already have the wounded condition when you lose the dying condition, your wounded condition value increases by 1. The wounded condition ends if someone successfully restores Hit Points to you with a single effect, and you become fully healed; or if you rest for 10 minutes and have at least 1 Hit Point at the end of the rest.",
|
||||
"traits": [],
|
||||
"source": "Player Core pg. 445"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -97,29 +97,29 @@ export function CampaignDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/')}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/')} className="flex-shrink-0">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{campaign.name}</h1>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-text-primary truncate">{campaign.name}</h1>
|
||||
{campaign.description && (
|
||||
<p className="text-text-secondary mt-1">{campaign.description}</p>
|
||||
<p className="text-text-secondary mt-1 text-sm sm:text-base line-clamp-2">{campaign.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-text-secondary">
|
||||
<Crown className="h-4 w-4 text-primary-500" />
|
||||
<span>GM: {campaign.gm.username}</span>
|
||||
<Crown className="h-4 w-4 text-primary-500 flex-shrink-0" />
|
||||
<span className="truncate">GM: {campaign.gm.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 ml-11 sm:ml-0 sm:justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEditCampaign(true)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Bearbeiten
|
||||
<span className="hidden sm:inline ml-1">Bearbeiten</span>
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDeleteCampaign}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -139,56 +139,57 @@ export function CampaignDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 w-full">
|
||||
{/* Members Section */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Mitglieder ({campaign.members.length})
|
||||
</CardTitle>
|
||||
{canManage && (
|
||||
<Button size="sm" onClick={() => setShowAddMember(true)}>
|
||||
<Button size="sm" onClick={() => setShowAddMember(true)} className="w-full sm:w-auto">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{campaign.members.map((member) => (
|
||||
<div
|
||||
key={member.userId}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary"
|
||||
className="flex items-center justify-between gap-2 p-2 sm:p-3 rounded-lg bg-bg-tertiary overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1 overflow-hidden">
|
||||
<div className="h-8 w-8 sm:h-10 sm:w-10 rounded-full bg-primary-500/20 flex items-center justify-center flex-shrink-0">
|
||||
{member.user.avatarUrl ? (
|
||||
<img
|
||||
src={member.user.avatarUrl}
|
||||
alt={member.user.username}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
className="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-primary-500 font-medium">
|
||||
<span className="text-primary-500 font-medium text-sm">
|
||||
{member.user.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary flex items-center gap-2">
|
||||
{member.user.username}
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<p className="font-medium text-text-primary flex items-center gap-1 sm:gap-2 text-sm sm:text-base">
|
||||
<span className="truncate">{member.user.username}</span>
|
||||
{member.userId === campaign.gmId && (
|
||||
<Crown className="h-4 w-4 text-primary-500" />
|
||||
<Crown className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-primary-500 flex-shrink-0" />
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">{member.user.email}</p>
|
||||
<p className="text-xs sm:text-sm text-text-secondary truncate">{member.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && member.userId !== campaign.gmId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-8 w-8"
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-text-secondary hover:text-red-500" />
|
||||
@@ -202,19 +203,19 @@ export function CampaignDetailPage() {
|
||||
|
||||
{/* Characters Section */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Swords className="h-5 w-5" />
|
||||
Charaktere ({campaign.characters?.length || 0})
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid grid-cols-2 sm:flex sm:items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setShowImportCharacter(true)}>
|
||||
<FileJson className="h-4 w-4" />
|
||||
Import
|
||||
<span className="ml-1">Import</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowCreateCharacter(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Neuer Charakter
|
||||
<span className="ml-1">Neu</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -225,35 +226,35 @@ export function CampaignDetailPage() {
|
||||
<p className="text-text-secondary">Noch keine Charaktere</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{campaign.characters.map((character: CharacterSummary) => (
|
||||
<div
|
||||
key={character.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary cursor-pointer transition-colors"
|
||||
className="flex items-center justify-between gap-2 p-2 sm:p-3 rounded-lg bg-bg-tertiary hover:bg-bg-elevated cursor-pointer transition-colors overflow-hidden"
|
||||
onClick={() => navigate(`/campaigns/${id}/characters/${character.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1 overflow-hidden">
|
||||
<div className="h-8 w-8 sm:h-10 sm:w-10 rounded-full bg-primary-500/20 flex items-center justify-center flex-shrink-0">
|
||||
{character.avatarUrl ? (
|
||||
<img
|
||||
src={character.avatarUrl}
|
||||
alt={character.name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
className="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-primary-500" />
|
||||
<Shield className="h-4 w-4 sm:h-5 sm:w-5 text-primary-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">{character.name}</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Level {character.level} {character.type}
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<p className="font-medium text-text-primary truncate text-sm sm:text-base">{character.name}</p>
|
||||
<p className="text-xs sm:text-sm text-text-secondary truncate">
|
||||
Lv. {character.level} {character.type}
|
||||
{character.owner && ` • ${character.owner.username}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Heart className="h-4 w-4 text-red-500" />
|
||||
<div className="flex items-center gap-1 text-xs sm:text-sm flex-shrink-0">
|
||||
<Heart className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-red-500" />
|
||||
<span className={character.hpCurrent < character.hpMax / 2 ? 'text-red-500' : 'text-text-secondary'}>
|
||||
{character.hpCurrent}/{character.hpMax}
|
||||
</span>
|
||||
@@ -267,13 +268,13 @@ export function CampaignDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors" onClick={() => navigate(`/campaigns/${id}/battle`)}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors active:scale-[0.98]" onClick={() => navigate(`/campaigns/${id}/battle`)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Swords className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text-primary">Kampfbildschirm</p>
|
||||
<p className="text-sm text-text-secondary">Kämpfe verwalten</p>
|
||||
</div>
|
||||
@@ -281,10 +282,10 @@ export function CampaignDetailPage() {
|
||||
</Card>
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text-primary">Dokumente</p>
|
||||
<p className="text-sm text-text-secondary">Bald verfügbar</p>
|
||||
</div>
|
||||
@@ -292,10 +293,10 @@ export function CampaignDetailPage() {
|
||||
</Card>
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text-primary">Notizen</p>
|
||||
<p className="text-sm text-text-secondary">Bald verfügbar</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { X, Search, AlertCircle } from 'lucide-react';
|
||||
import { Button, Input } from '@/shared/components/ui';
|
||||
|
||||
interface Condition {
|
||||
name: string;
|
||||
name_german: string;
|
||||
description: string;
|
||||
traits: string[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface ConditionsData {
|
||||
conditions: Condition[];
|
||||
}
|
||||
|
||||
// Conditions that have a value/level
|
||||
const VALUE_CONDITIONS = [
|
||||
'Clumsy',
|
||||
'Doomed',
|
||||
'Drained',
|
||||
'Dying',
|
||||
'Enfeebled',
|
||||
'Frightened',
|
||||
'Sickened',
|
||||
'Slowed',
|
||||
'Stunned',
|
||||
'Stupefied',
|
||||
'Wounded',
|
||||
'Persistent Damage',
|
||||
];
|
||||
|
||||
interface AddConditionModalProps {
|
||||
onClose: () => void;
|
||||
onAdd: (condition: { name: string; nameGerman: string; value?: number }) => Promise<void>;
|
||||
existingConditions: string[];
|
||||
}
|
||||
|
||||
export function AddConditionModal({ onClose, onAdd, existingConditions }: AddConditionModalProps) {
|
||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCondition, setSelectedCondition] = useState<Condition | null>(null);
|
||||
const [value, setValue] = useState<number>(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Load conditions from JSON
|
||||
useEffect(() => {
|
||||
fetch('/pathfinder_conditions.json')
|
||||
.then((res) => res.json())
|
||||
.then((data: ConditionsData) => {
|
||||
setConditions(data.conditions);
|
||||
})
|
||||
.catch((err) => console.error('Failed to load conditions:', err));
|
||||
}, []);
|
||||
|
||||
// Filter conditions based on search
|
||||
const filteredConditions = useMemo(() => {
|
||||
if (!searchQuery.trim()) return conditions;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return conditions.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.name_german.toLowerCase().includes(query)
|
||||
);
|
||||
}, [conditions, searchQuery]);
|
||||
|
||||
// Check if condition needs a value
|
||||
const needsValue = selectedCondition && VALUE_CONDITIONS.includes(selectedCondition.name);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedCondition) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onAdd({
|
||||
name: selectedCondition.name,
|
||||
nameGerman: selectedCondition.name_german,
|
||||
value: needsValue ? value : undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add condition:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAlreadyApplied = (conditionName: string) =>
|
||||
existingConditions.some((c) => c.toLowerCase() === conditionName.toLowerCase());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full sm:max-w-lg max-h-[85vh] bg-bg-secondary rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Zustand hinzufügen</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-secondary" />
|
||||
<Input
|
||||
placeholder="Zustand suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{selectedCondition ? (
|
||||
// Selected condition detail view
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedCondition(null)}
|
||||
className="text-sm text-primary-500 hover:text-primary-400"
|
||||
>
|
||||
← Zurück zur Liste
|
||||
</button>
|
||||
|
||||
<div className="p-4 rounded-xl bg-bg-tertiary">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">
|
||||
{selectedCondition.name_german}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">{selectedCondition.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-text-secondary leading-relaxed">
|
||||
{selectedCondition.description}
|
||||
</p>
|
||||
{selectedCondition.traits.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{selectedCondition.traits.map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="px-2 py-0.5 text-xs rounded bg-primary-500/20 text-primary-400"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value input for conditions with levels */}
|
||||
{needsValue && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-primary">
|
||||
Stufe / Wert
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setValue((v) => Math.max(1, v - 1))}
|
||||
disabled={value <= 1}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<span className="text-2xl font-bold text-text-primary w-12 text-center">
|
||||
{value}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setValue((v) => Math.min(10, v + 1))}
|
||||
disabled={value >= 10}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full h-12"
|
||||
onClick={handleAdd}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Wird hinzugefügt...' : `${selectedCondition.name_german} hinzufügen`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Condition list
|
||||
<div className="space-y-2">
|
||||
{filteredConditions.length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-8">
|
||||
Keine Zustände gefunden
|
||||
</p>
|
||||
) : (
|
||||
filteredConditions.map((condition) => {
|
||||
const applied = isAlreadyApplied(condition.name);
|
||||
return (
|
||||
<button
|
||||
key={condition.name}
|
||||
onClick={() => !applied && setSelectedCondition(condition)}
|
||||
disabled={applied}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||
applied
|
||||
? 'bg-bg-tertiary/50 opacity-50 cursor-not-allowed'
|
||||
: 'bg-bg-tertiary hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">
|
||||
{condition.name_german}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">{condition.name}</p>
|
||||
</div>
|
||||
{applied && (
|
||||
<span className="text-xs text-text-muted">Bereits aktiv</span>
|
||||
)}
|
||||
{VALUE_CONDITIONS.includes(condition.name) && !applied && (
|
||||
<span className="text-xs text-primary-400">Hat Stufe</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
client/src/features/characters/components/add-item-modal.tsx
Normal file
373
client/src/features/characters/components/add-item-modal.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Search, Package, Swords, Shield, FlaskConical, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button, Input, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { Equipment, EquipmentSearchResult } from '@/shared/types';
|
||||
|
||||
interface AddItemModalProps {
|
||||
onClose: () => void;
|
||||
onAdd: (item: {
|
||||
equipmentId: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
quantity: number;
|
||||
bulk: number;
|
||||
equipped: boolean;
|
||||
}) => Promise<void>;
|
||||
existingItemNames: string[];
|
||||
}
|
||||
|
||||
type CategoryFilter = 'all' | 'Weapons' | 'Armor' | 'Consumables' | 'Equipment';
|
||||
|
||||
const CATEGORY_ICONS: Record<CategoryFilter, React.ReactNode> = {
|
||||
all: <Package className="h-4 w-4" />,
|
||||
Weapons: <Swords className="h-4 w-4" />,
|
||||
Armor: <Shield className="h-4 w-4" />,
|
||||
Consumables: <FlaskConical className="h-4 w-4" />,
|
||||
Equipment: <Package className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<CategoryFilter, string> = {
|
||||
all: 'Alle',
|
||||
Weapons: 'Waffen',
|
||||
Armor: 'Rüstung',
|
||||
Consumables: 'Verbrauchsgüter',
|
||||
Equipment: 'Ausrüstung',
|
||||
};
|
||||
|
||||
function parseBulk(bulkStr?: string): number {
|
||||
if (!bulkStr || bulkStr === '-' || bulkStr === '') return 0;
|
||||
if (bulkStr === 'L') return 0.1;
|
||||
const num = parseFloat(bulkStr);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
export function AddItemModal({ onClose, onAdd, existingItemNames }: AddItemModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [category, setCategory] = useState<CategoryFilter>('all');
|
||||
const [searchResult, setSearchResult] = useState<EquipmentSearchResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<Equipment | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Search equipment
|
||||
useEffect(() => {
|
||||
const searchEquipment = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await api.searchEquipment({
|
||||
query: searchQuery || undefined,
|
||||
category: category !== 'all' ? category : undefined,
|
||||
page,
|
||||
limit: 30,
|
||||
});
|
||||
setSearchResult(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to search equipment:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(searchEquipment, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [searchQuery, category, page]);
|
||||
|
||||
// Reset page when search/category changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchQuery, category]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedItem) return;
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await onAdd({
|
||||
equipmentId: selectedItem.id,
|
||||
name: selectedItem.name,
|
||||
quantity,
|
||||
bulk: parseBulk(selectedItem.bulk),
|
||||
equipped: false,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add item:', error);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAlreadyOwned = (itemName: string) =>
|
||||
existingItemNames.some((n) => n.toLowerCase() === itemName.toLowerCase());
|
||||
|
||||
const getCategoryColor = (itemCategory: string) => {
|
||||
switch (itemCategory) {
|
||||
case 'Weapons':
|
||||
return 'text-red-400';
|
||||
case 'Armor':
|
||||
return 'text-blue-400';
|
||||
case 'Consumables':
|
||||
return 'text-green-400';
|
||||
default:
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full sm:max-w-2xl max-h-[90vh] bg-bg-secondary rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{selectedItem ? 'Gegenstand hinzufügen' : 'Gegenstand suchen'}
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedItem ? (
|
||||
// Selected item detail view
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="text-sm text-primary-500 hover:text-primary-400"
|
||||
>
|
||||
← Zurück zur Suche
|
||||
</button>
|
||||
|
||||
<div className="p-4 rounded-xl bg-bg-tertiary">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary text-lg">
|
||||
{selectedItem.name}
|
||||
</h3>
|
||||
<p className={`text-sm ${getCategoryColor(selectedItem.itemCategory)}`}>
|
||||
{selectedItem.itemCategory}
|
||||
{selectedItem.itemSubcategory && ` • ${selectedItem.itemSubcategory}`}
|
||||
</p>
|
||||
</div>
|
||||
{selectedItem.level !== undefined && selectedItem.level !== null && (
|
||||
<span className="px-2 py-1 text-xs rounded bg-primary-500/20 text-primary-400">
|
||||
Stufe {selectedItem.level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Item Stats */}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-sm">
|
||||
{selectedItem.bulk && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Gewicht:</span>{' '}
|
||||
<span className="text-text-primary">{selectedItem.bulk === 'L' ? 'Leicht' : selectedItem.bulk}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.damage && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Schaden:</span>{' '}
|
||||
<span className="text-text-primary">{selectedItem.damage}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.hands && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Hände:</span>{' '}
|
||||
<span className="text-text-primary">{selectedItem.hands}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.ac !== undefined && selectedItem.ac !== null && (
|
||||
<div>
|
||||
<span className="text-text-secondary">RK:</span>{' '}
|
||||
<span className="text-text-primary">+{selectedItem.ac}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.weaponCategory && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Kategorie:</span>{' '}
|
||||
<span className="text-text-primary">{selectedItem.weaponCategory}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Traits */}
|
||||
{selectedItem.traits.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{selectedItem.traits.map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="px-2 py-0.5 text-xs rounded bg-primary-500/20 text-primary-400"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{selectedItem.summary && (
|
||||
<p className="mt-3 text-sm text-text-secondary leading-relaxed">
|
||||
{selectedItem.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-primary">Anzahl</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<span className="text-2xl font-bold text-text-primary w-12 text-center">
|
||||
{quantity}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setQuantity((q) => Math.min(99, q + 1))}
|
||||
disabled={quantity >= 99}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-12"
|
||||
onClick={handleAdd}
|
||||
disabled={isAdding}
|
||||
>
|
||||
{isAdding ? 'Wird hinzugefügt...' : `${selectedItem.name} hinzufügen`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Search view
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-secondary" />
|
||||
<Input
|
||||
placeholder="Nach Gegenstand suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
{(Object.keys(CATEGORY_LABELS) as CategoryFilter[]).map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategory(cat)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
category === cat
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-bg-tertiary text-text-secondary hover:bg-bg-elevated hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_ICONS[cat]}
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : !searchResult?.items.length ? (
|
||||
<p className="text-center text-text-secondary py-8">
|
||||
Keine Gegenstände gefunden
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{searchResult.items.map((item) => {
|
||||
const owned = isAlreadyOwned(item.name);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => !owned && setSelectedItem(item)}
|
||||
disabled={owned}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||
owned
|
||||
? 'bg-bg-tertiary/50 opacity-50 cursor-not-allowed'
|
||||
: 'bg-bg-tertiary hover:bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text-primary truncate">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className={`text-xs ${getCategoryColor(item.itemCategory)}`}>
|
||||
{item.itemCategory}
|
||||
{item.bulk && ` • ${item.bulk === 'L' ? 'Leicht' : `${item.bulk} Bulk`}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{item.level !== undefined && item.level !== null && (
|
||||
<span className="text-xs text-text-muted">Lv. {item.level}</span>
|
||||
)}
|
||||
{owned && (
|
||||
<span className="text-xs text-text-muted">Bereits im Inventar</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{searchResult && searchResult.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between p-4 border-t border-border">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{searchResult.total} Ergebnisse
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-text-primary">
|
||||
{page} / {searchResult.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(searchResult.totalPages, p + 1))}
|
||||
disabled={page >= searchResult.totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,10 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Heart,
|
||||
Shield,
|
||||
Zap,
|
||||
Swords,
|
||||
BookOpen,
|
||||
Package,
|
||||
AlertCircle,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Plus,
|
||||
@@ -25,16 +22,19 @@ import {
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Spinner,
|
||||
Input,
|
||||
} from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import { HpControl } from './hp-control';
|
||||
import { AddConditionModal } from './add-condition-modal';
|
||||
import { AddItemModal } from './add-item-modal';
|
||||
import type { Character } from '@/shared/types';
|
||||
|
||||
type TabType = 'status' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
|
||||
type TabType = 'status' | 'skills' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
|
||||
|
||||
const TABS: { id: TabType; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'status', label: 'Status', icon: <User className="h-4 w-4" /> },
|
||||
{ id: 'skills', label: 'Fertigkeiten', icon: <BookOpen className="h-4 w-4" /> },
|
||||
{ id: 'inventory', label: 'Inventar', icon: <Package className="h-4 w-4" /> },
|
||||
{ id: 'feats', label: 'Talente', icon: <Star className="h-4 w-4" /> },
|
||||
{ id: 'spells', label: 'Zauber', icon: <Sparkles className="h-4 w-4" /> },
|
||||
@@ -67,6 +67,44 @@ const PROFICIENCY_COLORS: Record<string, string> = {
|
||||
LEGENDARY: 'text-red-500',
|
||||
};
|
||||
|
||||
// Proficiency bonus values (added to level for trained+)
|
||||
const PROFICIENCY_BONUS: Record<string, number> = {
|
||||
UNTRAINED: 0,
|
||||
TRAINED: 2,
|
||||
EXPERT: 4,
|
||||
MASTER: 6,
|
||||
LEGENDARY: 8,
|
||||
};
|
||||
|
||||
// Skill translations and linked abilities
|
||||
const SKILL_DATA: Record<string, { german: string; ability: 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA' }> = {
|
||||
'Acrobatics': { german: 'Akrobatik', ability: 'DEX' },
|
||||
'Arcana': { german: 'Arkane Künste', ability: 'INT' },
|
||||
'Athletics': { german: 'Athletik', ability: 'STR' },
|
||||
'Crafting': { german: 'Handwerkskunst', ability: 'INT' },
|
||||
'Deception': { german: 'Täuschung', ability: 'CHA' },
|
||||
'Diplomacy': { german: 'Diplomatie', ability: 'CHA' },
|
||||
'Intimidation': { german: 'Einschüchtern', ability: 'CHA' },
|
||||
'Medicine': { german: 'Medizin', ability: 'WIS' },
|
||||
'Nature': { german: 'Naturkunde', ability: 'WIS' },
|
||||
'Occultism': { german: 'Okkultismus', ability: 'INT' },
|
||||
'Performance': { german: 'Darbietung', ability: 'CHA' },
|
||||
'Religion': { german: 'Religionskunde', ability: 'WIS' },
|
||||
'Society': { german: 'Gesellschaftskunde', ability: 'INT' },
|
||||
'Stealth': { german: 'Heimlichkeit', ability: 'DEX' },
|
||||
'Survival': { german: 'Überleben', ability: 'WIS' },
|
||||
'Thievery': { german: 'Diebeskunst', ability: 'DEX' },
|
||||
};
|
||||
|
||||
// Short proficiency indicators
|
||||
const PROFICIENCY_SHORT: Record<string, string> = {
|
||||
UNTRAINED: 'U',
|
||||
TRAINED: 'G',
|
||||
EXPERT: 'E',
|
||||
MASTER: 'M',
|
||||
LEGENDARY: 'L',
|
||||
};
|
||||
|
||||
export function CharacterSheetPage() {
|
||||
const { id: campaignId, characterId } = useParams<{ id: string; characterId: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -75,7 +113,8 @@ export function CharacterSheetPage() {
|
||||
const [character, setCharacter] = useState<Character | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('status');
|
||||
const [hpEdit, setHpEdit] = useState<number | null>(null);
|
||||
const [showAddCondition, setShowAddCondition] = useState(false);
|
||||
const [showAddItem, setShowAddItem] = useState(false);
|
||||
|
||||
const isOwner = character?.ownerId === user?.id;
|
||||
|
||||
@@ -96,27 +135,11 @@ export function CharacterSheetPage() {
|
||||
fetchCharacter();
|
||||
}, [campaignId, characterId]);
|
||||
|
||||
const handleHpChange = async (delta: number) => {
|
||||
const handleHpChange = async (newHp: number) => {
|
||||
if (!character || !campaignId) return;
|
||||
const newHp = Math.max(0, Math.min(character.hpMax, character.hpCurrent + delta));
|
||||
try {
|
||||
await api.updateCharacterHp(campaignId, character.id, newHp);
|
||||
setCharacter({ ...character, hpCurrent: newHp });
|
||||
} catch (error) {
|
||||
console.error('Failed to update HP:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHpSet = async () => {
|
||||
if (hpEdit === null || !character || !campaignId) return;
|
||||
const newHp = Math.max(0, Math.min(character.hpMax, hpEdit));
|
||||
try {
|
||||
await api.updateCharacterHp(campaignId, character.id, newHp);
|
||||
setCharacter({ ...character, hpCurrent: newHp });
|
||||
setHpEdit(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update HP:', error);
|
||||
}
|
||||
const clampedHp = Math.max(0, Math.min(character.hpMax, newHp));
|
||||
await api.updateCharacterHp(campaignId, character.id, clampedHp);
|
||||
setCharacter({ ...character, hpCurrent: clampedHp });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -142,9 +165,57 @@ export function CharacterSheetPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getAbilityModifier = (score: number) => {
|
||||
const mod = Math.floor((score - 10) / 2);
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
const handleAddCondition = async (condition: { name: string; nameGerman: string; value?: number }) => {
|
||||
if (!character || !campaignId) return;
|
||||
const newCondition = await api.addCharacterCondition(campaignId, character.id, condition);
|
||||
setCharacter({
|
||||
...character,
|
||||
conditions: [...character.conditions, newCondition],
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddItem = async (item: {
|
||||
equipmentId: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
quantity: number;
|
||||
bulk: number;
|
||||
equipped: boolean;
|
||||
}) => {
|
||||
if (!character || !campaignId) return;
|
||||
const newItem = await api.addCharacterItem(campaignId, character.id, item);
|
||||
setCharacter({
|
||||
...character,
|
||||
items: [...character.items, newItem],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveItem = async (itemId: string) => {
|
||||
if (!character || !campaignId) return;
|
||||
try {
|
||||
await api.removeCharacterItem(campaignId, character.id, itemId);
|
||||
setCharacter({
|
||||
...character,
|
||||
items: character.items.filter((i) => i.id !== itemId),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to remove item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEquipped = async (itemId: string, equipped: boolean) => {
|
||||
if (!character || !campaignId) return;
|
||||
try {
|
||||
await api.updateCharacterItem(campaignId, character.id, itemId, { equipped });
|
||||
setCharacter({
|
||||
...character,
|
||||
items: character.items.map((i) =>
|
||||
i.id === itemId ? { ...i, equipped } : i
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle equipped:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -163,114 +234,128 @@ export function CharacterSheetPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hpPercentage = (character.hpCurrent / character.hpMax) * 100;
|
||||
|
||||
// Tab Content Renderers
|
||||
const renderStatusTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* HP Bar */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Heart className="h-6 w-6 text-red-500" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-text-primary">Trefferpunkte</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{hpEdit !== null ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
value={hpEdit}
|
||||
onChange={(e) => setHpEdit(parseInt(e.target.value) || 0)}
|
||||
className="w-20 h-8 text-center"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleHpSet()}
|
||||
{/* Mobile-optimized HP Control */}
|
||||
<HpControl
|
||||
hpCurrent={character.hpCurrent}
|
||||
hpMax={character.hpMax}
|
||||
hpTemp={character.hpTemp}
|
||||
onHpChange={handleHpChange}
|
||||
/>
|
||||
<Button size="sm" onClick={handleHpSet}>OK</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHpEdit(null)}>X</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="icon" variant="ghost" onClick={() => handleHpChange(-1)}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span
|
||||
className={`font-bold cursor-pointer ${hpPercentage < 25 ? 'text-red-500' : hpPercentage < 50 ? 'text-yellow-500' : 'text-text-primary'}`}
|
||||
onClick={() => setHpEdit(character.hpCurrent)}
|
||||
>
|
||||
{character.hpCurrent} / {character.hpMax}
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" onClick={() => handleHpChange(1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{character.hpTemp > 0 && (
|
||||
<span className="text-sm text-blue-500">(+{character.hpTemp} temp)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${hpPercentage < 25 ? 'bg-red-500' : hpPercentage < 50 ? 'bg-yellow-500' : 'bg-green-500'}`}
|
||||
style={{ width: `${hpPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Abilities */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||
{character.abilities.map((ability) => {
|
||||
const mod = Math.floor((ability.score - 10) / 2);
|
||||
const isPositive = mod >= 0;
|
||||
return (
|
||||
<div
|
||||
key={ability.ability}
|
||||
className="relative text-center p-3 rounded-xl bg-bg-secondary border border-border hover:border-primary-500/50 transition-colors"
|
||||
>
|
||||
{/* Ability Name Badge */}
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded bg-bg-tertiary border border-border text-[10px] font-semibold text-text-secondary uppercase tracking-wider">
|
||||
{ability.ability}
|
||||
</div>
|
||||
{/* Modifier */}
|
||||
<p className={`text-2xl font-bold mt-2 ${isPositive ? 'text-text-primary' : 'text-red-400'}`}>
|
||||
{isPositive ? `+${mod}` : mod}
|
||||
</p>
|
||||
{/* Score */}
|
||||
<p className="text-xs text-text-muted mt-1">{ability.score}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Saving Throws */}
|
||||
{(() => {
|
||||
const getAbilityMod = (abilityType: 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA') => {
|
||||
const ability = character.abilities.find(a => a.ability === abilityType);
|
||||
return ability ? Math.floor((ability.score - 10) / 2) : 0;
|
||||
};
|
||||
|
||||
// TODO: Get actual proficiency from Pathbuilder data when available
|
||||
// For now, assume trained (+2) as baseline for all saves
|
||||
const baseProficiency = 2;
|
||||
const level = character.level;
|
||||
|
||||
const saves = [
|
||||
{
|
||||
name: 'Zähigkeit',
|
||||
shortName: 'ZÄH',
|
||||
ability: 'CON' as const,
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-500/20',
|
||||
},
|
||||
{
|
||||
name: 'Reflex',
|
||||
shortName: 'REF',
|
||||
ability: 'DEX' as const,
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-500/20',
|
||||
},
|
||||
{
|
||||
name: 'Willen',
|
||||
shortName: 'WIL',
|
||||
ability: 'WIS' as const,
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Attribute
|
||||
<Shield className="h-5 w-5" />
|
||||
Rettungswürfe
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||
{character.abilities.map((ability) => (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{saves.map((save) => {
|
||||
const abilityMod = getAbilityMod(save.ability);
|
||||
const total = abilityMod + level + baseProficiency;
|
||||
const bonusString = total >= 0 ? `+${total}` : `${total}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ability.ability}
|
||||
className="text-center p-3 rounded-lg bg-bg-secondary"
|
||||
key={save.name}
|
||||
className={`text-center p-3 rounded-lg ${save.bgColor}`}
|
||||
>
|
||||
<p className="text-xs text-text-secondary mb-1">
|
||||
{ABILITY_NAMES[ability.ability]}
|
||||
<p className={`text-xs font-medium mb-1 ${save.color}`}>
|
||||
{save.name}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-text-primary">
|
||||
{getAbilityModifier(ability.score)}
|
||||
<p className={`text-2xl font-bold ${save.color}`}>
|
||||
{bonusString}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{ABILITY_NAMES[save.ability].slice(0, 3).toUpperCase()}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">{ability.score}</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Conditions */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Zustände ({character.conditions.length})
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Conditions - Compact inline display */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-text-secondary">Zustände:</span>
|
||||
{character.conditions.length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine aktiven Zustände</p>
|
||||
<span className="text-xs text-text-muted">Keine</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{character.conditions.map((condition) => (
|
||||
character.conditions.map((condition) => (
|
||||
<div
|
||||
key={condition.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-500/20 text-red-400"
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-red-500/20 text-red-400 text-xs"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
<span className="font-medium">
|
||||
{condition.nameGerman || condition.name}
|
||||
{condition.value && ` ${condition.value}`}
|
||||
</span>
|
||||
@@ -281,116 +366,251 @@ export function CharacterSheetPage() {
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-text-secondary hover:text-text-primary"
|
||||
onClick={() => setShowAddCondition(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Skills */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Fertigkeiten ({character.skills.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
const renderSkillsTab = () => {
|
||||
// Filter out saves and perception (these are displayed separately)
|
||||
const EXCLUDED_SKILLS = ['Fortitude', 'Reflex', 'Will', 'Perception'];
|
||||
const filteredSkills = character.skills.filter(
|
||||
(skill) => !EXCLUDED_SKILLS.includes(skill.skillName)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Skills Grid */}
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
{character.skills.map((skill) => (
|
||||
{filteredSkills.map((skill) => {
|
||||
// Get skill data (German name + linked ability)
|
||||
const skillData = SKILL_DATA[skill.skillName];
|
||||
const germanName = skillData?.german || skill.skillName;
|
||||
const linkedAbility = skillData?.ability || 'INT';
|
||||
|
||||
// Get ability modifier
|
||||
const abilityScore = character.abilities.find(a => a.ability === linkedAbility)?.score || 10;
|
||||
const abilityMod = Math.floor((abilityScore - 10) / 2);
|
||||
|
||||
// Calculate total bonus
|
||||
const profBonus = PROFICIENCY_BONUS[skill.proficiency] || 0;
|
||||
const levelBonus = skill.proficiency !== 'UNTRAINED' ? character.level : 0;
|
||||
const totalBonus = abilityMod + levelBonus + profBonus;
|
||||
const bonusString = totalBonus >= 0 ? `+${totalBonus}` : `${totalBonus}`;
|
||||
|
||||
// Check if it's a Lore skill
|
||||
const isLore = skill.skillName.toLowerCase().includes('lore');
|
||||
const displayName = isLore ? skill.skillName.replace(' Lore', '') + ' (Wissen)' : germanName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.skillName}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bg-secondary"
|
||||
className="flex items-center justify-between py-2.5 px-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary"
|
||||
>
|
||||
<span className="text-text-primary">{skill.skillName}</span>
|
||||
<span className={`text-sm font-medium ${PROFICIENCY_COLORS[skill.proficiency]}`}>
|
||||
{PROFICIENCY_NAMES[skill.proficiency]}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span
|
||||
className={`text-xs font-bold w-5 h-5 rounded flex items-center justify-center flex-shrink-0 ${PROFICIENCY_COLORS[skill.proficiency]} bg-current/10`}
|
||||
title={PROFICIENCY_NAMES[skill.proficiency]}
|
||||
>
|
||||
{PROFICIENCY_SHORT[skill.proficiency]}
|
||||
</span>
|
||||
<span className="text-text-primary text-sm truncate">{displayName}</span>
|
||||
</div>
|
||||
<span className={`font-bold text-lg tabular-nums ${totalBonus >= 0 ? 'text-text-primary' : 'text-red-400'}`}>
|
||||
{bonusString}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-text-secondary pt-2 border-t border-border">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-text-secondary font-bold">U</span> Ungeübt
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-blue-500 font-bold">G</span> Geübt
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-purple-500 font-bold">E</span> Experte
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-orange-500 font-bold">M</span> Meister
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-red-500 font-bold">L</span> Legendär
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderInventoryTab = () => {
|
||||
// Calculate total bulk
|
||||
const totalBulk = character.items.reduce((sum, item) => {
|
||||
const itemBulk = typeof item.bulk === 'number' ? item.bulk : 0;
|
||||
return sum + itemBulk * item.quantity;
|
||||
}, 0);
|
||||
|
||||
// STR modifier for bulk limit
|
||||
const strScore = character.abilities.find((a) => a.ability === 'STR')?.score || 10;
|
||||
const strMod = Math.floor((strScore - 10) / 2);
|
||||
const bulkLimit = 5 + strMod;
|
||||
const encumberedLimit = bulkLimit + 5;
|
||||
|
||||
const isEncumbered = totalBulk > bulkLimit;
|
||||
const isOverburdened = totalBulk > encumberedLimit;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Bulk Tracker */}
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary">Traglast</span>
|
||||
<span className={`text-sm font-bold ${isOverburdened ? 'text-red-400' : isEncumbered ? 'text-yellow-400' : 'text-text-primary'}`}>
|
||||
{totalBulk.toFixed(1)} / {bulkLimit} Bulk
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
isOverburdened ? 'bg-red-500' : isEncumbered ? 'bg-yellow-500' : 'bg-primary-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (totalBulk / encumberedLimit) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{(isEncumbered || isOverburdened) && (
|
||||
<p className={`text-xs mt-1 ${isOverburdened ? 'text-red-400' : 'text-yellow-400'}`}>
|
||||
{isOverburdened ? 'Überlastet' : 'Belastet'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Item Button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => setShowAddItem(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Gegenstand hinzufügen
|
||||
</Button>
|
||||
|
||||
{/* Equipped Items */}
|
||||
{character.items.filter((i) => i.equipped).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-text-secondary flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Ausgerüstet
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{character.items
|
||||
.filter((i) => i.equipped)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 rounded-lg bg-bg-secondary border border-primary-500/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text-primary truncate">
|
||||
{item.nameGerman || item.name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{item.bulk > 0 ? (item.bulk < 1 ? 'L' : `${item.bulk} Bulk`) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handleToggleEquipped(item.id, false)}
|
||||
>
|
||||
Ablegen
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-text-secondary hover:text-red-400"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
|
||||
const renderInventoryTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Equipped Items */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Ausrüstung
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.items.filter(i => i.equipped).length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine ausgerüsteten Gegenstände</p>
|
||||
{/* Inventory Items */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-text-secondary flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Inventar ({character.items.filter((i) => !i.equipped).length})
|
||||
</h3>
|
||||
{character.items.filter((i) => !i.equipped).length === 0 ? (
|
||||
<p className="text-center text-text-muted py-6">Keine Gegenstände im Inventar</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{character.items.filter(i => i.equipped).map((item) => (
|
||||
{character.items
|
||||
.filter((i) => !i.equipped)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 rounded-lg bg-bg-secondary border border-primary-500/50"
|
||||
className="p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-text-primary">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text-primary truncate">
|
||||
{item.nameGerman || item.name}
|
||||
</span>
|
||||
<span className="text-xs bg-primary-500/20 text-primary-500 px-2 py-0.5 rounded">
|
||||
Ausgerüstet
|
||||
</span>
|
||||
</div>
|
||||
{item.notes && (
|
||||
<p className="text-xs text-text-secondary mt-1">{item.notes}</p>
|
||||
{item.quantity > 1 && (
|
||||
<span className="text-text-secondary"> ×{item.quantity}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{item.bulk > 0 ? (item.bulk < 1 ? 'L' : `${item.bulk} Bulk`) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handleToggleEquipped(item.id, true)}
|
||||
>
|
||||
Anlegen
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-text-secondary hover:text-red-400"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* All Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Inventar ({character.items.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.items.length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine Gegenstände</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{character.items.filter(i => !i.equipped).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 rounded-lg bg-bg-secondary"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-text-primary">
|
||||
{item.nameGerman || item.name}
|
||||
{item.quantity > 1 && ` (×${item.quantity})`}
|
||||
</span>
|
||||
</div>
|
||||
{item.notes && (
|
||||
<p className="text-xs text-text-secondary mt-1">{item.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFeatsTab = () => (
|
||||
<div className="space-y-6">
|
||||
@@ -636,6 +856,8 @@ export function CharacterSheetPage() {
|
||||
switch (activeTab) {
|
||||
case 'status':
|
||||
return renderStatusTab();
|
||||
case 'skills':
|
||||
return renderSkillsTab();
|
||||
case 'inventory':
|
||||
return renderInventoryTab();
|
||||
case 'feats':
|
||||
@@ -713,6 +935,22 @@ export function CharacterSheetPage() {
|
||||
<div className="min-h-[400px]">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showAddCondition && (
|
||||
<AddConditionModal
|
||||
onClose={() => setShowAddCondition(false)}
|
||||
onAdd={handleAddCondition}
|
||||
existingConditions={character.conditions.map((c) => c.name)}
|
||||
/>
|
||||
)}
|
||||
{showAddItem && (
|
||||
<AddItemModal
|
||||
onClose={() => setShowAddItem(false)}
|
||||
onAdd={handleAddItem}
|
||||
existingItemNames={character.items.map((i) => i.name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
328
client/src/features/characters/components/hp-control.tsx
Normal file
328
client/src/features/characters/components/hp-control.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState } from 'react';
|
||||
import { Heart, Swords, Sparkles, X } from 'lucide-react';
|
||||
import { Button, Card, CardContent, Input } from '@/shared/components/ui';
|
||||
|
||||
interface HpControlProps {
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
hpTemp?: number;
|
||||
onHpChange: (newHp: number) => Promise<void>;
|
||||
}
|
||||
|
||||
type Mode = 'view' | 'damage' | 'heal' | 'direct';
|
||||
|
||||
export function HpControl({ hpCurrent, hpMax, hpTemp = 0, onHpChange }: HpControlProps) {
|
||||
const [mode, setMode] = useState<Mode>('view');
|
||||
const [pendingChange, setPendingChange] = useState(0);
|
||||
const [directValue, setDirectValue] = useState(hpCurrent);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const hpPercentage = (hpCurrent / hpMax) * 100;
|
||||
|
||||
const getHpColor = () => {
|
||||
if (hpPercentage <= 25) return 'text-red-500';
|
||||
if (hpPercentage <= 50) return 'text-yellow-500';
|
||||
return 'text-green-500';
|
||||
};
|
||||
|
||||
const getBarColor = () => {
|
||||
if (hpPercentage <= 25) return 'bg-red-500';
|
||||
if (hpPercentage <= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const handleQuickChange = (amount: number) => {
|
||||
setPendingChange((prev) => {
|
||||
const newValue = prev + amount;
|
||||
// Prevent going below 0 or above max in damage/heal mode
|
||||
if (mode === 'damage') {
|
||||
return Math.min(newValue, hpCurrent);
|
||||
} else if (mode === 'heal') {
|
||||
return Math.min(newValue, hpMax - hpCurrent);
|
||||
}
|
||||
return Math.max(0, newValue);
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (isUpdating) return;
|
||||
setIsUpdating(true);
|
||||
|
||||
let newHp: number;
|
||||
if (mode === 'damage') {
|
||||
newHp = Math.max(0, hpCurrent - pendingChange);
|
||||
} else if (mode === 'heal') {
|
||||
newHp = Math.min(hpMax, hpCurrent + pendingChange);
|
||||
} else if (mode === 'direct') {
|
||||
newHp = Math.max(0, Math.min(hpMax, directValue));
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onHpChange(newHp);
|
||||
setMode('view');
|
||||
setPendingChange(0);
|
||||
} catch (error) {
|
||||
console.error('Failed to update HP:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setMode('view');
|
||||
setPendingChange(0);
|
||||
setDirectValue(hpCurrent);
|
||||
};
|
||||
|
||||
const openMode = (newMode: Mode) => {
|
||||
setMode(newMode);
|
||||
setPendingChange(0);
|
||||
setDirectValue(hpCurrent);
|
||||
};
|
||||
|
||||
// View Mode - Main Display
|
||||
if (mode === 'view') {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{/* HP Display */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Heart className="h-6 w-6 text-red-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-text-primary">Trefferpunkte</span>
|
||||
<button
|
||||
onClick={() => openMode('direct')}
|
||||
className={`font-bold text-lg ${getHpColor()}`}
|
||||
>
|
||||
{hpCurrent} / {hpMax}
|
||||
{hpTemp > 0 && (
|
||||
<span className="text-sm text-blue-400 ml-1">(+{hpTemp})</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-4 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${getBarColor()}`}
|
||||
style={{ width: `${hpPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-14 text-base font-semibold border-red-500/50 text-red-400 hover:bg-red-500/20 hover:border-red-500"
|
||||
onClick={() => openMode('damage')}
|
||||
>
|
||||
<Swords className="h-5 w-5 mr-2" />
|
||||
Schaden
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-14 text-base font-semibold border-green-500/50 text-green-400 hover:bg-green-500/20 hover:border-green-500"
|
||||
onClick={() => openMode('heal')}
|
||||
>
|
||||
<Sparkles className="h-5 w-5 mr-2" />
|
||||
Heilung
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Direct Edit Mode
|
||||
if (mode === 'direct') {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="h-6 w-6 text-red-500" />
|
||||
<span className="font-medium text-text-primary">HP direkt setzen</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleCancel} className="h-10 w-10">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
value={directValue}
|
||||
onChange={(e) => setDirectValue(parseInt(e.target.value) || 0)}
|
||||
className="w-24 h-14 text-center text-2xl font-bold"
|
||||
min={0}
|
||||
max={hpMax}
|
||||
/>
|
||||
<span className="text-2xl text-text-secondary">/</span>
|
||||
<span className="text-2xl font-bold text-text-primary">{hpMax}</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Set Buttons */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => setDirectValue(0)}
|
||||
>
|
||||
0
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => setDirectValue(Math.floor(hpMax * 0.25))}
|
||||
>
|
||||
25%
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => setDirectValue(Math.floor(hpMax * 0.5))}
|
||||
>
|
||||
50%
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => setDirectValue(hpMax)}
|
||||
>
|
||||
Max
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-14 text-lg font-semibold"
|
||||
onClick={handleApply}
|
||||
disabled={isUpdating || directValue === hpCurrent}
|
||||
>
|
||||
{isUpdating ? 'Speichere...' : 'Übernehmen'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Damage / Heal Mode
|
||||
const isDamage = mode === 'damage';
|
||||
const accentColor = isDamage ? 'red' : 'green';
|
||||
const Icon = isDamage ? Swords : Sparkles;
|
||||
const title = isDamage ? 'Schaden' : 'Heilung';
|
||||
const previewHp = isDamage
|
||||
? Math.max(0, hpCurrent - pendingChange)
|
||||
: Math.min(hpMax, hpCurrent + pendingChange);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-6 w-6 text-${accentColor}-500`} />
|
||||
<span className="font-medium text-text-primary">{title}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleCancel} className="h-10 w-10">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl font-bold text-text-primary mb-1">
|
||||
{isDamage ? '-' : '+'}{pendingChange}
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary">
|
||||
{hpCurrent} → <span className={`font-semibold text-${accentColor}-400`}>{previewHp}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Buttons - Large Touch Targets */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-14 text-lg font-bold border-${accentColor}-500/50 text-${accentColor}-400 hover:bg-${accentColor}-500/20`}
|
||||
onClick={() => handleQuickChange(1)}
|
||||
>
|
||||
+1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-14 text-lg font-bold border-${accentColor}-500/50 text-${accentColor}-400 hover:bg-${accentColor}-500/20`}
|
||||
onClick={() => handleQuickChange(5)}
|
||||
>
|
||||
+5
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-14 text-lg font-bold border-${accentColor}-500/50 text-${accentColor}-400 hover:bg-${accentColor}-500/20`}
|
||||
onClick={() => handleQuickChange(10)}
|
||||
>
|
||||
+10
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-14 text-lg font-bold border-${accentColor}-500/50 text-${accentColor}-400 hover:bg-${accentColor}-500/20`}
|
||||
onClick={() => handleQuickChange(20)}
|
||||
>
|
||||
+20
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Subtract Row */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-12 text-base text-text-secondary"
|
||||
onClick={() => handleQuickChange(-1)}
|
||||
disabled={pendingChange <= 0}
|
||||
>
|
||||
-1
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-12 text-base text-text-secondary"
|
||||
onClick={() => handleQuickChange(-5)}
|
||||
disabled={pendingChange < 5}
|
||||
>
|
||||
-5
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-12 text-base text-text-secondary"
|
||||
onClick={() => handleQuickChange(-10)}
|
||||
disabled={pendingChange < 10}
|
||||
>
|
||||
-10
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-12 text-base"
|
||||
onClick={() => setPendingChange(0)}
|
||||
disabled={pendingChange === 0}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Apply Button */}
|
||||
<Button
|
||||
className={`w-full h-14 text-lg font-semibold ${
|
||||
isDamage
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
onClick={handleApply}
|
||||
disabled={isUpdating || pendingChange === 0}
|
||||
>
|
||||
{isUpdating ? 'Speichere...' : `${pendingChange} ${title} anwenden`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -123,12 +123,20 @@ html {
|
||||
color: var(--color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
#root {
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* Focus Styles */
|
||||
|
||||
@@ -14,7 +14,7 @@ export function Layout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
<div className="min-h-screen bg-bg-primary w-full max-w-full overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-bg-secondary/80 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -71,7 +71,7 @@ export function Layout() {
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<main className="w-full max-w-7xl mx-auto px-4 py-6 sm:py-8 overflow-x-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-bg-secondary shadow-sm',
|
||||
'rounded-xl border border-border bg-bg-secondary shadow-sm overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,7 +19,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
className={cn('flex flex-col space-y-1.5 p-4 sm:p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -50,7 +50,7 @@ CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
<div ref={ref} className={cn('p-4 sm:p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
@@ -195,6 +195,81 @@ class ApiClient {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/conditions/${conditionId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Character Items
|
||||
async addCharacterItem(campaignId: string, characterId: string, data: {
|
||||
equipmentId?: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
quantity?: number;
|
||||
bulk?: number;
|
||||
equipped?: boolean;
|
||||
invested?: boolean;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/items`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateCharacterItem(campaignId: string, characterId: string, itemId: string, data: {
|
||||
quantity?: number;
|
||||
equipped?: boolean;
|
||||
invested?: boolean;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/items/${itemId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async removeCharacterItem(campaignId: string, characterId: string, itemId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/items/${itemId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Equipment Database (Browse/Search)
|
||||
async searchEquipment(params: {
|
||||
query?: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
minLevel?: number;
|
||||
maxLevel?: number;
|
||||
traits?: string[];
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.query) queryParams.set('query', params.query);
|
||||
if (params.category) queryParams.set('category', params.category);
|
||||
if (params.subcategory) queryParams.set('subcategory', params.subcategory);
|
||||
if (params.minLevel !== undefined) queryParams.set('minLevel', params.minLevel.toString());
|
||||
if (params.maxLevel !== undefined) queryParams.set('maxLevel', params.maxLevel.toString());
|
||||
if (params.traits?.length) queryParams.set('traits', params.traits.join(','));
|
||||
if (params.page) queryParams.set('page', params.page.toString());
|
||||
if (params.limit) queryParams.set('limit', params.limit.toString());
|
||||
|
||||
const response = await this.client.get(`/equipment?${queryParams.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEquipmentCategories() {
|
||||
const response = await this.client.get('/equipment/categories');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEquipmentSubcategories(category: string) {
|
||||
const response = await this.client.get(`/equipment/categories/${encodeURIComponent(category)}/subcategories`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEquipmentById(id: string) {
|
||||
const response = await this.client.get(`/equipment/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEquipmentByName(name: string) {
|
||||
const response = await this.client.get(`/equipment/by-name/${encodeURIComponent(name)}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
@@ -230,6 +230,53 @@ export interface Document {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Equipment Database Types
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
traits: string[];
|
||||
itemCategory: string;
|
||||
itemSubcategory?: string;
|
||||
bulk?: string;
|
||||
url?: string;
|
||||
summary?: string;
|
||||
level?: number;
|
||||
price?: number;
|
||||
// Weapon fields
|
||||
hands?: string;
|
||||
damage?: string;
|
||||
damageType?: string;
|
||||
range?: string;
|
||||
reload?: string;
|
||||
weaponCategory?: string;
|
||||
weaponGroup?: string;
|
||||
// Armor fields
|
||||
ac?: number;
|
||||
dexCap?: number;
|
||||
checkPenalty?: number;
|
||||
speedPenalty?: number;
|
||||
strength?: number;
|
||||
armorCategory?: string;
|
||||
armorGroup?: string;
|
||||
// Shield fields
|
||||
shieldHp?: number;
|
||||
shieldHardness?: number;
|
||||
shieldBt?: number;
|
||||
// Consumable fields
|
||||
activation?: string;
|
||||
duration?: string;
|
||||
usage?: string;
|
||||
}
|
||||
|
||||
export interface EquipmentSearchResult {
|
||||
items: Equipment[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiError {
|
||||
statusCode: number;
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: dimension47
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
572
server/package-lock.json
generated
572
server/package-lock.json
generated
@@ -59,6 +59,7 @@
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
}
|
||||
@@ -258,6 +259,7 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -839,7 +841,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@electric-sql/pglite-socket": {
|
||||
"version": "0.0.6",
|
||||
@@ -898,6 +901,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -2279,6 +2724,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
|
||||
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"file-type": "21.3.0",
|
||||
"iterare": "1.2.1",
|
||||
@@ -2338,6 +2784,7 @@
|
||||
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nuxt/opencollective": "0.4.1",
|
||||
"fast-safe-stringify": "2.1.1",
|
||||
@@ -2421,6 +2868,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
|
||||
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"express": "5.2.1",
|
||||
@@ -2442,6 +2890,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
|
||||
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.3",
|
||||
"tslib": "2.8.1"
|
||||
@@ -2620,6 +3069,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz",
|
||||
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iterare": "1.2.1",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -3092,6 +3542,7 @@
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
@@ -3230,6 +3681,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3411,6 +3863,7 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -4092,6 +4545,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4141,6 +4595,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -4583,6 +5038,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4844,6 +5300,7 @@
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -4901,13 +5358,15 @@
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.14.3",
|
||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
@@ -5261,8 +5720,7 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -5691,6 +6149,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.2",
|
||||
"@esbuild/android-arm": "0.27.2",
|
||||
"@esbuild/android-arm64": "0.27.2",
|
||||
"@esbuild/android-x64": "0.27.2",
|
||||
"@esbuild/darwin-arm64": "0.27.2",
|
||||
"@esbuild/darwin-x64": "0.27.2",
|
||||
"@esbuild/freebsd-arm64": "0.27.2",
|
||||
"@esbuild/freebsd-x64": "0.27.2",
|
||||
"@esbuild/linux-arm": "0.27.2",
|
||||
"@esbuild/linux-arm64": "0.27.2",
|
||||
"@esbuild/linux-ia32": "0.27.2",
|
||||
"@esbuild/linux-loong64": "0.27.2",
|
||||
"@esbuild/linux-mips64el": "0.27.2",
|
||||
"@esbuild/linux-ppc64": "0.27.2",
|
||||
"@esbuild/linux-riscv64": "0.27.2",
|
||||
"@esbuild/linux-s390x": "0.27.2",
|
||||
"@esbuild/linux-x64": "0.27.2",
|
||||
"@esbuild/netbsd-arm64": "0.27.2",
|
||||
"@esbuild/netbsd-x64": "0.27.2",
|
||||
"@esbuild/openbsd-arm64": "0.27.2",
|
||||
"@esbuild/openbsd-x64": "0.27.2",
|
||||
"@esbuild/openharmony-arm64": "0.27.2",
|
||||
"@esbuild/sunos-x64": "0.27.2",
|
||||
"@esbuild/win32-arm64": "0.27.2",
|
||||
"@esbuild/win32-ia32": "0.27.2",
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5726,6 +6226,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5786,6 +6287,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -6018,6 +6520,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -6556,6 +7059,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||
@@ -6755,6 +7271,7 @@
|
||||
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -7141,6 +7658,7 @@
|
||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.2.0",
|
||||
"@jest/types": "30.2.0",
|
||||
@@ -8939,6 +9457,7 @@
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
@@ -9071,6 +9590,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
|
||||
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.10.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
@@ -9354,6 +9874,7 @@
|
||||
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9412,6 +9933,7 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "7.2.0",
|
||||
"@prisma/dev": "0.17.0",
|
||||
@@ -9622,7 +10144,8 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/regexp-to-ast": {
|
||||
"version": "0.5.0",
|
||||
@@ -9707,6 +10230,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
|
||||
@@ -9759,6 +10292,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -9794,8 +10328,7 @@
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
@@ -10504,6 +11037,7 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -10847,6 +11381,7 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -10932,6 +11467,26 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -10994,6 +11549,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11275,6 +11831,7 @@
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -11344,6 +11901,7 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:migrate:reset": "prisma migrate reset",
|
||||
"db:migrate:status": "prisma migrate status",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:seed:equipment": "tsx prisma/seed-equipment.ts",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
@@ -73,6 +78,7 @@
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
|
||||
2240
server/prisma/data/armor.json
Normal file
2240
server/prisma/data/armor.json
Normal file
File diff suppressed because it is too large
Load Diff
44780
server/prisma/data/equipment.json
Normal file
44780
server/prisma/data/equipment.json
Normal file
File diff suppressed because it is too large
Load Diff
8786
server/prisma/data/weapons.json
Normal file
8786
server/prisma/data/weapons.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Equipment" ADD COLUMN "ac" INTEGER,
|
||||
ADD COLUMN "armorCategory" TEXT,
|
||||
ADD COLUMN "armorGroup" TEXT,
|
||||
ADD COLUMN "checkPenalty" INTEGER,
|
||||
ADD COLUMN "damageType" TEXT,
|
||||
ADD COLUMN "dexCap" INTEGER,
|
||||
ADD COLUMN "duration" TEXT,
|
||||
ADD COLUMN "reload" TEXT,
|
||||
ADD COLUMN "shieldBt" INTEGER,
|
||||
ADD COLUMN "shieldHardness" INTEGER,
|
||||
ADD COLUMN "shieldHp" INTEGER,
|
||||
ADD COLUMN "speedPenalty" INTEGER,
|
||||
ADD COLUMN "strength" INTEGER,
|
||||
ADD COLUMN "usage" TEXT,
|
||||
ADD COLUMN "weaponGroup" TEXT;
|
||||
@@ -482,18 +482,41 @@ model Equipment {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
traits String[]
|
||||
itemCategory String
|
||||
itemCategory String // "Weapons", "Armor", "Consumables", "Shields", etc.
|
||||
itemSubcategory String?
|
||||
bulk String?
|
||||
bulk String? // "L" for light, "1", "2", etc.
|
||||
url String?
|
||||
summary String?
|
||||
activation String?
|
||||
hands String?
|
||||
damage String?
|
||||
range String?
|
||||
weaponCategory String?
|
||||
price Int? // In CP
|
||||
level Int?
|
||||
price Int? // In CP
|
||||
|
||||
// Weapon-specific fields
|
||||
hands String?
|
||||
damage String? // "1d8 S", "1d6 P", etc.
|
||||
damageType String? // "S", "P", "B" (Slashing, Piercing, Bludgeoning)
|
||||
range String?
|
||||
reload String?
|
||||
weaponCategory String? // "Simple", "Martial", "Advanced", "Ammunition"
|
||||
weaponGroup String? // "Sword", "Axe", "Bow", etc.
|
||||
|
||||
// Armor-specific fields
|
||||
ac Int?
|
||||
dexCap Int?
|
||||
checkPenalty Int?
|
||||
speedPenalty Int?
|
||||
strength Int? // Strength requirement
|
||||
armorCategory String? // "Unarmored", "Light", "Medium", "Heavy"
|
||||
armorGroup String? // "Leather", "Chain", "Plate", etc.
|
||||
|
||||
// Shield-specific fields
|
||||
shieldHp Int?
|
||||
shieldHardness Int?
|
||||
shieldBt Int? // Broken Threshold
|
||||
|
||||
// Consumable/Equipment-specific fields
|
||||
activation String? // "Cast A Spell", "[one-action]", etc.
|
||||
duration String?
|
||||
usage String?
|
||||
|
||||
characterItems CharacterItem[]
|
||||
}
|
||||
|
||||
254
server/prisma/seed-equipment.ts
Normal file
254
server/prisma/seed-equipment.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'dotenv/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
interface WeaponJson {
|
||||
name: string;
|
||||
trait: string;
|
||||
item_category: string;
|
||||
item_subcategory: string;
|
||||
bulk: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
hands?: string;
|
||||
damage?: string;
|
||||
range?: string;
|
||||
weapon_category?: string;
|
||||
}
|
||||
|
||||
interface ArmorJson {
|
||||
name: string;
|
||||
trait: string;
|
||||
item_category: string;
|
||||
item_subcategory: string;
|
||||
bulk: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
ac?: string;
|
||||
dex_cap?: string;
|
||||
}
|
||||
|
||||
interface EquipmentJson {
|
||||
name: string;
|
||||
trait: string;
|
||||
item_category: string;
|
||||
item_subcategory: string;
|
||||
bulk: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
activation?: string;
|
||||
}
|
||||
|
||||
function parseTraits(traitString: string): string[] {
|
||||
if (!traitString || traitString.trim() === '') return [];
|
||||
return traitString.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
function parseDamage(damageStr: string): { damage: string | null; damageType: string | null } {
|
||||
if (!damageStr || damageStr.trim() === '') return { damage: null, damageType: null };
|
||||
|
||||
// Parse strings like "1d8 S", "1d6 P", "2d6 B"
|
||||
const match = damageStr.match(/^(.+?)\s+([SPB])$/i);
|
||||
if (match) {
|
||||
return { damage: match[1].trim(), damageType: match[2].toUpperCase() };
|
||||
}
|
||||
return { damage: damageStr, damageType: null };
|
||||
}
|
||||
|
||||
function parseNumber(str: string | undefined): number | null {
|
||||
if (!str || str.trim() === '') return null;
|
||||
const num = parseInt(str, 10);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
async function seedWeapons() {
|
||||
const dataPath = path.join(__dirname, 'data', 'weapons.json');
|
||||
const data: WeaponJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
|
||||
console.log(`⚔️ Importing ${data.length} weapons...`);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
const { damage, damageType } = parseDamage(item.damage || '');
|
||||
|
||||
// Check if exists first
|
||||
const existing = await prisma.equipment.findUnique({ where: { name: item.name } });
|
||||
|
||||
if (existing) {
|
||||
// Update with weapon-specific fields
|
||||
await prisma.equipment.update({
|
||||
where: { name: item.name },
|
||||
data: {
|
||||
hands: item.hands || existing.hands,
|
||||
damage: damage || existing.damage,
|
||||
damageType: damageType || existing.damageType,
|
||||
range: item.range || existing.range,
|
||||
weaponCategory: item.weapon_category || existing.weaponCategory,
|
||||
// Don't overwrite traits/summary if already set
|
||||
traits: existing.traits.length > 0 ? existing.traits : parseTraits(item.trait),
|
||||
summary: existing.summary || item.summary || null,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.equipment.create({
|
||||
data: {
|
||||
name: item.name,
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Weapons',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
hands: item.hands || null,
|
||||
damage,
|
||||
damageType,
|
||||
range: item.range || null,
|
||||
weaponCategory: item.weapon_category || null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (errors === 0) {
|
||||
// Print full error for first failure only
|
||||
console.log(` ⚠️ First error for "${item.name}":`);
|
||||
console.log(error.message);
|
||||
}
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✅ Created: ${created}, Updated: ${updated}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
async function seedArmor() {
|
||||
const dataPath = path.join(__dirname, 'data', 'armor.json');
|
||||
const data: ArmorJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
|
||||
console.log(`🛡️ Importing ${data.length} armor items...`);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
const existing = await prisma.equipment.findUnique({ where: { name: item.name } });
|
||||
|
||||
if (existing) {
|
||||
// Update with armor-specific fields
|
||||
await prisma.equipment.update({
|
||||
where: { name: item.name },
|
||||
data: {
|
||||
ac: parseNumber(item.ac) ?? existing.ac,
|
||||
dexCap: parseNumber(item.dex_cap) ?? existing.dexCap,
|
||||
traits: existing.traits.length > 0 ? existing.traits : parseTraits(item.trait),
|
||||
summary: existing.summary || item.summary || null,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.equipment.create({
|
||||
data: {
|
||||
name: item.name,
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Armor',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
ac: parseNumber(item.ac),
|
||||
dexCap: parseNumber(item.dex_cap),
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (errors < 3) {
|
||||
console.log(` ⚠️ Error for "${item.name}": ${error.message?.slice(0, 100)}`);
|
||||
}
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✅ Created: ${created}, Updated: ${updated}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
async function seedEquipment() {
|
||||
const dataPath = path.join(__dirname, 'data', 'equipment.json');
|
||||
const data: EquipmentJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
|
||||
console.log(`📦 Importing ${data.length} equipment items...`);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
await prisma.equipment.upsert({
|
||||
where: { name: item.name },
|
||||
update: {
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Equipment',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
activation: item.activation || null,
|
||||
},
|
||||
create: {
|
||||
name: item.name,
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Equipment',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
activation: item.activation || null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
} catch (error) {
|
||||
// Item with same name already exists - count as update attempt
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✅ Created: ${created}, Duplicates: ${updated}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🗃️ Seeding Pathfinder 2e Equipment Database...\n');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// WICHTIG: Equipment zuerst, dann Waffen/Rüstung um spezifische Felder zu ergänzen
|
||||
await seedEquipment();
|
||||
await seedWeapons(); // Ergänzt damage, hands, weapon_category etc.
|
||||
await seedArmor(); // Ergänzt ac, dex_cap etc.
|
||||
|
||||
const totalCount = await prisma.equipment.count();
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\n✅ Equipment database seeded successfully!`);
|
||||
console.log(` Total items in database: ${totalCount}`);
|
||||
console.log(` Duration: ${duration}s`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Equipment seeding failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -7,9 +7,13 @@ const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
console.log('Seeding database...\n');
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
// Create password hash
|
||||
const passwordHash = await bcrypt.hash('password123', 10);
|
||||
|
||||
// Create Admin User
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
@@ -19,10 +23,208 @@ async function main() {
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
console.log('Created Admin:', admin.username);
|
||||
|
||||
console.log('Admin user created:', user.username, user.email);
|
||||
// Create GM User
|
||||
const gm = await prisma.user.upsert({
|
||||
where: { email: 'gm@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'gamemaster',
|
||||
email: 'gm@dimension47.local',
|
||||
passwordHash,
|
||||
role: 'GM',
|
||||
},
|
||||
});
|
||||
console.log('Created GM:', gm.username);
|
||||
|
||||
// Create Player Users
|
||||
const player1 = await prisma.user.upsert({
|
||||
where: { email: 'player1@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'spieler1',
|
||||
email: 'player1@dimension47.local',
|
||||
passwordHash,
|
||||
role: 'PLAYER',
|
||||
},
|
||||
});
|
||||
console.log('Created Player:', player1.username);
|
||||
|
||||
const player2 = await prisma.user.upsert({
|
||||
where: { email: 'player2@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'spieler2',
|
||||
email: 'player2@dimension47.local',
|
||||
passwordHash,
|
||||
role: 'PLAYER',
|
||||
},
|
||||
});
|
||||
console.log('Created Player:', player2.username);
|
||||
|
||||
// Create Test Campaign
|
||||
const campaign = await prisma.campaign.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000001' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
name: 'Abendliche Schatten',
|
||||
description: 'Eine spannende Kampagne in der Welt von Golarion. Die Helden erkunden uralte Ruinen und stellen sich finsteren Mächten.',
|
||||
gmId: gm.id,
|
||||
},
|
||||
});
|
||||
console.log('Created Campaign:', campaign.name);
|
||||
|
||||
// Add members to campaign
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: gm.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign.id, userId: gm.id },
|
||||
});
|
||||
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: player1.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign.id, userId: player1.id },
|
||||
});
|
||||
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: player2.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign.id, userId: player2.id },
|
||||
});
|
||||
console.log('Added members to campaign');
|
||||
|
||||
// Create Test Characters
|
||||
const character1 = await prisma.character.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000101' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000101',
|
||||
campaignId: campaign.id,
|
||||
ownerId: player1.id,
|
||||
name: 'Thorin Eisenschild',
|
||||
type: 'PC',
|
||||
level: 3,
|
||||
hpCurrent: 38,
|
||||
hpMax: 42,
|
||||
hpTemp: 0,
|
||||
ancestryId: 'dwarf',
|
||||
classId: 'fighter',
|
||||
backgroundId: 'warrior',
|
||||
experiencePoints: 1200,
|
||||
},
|
||||
});
|
||||
console.log('Created Character:', character1.name);
|
||||
|
||||
// Add abilities for character1
|
||||
const abilities1 = [
|
||||
{ ability: 'STR' as const, score: 18 },
|
||||
{ ability: 'DEX' as const, score: 12 },
|
||||
{ ability: 'CON' as const, score: 16 },
|
||||
{ ability: 'INT' as const, score: 10 },
|
||||
{ ability: 'WIS' as const, score: 14 },
|
||||
{ ability: 'CHA' as const, score: 8 },
|
||||
];
|
||||
|
||||
for (const ab of abilities1) {
|
||||
await prisma.characterAbility.upsert({
|
||||
where: { characterId_ability: { characterId: character1.id, ability: ab.ability } },
|
||||
update: { score: ab.score },
|
||||
create: { characterId: character1.id, ability: ab.ability, score: ab.score },
|
||||
});
|
||||
}
|
||||
console.log('Added abilities to', character1.name);
|
||||
|
||||
const character2 = await prisma.character.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000102' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000102',
|
||||
campaignId: campaign.id,
|
||||
ownerId: player2.id,
|
||||
name: 'Elara Sternenlicht',
|
||||
type: 'PC',
|
||||
level: 3,
|
||||
hpCurrent: 24,
|
||||
hpMax: 28,
|
||||
hpTemp: 0,
|
||||
ancestryId: 'elf',
|
||||
classId: 'wizard',
|
||||
backgroundId: 'scholar',
|
||||
experiencePoints: 1200,
|
||||
},
|
||||
});
|
||||
console.log('Created Character:', character2.name);
|
||||
|
||||
// Add abilities for character2
|
||||
const abilities2 = [
|
||||
{ ability: 'STR' as const, score: 8 },
|
||||
{ ability: 'DEX' as const, score: 14 },
|
||||
{ ability: 'CON' as const, score: 12 },
|
||||
{ ability: 'INT' as const, score: 18 },
|
||||
{ ability: 'WIS' as const, score: 14 },
|
||||
{ ability: 'CHA' as const, score: 12 },
|
||||
];
|
||||
|
||||
for (const ab of abilities2) {
|
||||
await prisma.characterAbility.upsert({
|
||||
where: { characterId_ability: { characterId: character2.id, ability: ab.ability } },
|
||||
update: { score: ab.score },
|
||||
create: { characterId: character2.id, ability: ab.ability, score: ab.score },
|
||||
});
|
||||
}
|
||||
console.log('Added abilities to', character2.name);
|
||||
|
||||
// Create an NPC
|
||||
const npc = await prisma.character.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000201' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000201',
|
||||
campaignId: campaign.id,
|
||||
ownerId: null,
|
||||
name: 'Meister Aldric',
|
||||
type: 'NPC',
|
||||
level: 5,
|
||||
hpCurrent: 55,
|
||||
hpMax: 55,
|
||||
hpTemp: 0,
|
||||
},
|
||||
});
|
||||
console.log('Created NPC:', npc.name);
|
||||
|
||||
// Create a second campaign
|
||||
const campaign2 = await prisma.campaign.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000002' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
name: 'Die verlorene Stadt',
|
||||
description: 'Eine Expedition in die legendäre verlorene Stadt Xin-Shalast.',
|
||||
gmId: gm.id,
|
||||
},
|
||||
});
|
||||
console.log('Created Campaign:', campaign2.name);
|
||||
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign2.id, userId: gm.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign2.id, userId: gm.id },
|
||||
});
|
||||
|
||||
console.log('\n✅ Database seeded successfully!');
|
||||
console.log('\n📋 Test Accounts:');
|
||||
console.log(' Admin: admin@dimension47.local / password123');
|
||||
console.log(' GM: gm@dimension47.local / password123');
|
||||
console.log(' Player 1: player1@dimension47.local / password123');
|
||||
console.log(' Player 2: player2@dimension47.local / password123');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error('Seeding failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CampaignsModule } from './modules/campaigns/campaigns.module';
|
||||
import { CharactersModule } from './modules/characters/characters.module';
|
||||
import { TranslationsModule } from './modules/translations/translations.module';
|
||||
import { EquipmentModule } from './modules/equipment/equipment.module';
|
||||
|
||||
// Guards
|
||||
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
|
||||
@@ -33,6 +34,7 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
|
||||
CampaignsModule,
|
||||
CharactersModule,
|
||||
TranslationsModule,
|
||||
EquipmentModule,
|
||||
],
|
||||
providers: [
|
||||
// Global JWT Auth Guard
|
||||
|
||||
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