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