diff --git a/src/assets/icons/loader-2.svg b/src/assets/icons/loader-2.svg new file mode 100644 index 00000000..a55230b6 --- /dev/null +++ b/src/assets/icons/loader-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/api/adapters/grid.adapter.ts b/src/lib/api/adapters/grid.adapter.ts index 0e8ed984..c1d136bb 100644 --- a/src/lib/api/adapters/grid.adapter.ts +++ b/src/lib/api/adapters/grid.adapter.ts @@ -10,8 +10,10 @@ import { BaseAdapter } from './base.adapter' import type { AdapterOptions } from './types' import type { GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party' +import type { Character, Weapon } from '$lib/types/api/entities' import { DEFAULT_ADAPTER_CONFIG } from './config' import { validateGridWeapon, validateGridCharacter, validateGridSummon } from '$lib/utils/gridValidation' +import { isConflictResponse } from '$lib/types/api/conflict' // GridWeapon, GridCharacter, and GridSummon types are imported from types/api/party // Re-export for test files and consumers @@ -88,6 +90,24 @@ export interface ResolveConflictParams { conflictingIds: string[] } +/** + * Character conflict response from API + */ +export interface CharacterConflictResponse { + position: number + conflicts: GridCharacter[] + incoming: Character +} + +/** + * Weapon conflict response from API + */ +export interface WeaponConflictResponse { + position: number + conflicts: GridWeapon[] + incoming: Weapon +} + /** * Grid adapter for managing user's grid item instances */ @@ -97,16 +117,23 @@ export class GridAdapter extends BaseAdapter { /** * Creates a new grid weapon instance + * Returns either a GridWeapon on success, or a WeaponConflictResponse if conflicts are detected */ - async createWeapon(params: CreateGridWeaponParams, headers?: Record): Promise { - const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons', { + async createWeapon(params: CreateGridWeaponParams, headers?: Record): Promise { + const response = await this.request<{ gridWeapon: GridWeapon } | WeaponConflictResponse>('/grid_weapons', { method: 'POST', body: { weapon: params }, headers }) - // Validate and normalize response - const validated = validateGridWeapon(response.gridWeapon) + // Check if this is a conflict response + if (isConflictResponse(response)) { + return response as WeaponConflictResponse + } + + // Normal success response - validate and normalize + const gridWeaponResponse = response as { gridWeapon: GridWeapon } + const validated = validateGridWeapon(gridWeaponResponse.gridWeapon) if (!validated) { throw new Error('API returned incomplete GridWeapon data') } @@ -208,16 +235,23 @@ export class GridAdapter extends BaseAdapter { /** * Creates a new grid character instance + * Returns either a GridCharacter on success, or a CharacterConflictResponse if conflicts are detected */ - async createCharacter(params: CreateGridCharacterParams, headers?: Record): Promise { - const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters', { + async createCharacter(params: CreateGridCharacterParams, headers?: Record): Promise { + const response = await this.request<{ gridCharacter: GridCharacter } | CharacterConflictResponse>('/grid_characters', { method: 'POST', body: { character: params }, headers }) - // Validate and normalize response - const validated = validateGridCharacter(response.gridCharacter) + // Check if this is a conflict response + if (isConflictResponse(response)) { + return response as CharacterConflictResponse + } + + // Normal success response - validate and normalize + const gridCharacterResponse = response as { gridCharacter: GridCharacter } + const validated = validateGridCharacter(gridCharacterResponse.gridCharacter) if (!validated) { throw new Error('API returned incomplete GridCharacter data') } diff --git a/src/lib/components/dialogs/ConflictDialog.module.scss b/src/lib/components/dialogs/ConflictDialog.module.scss new file mode 100644 index 00000000..3a9a6eda --- /dev/null +++ b/src/lib/components/dialogs/ConflictDialog.module.scss @@ -0,0 +1,83 @@ +/** + * ConflictDialog Styles + * + * Layout: [Conflicting Items] → [Incoming Item] + */ + +@use '$src/themes/spacing' as *; +@use '$src/themes/typography' as *; + +$item-diameter: 10rem; + +.content { + display: flex; + flex-direction: column; + gap: $unit-3x; +} + +.message { + font-size: $font-regular; + line-height: 1.5; + color: var(--text-primary); + + :global(strong) { + font-weight: $bold; + } +} + +.diagram { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: $unit-2x; +} + +.conflicts { + display: flex; + flex-direction: column; + align-items: center; + gap: $unit-2x; + list-style: none; + padding: 0; + margin: 0; +} + +.item { + display: flex; + flex-direction: column; + align-items: center; + gap: $unit; + text-align: center; + width: $item-diameter; + font-weight: $medium; + font-size: $font-small; + + img { + border-radius: $unit; + width: $item-diameter; + height: auto; + aspect-ratio: 1; + object-fit: cover; + } + + span { + line-height: 1.3; + color: var(--text-primary); + } +} + +.arrow { + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + color: var(--text-tertiary); + height: $item-diameter; + padding: 0 $unit; +} + +.incoming { + display: flex; + justify-content: center; + width: 100%; +} diff --git a/src/lib/components/dialogs/ConflictDialog.svelte b/src/lib/components/dialogs/ConflictDialog.svelte new file mode 100644 index 00000000..3489b97a --- /dev/null +++ b/src/lib/components/dialogs/ConflictDialog.svelte @@ -0,0 +1,239 @@ + + + + + {#if conflict} +
+

{conflictMessage}

+ +
+ +
    + {#if conflict.type === 'character'} + {#each conflict.conflicts as gridChar (gridChar.id)} +
  • + {gridChar.character.name[locale]} + {gridChar.character.name[locale]} +
  • + {/each} + {:else} + {#each conflict.conflicts as gridWeapon (gridWeapon.id)} +
  • + {gridWeapon.weapon.name[locale]} + {gridWeapon.weapon.name[locale]} +
  • + {/each} + {/if} +
+ + + + + +
+ {#if conflict.type === 'character'} +
+ {conflict.incoming.name[locale]} + {conflict.incoming.name[locale]} +
+ {:else} +
+ {conflict.incoming.name[locale]} + {conflict.incoming.name[locale]} +
+ {/if} +
+
+
+ {/if} + + {#snippet footer()} + + + {/snippet} +
diff --git a/src/lib/types/api/conflict.ts b/src/lib/types/api/conflict.ts new file mode 100644 index 00000000..835c91bc --- /dev/null +++ b/src/lib/types/api/conflict.ts @@ -0,0 +1,92 @@ +/** + * Conflict Types + * + * Type definitions for conflict detection and resolution when adding + * characters or weapons to a party that would violate uniqueness constraints. + * + * @module types/api/conflict + */ + +import type { Character, Weapon } from './entities' +import type { GridCharacter, GridWeapon } from './party' + +/** + * Types of units that can have conflicts + */ +export type ConflictType = 'character' | 'weapon' + +/** + * Conflict data when adding a character that already exists in the party + */ +export interface CharacterConflictData { + type: 'character' + position: number + conflicts: GridCharacter[] + incoming: Character +} + +/** + * Conflict data when adding a weapon that violates series constraints + */ +export interface WeaponConflictData { + type: 'weapon' + position: number + conflicts: GridWeapon[] + incoming: Weapon +} + +/** + * Union type for all conflict data + */ +export type ConflictData = CharacterConflictData | WeaponConflictData + +/** + * Raw conflict response from API (before adding type discriminator) + */ +export interface RawConflictResponse { + position: number + conflicts: unknown[] + incoming: unknown +} + +/** + * Type guard to check if an API response is a conflict response. + * Conflict responses have `conflicts`, `incoming`, and `position` properties. + * + * @param data - The API response data to check + * @returns True if the response is a conflict response + */ +export function isConflictResponse(data: unknown): data is RawConflictResponse { + return ( + data !== null && + typeof data === 'object' && + 'conflicts' in data && + 'incoming' in data && + 'position' in data && + Array.isArray((data as RawConflictResponse).conflicts) + ) +} + +/** + * Creates typed ConflictData from a raw API conflict response. + * + * @param raw - The raw conflict response from the API + * @param type - The type of conflict ('character' or 'weapon') + * @returns Typed ConflictData ready for use in ConflictDialog + */ +export function createConflictData(raw: RawConflictResponse, type: ConflictType): ConflictData { + if (type === 'character') { + return { + type: 'character', + position: raw.position, + conflicts: raw.conflicts as GridCharacter[], + incoming: raw.incoming as Character + } + } + return { + type: 'weapon', + position: raw.position, + conflicts: raw.conflicts as GridWeapon[], + incoming: raw.incoming as Weapon + } +}