feat(web): add inventory page with grid, detail panel, and equipment overview
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user