feat(web): add inventory page with grid, detail panel, and equipment overview

This commit is contained in:
Bastian Wagner
2026-08-20 17:46:09 +02:00
parent 973d4e3ab4
commit 1015505f38
4 changed files with 410 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
<section class="inventory-page" aria-label="Inventar">
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
} @else if (inventoryStore.inventory(); as inventory) {
<section class="inventory-page__grid" aria-label="Gegenstände">
@for (entry of inventory.items; track entry.id) {
<button
type="button"
class="inventory-page__slot"
[class.inventory-page__slot--selected]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
(click)="selectItem(entry.id)"
>
<app-item-card [item]="entry.item" [quantity]="entry.quantity" />
@if (entry.equipped) {
<span class="inventory-page__equipped-badge" data-slot-equipped>Ausgerüstet</span>
}
</button>
} @empty {
<p class="inventory-page__empty" data-inventory-empty>Noch keine Gegenstände gefunden.</p>
}
</section>
<aside class="inventory-page__side" aria-label="Details und Ausrüstung">
<app-inventory-detail-panel
[item]="inventoryStore.selectedItem()"
[equippedItemInSlot]="equippedItemInSelectedSlot()"
[characterLevel]="characterLevel()"
[busy]="inventoryStore.equipping()"
(equip)="equipSelected($event)"
/>
@if (inventoryStore.equipment(); as equipment) {
<section class="inventory-page__equipment" aria-label="Ausrüstung">
<h3>Ausrüstung</h3>
<ul class="inventory-page__equipment-list">
@for (slot of slotOrder; track slot) {
<li>
<span class="inventory-page__equipment-slot-label">{{ slotLabels[slot] }}</span>
<span class="inventory-page__equipment-slot-value">
{{ equipment.slots[slot]?.item?.name ?? 'Leer' }}
</span>
</li>
}
</ul>
<dl class="inventory-page__stats" data-inventory-stats>
<div><dt>Leben</dt><dd>{{ equipment.stats.maxHp }}</dd></div>
<div><dt>Angriff</dt><dd>{{ equipment.stats.attack }}</dd></div>
<div><dt>Waffenschaden</dt><dd>{{ equipment.stats.weaponDamage }}</dd></div>
<div><dt>Rüstung</dt><dd>{{ equipment.stats.armor }}</dd></div>
</dl>
</section>
}
</aside>
}
@if (inventoryStore.error(); as error) {
<section class="inventory-page__notice inventory-page__notice--error" role="alert">
<p>{{ error }}</p>
<button type="button" data-inventory-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>

View File

@@ -0,0 +1,133 @@
// apps/web/src/app/features/inventory/inventory-page.component.scss
:host {
display: block;
}
.inventory-page {
display: grid;
grid-template-columns: 1fr 20rem;
gap: var(--ar-space-5);
align-items: start;
padding: var(--ar-space-5);
}
.inventory-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: var(--ar-space-3);
}
.inventory-page__slot {
position: relative;
padding: var(--ar-space-2);
border: 1px solid transparent;
border-radius: var(--ar-radius-sm);
background: transparent;
}
.inventory-page__slot--selected {
border-color: var(--ar-border-highlight);
background: rgb(155 122 66 / 0.12);
}
.inventory-page__equipped-badge {
position: absolute;
inset-block-start: 0.1rem;
inset-inline-start: 50%;
translate: -50% 0;
padding: 0.05rem 0.4rem;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-gold);
background: rgb(9 11 13 / 0.9);
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__empty {
grid-column: 1 / -1;
padding: var(--ar-space-4);
color: var(--ar-text-muted);
font-style: italic;
}
.inventory-page__side {
display: grid;
gap: var(--ar-space-4);
}
.inventory-page__equipment {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
}
.inventory-page__equipment h3 {
margin: 0 0 var(--ar-space-2);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
font-weight: 400;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__equipment-list {
display: grid;
gap: var(--ar-space-1);
margin: 0 0 var(--ar-space-3);
padding: 0;
list-style: none;
}
.inventory-page__equipment-list li {
display: flex;
justify-content: space-between;
padding-block: var(--ar-space-1);
border-block-end: 1px solid rgb(85 74 57 / 0.4);
font-size: var(--ar-font-sm);
}
.inventory-page__equipment-slot-label {
color: var(--ar-text-muted);
}
.inventory-page__stats {
display: grid;
gap: var(--ar-space-1);
margin: 0;
padding-block-start: var(--ar-space-2);
border-block-start: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-page__stats div {
display: flex;
justify-content: space-between;
}
.inventory-page__stats dt {
color: var(--ar-text-muted);
}
.inventory-page__stats dd {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-page__notice {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
text-align: center;
}
.inventory-page__notice--error {
color: var(--ar-danger);
}
@media (width < 960px) {
.inventory-page {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,143 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { CharacterResponse, EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
import { WorldStore } from '../world/world.store';
import { InventoryPageComponent } from './inventory-page.component';
import { InventoryStore } from './inventory.store';
const inventory: InventoryResponse = {
items: [
{
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
},
],
};
const equipment: EquipmentResponse = {
slots: {
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
},
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
};
const character: CharacterResponse = {
id: 'character-1',
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
};
async function setup() {
const inventoryStore = {
inventory: signal(inventory),
equipment: signal(equipment),
selectedItemId: signal<string | null>(null),
loading: signal(false),
equipping: signal(false),
error: signal<string | null>(null),
load: vi.fn(() => Promise.resolve()),
selectItem: vi.fn(),
selectedItem: vi.fn(() => null),
equip: vi.fn(() => Promise.resolve()),
};
const worldStore = { character: signal(character) };
await TestBed.configureTestingModule({
imports: [InventoryPageComponent],
providers: [
{ provide: InventoryStore, useValue: inventoryStore },
{ provide: WorldStore, useValue: worldStore },
],
}).compileComponents();
const fixture = TestBed.createComponent(InventoryPageComponent);
fixture.detectChanges();
return { fixture, inventoryStore };
}
describe('InventoryPageComponent', () => {
it('loads the inventory on init', async () => {
const { inventoryStore } = await setup();
expect(inventoryStore.load).toHaveBeenCalledOnce();
});
it('renders one tile per owned item', async () => {
const { fixture } = await setup();
const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot');
expect(tiles.length).toBe(2);
});
it('marks the equipped item with a badge', async () => {
const { fixture } = await setup();
expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull();
});
it('selects an item when its tile is clicked', async () => {
const { fixture, inventoryStore } = await setup();
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('.inventory-page__slot')[1].click();
expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade');
});
it('shows the equipment overview with all seven slots and empty ones as Leer', async () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? '';
expect(text).toContain('Waffe');
expect(text).toContain('Abgenutztes Kurzschwert');
expect(text).toContain('Kopf');
expect(text).toContain('Leer');
});
it('shows the effective stats summary from the equipment response', async () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? '';
expect(text).toContain('100');
expect(text).toContain('6');
expect(text).toContain('8');
});
});

View File

@@ -0,0 +1,69 @@
import { Component, OnInit, computed, inject } from '@angular/core';
import type { EquipmentSlot } from '../../core/api/game-api.models';
import { ItemCardComponent } from '../../shared/item-card/item-card.component';
import { WorldStore } from '../world/world.store';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
import { InventoryStore } from './inventory.store';
const SLOT_ORDER: readonly EquipmentSlot[] = [
'WEAPON',
'HEAD',
'CHEST',
'HANDS',
'LEGS',
'FEET',
'AMULET',
];
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};
@Component({
selector: 'app-inventory-page',
imports: [ItemCardComponent, InventoryDetailPanelComponent],
templateUrl: './inventory-page.component.html',
styleUrl: './inventory-page.component.scss',
})
export class InventoryPageComponent implements OnInit {
protected readonly inventoryStore = inject(InventoryStore);
private readonly worldStore = inject(WorldStore);
protected readonly slotOrder = SLOT_ORDER;
protected readonly slotLabels = SLOT_LABELS;
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
protected readonly equippedItemInSelectedSlot = computed(() => {
const selected = this.inventoryStore.selectedItem();
if (!selected?.item.equipmentSlot) {
return null;
}
return (
this.inventoryStore.inventory()?.items.find(
(item) => item.equipped && item.item.equipmentSlot === selected.item.equipmentSlot,
) ?? null
);
});
ngOnInit(): void {
void this.inventoryStore.load();
}
protected selectItem(itemId: string): void {
this.inventoryStore.selectItem(itemId);
}
protected async equipSelected(characterItemId: string): Promise<void> {
await this.inventoryStore.equip(characterItemId);
}
protected retry(): void {
void this.inventoryStore.load();
}
}