feat: add conflict dialog for duplicate characters/weapons

This commit is contained in:
Justin Edmund 2025-11-30 02:32:03 -08:00
parent ff7199fbbb
commit 8b078cdfd8
5 changed files with 459 additions and 8 deletions

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

View file

@ -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<string, string>): Promise<GridWeapon> {
const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons', {
async createWeapon(params: CreateGridWeaponParams, headers?: Record<string, string>): Promise<GridWeapon | WeaponConflictResponse> {
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<string, string>): Promise<GridCharacter> {
const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters', {
async createCharacter(params: CreateGridCharacterParams, headers?: Record<string, string>): Promise<GridCharacter | CharacterConflictResponse> {
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')
}

View file

@ -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%;
}

View file

@ -0,0 +1,239 @@
<!--
ConflictDialog Component
A unified dialog for handling character and weapon conflicts when adding
units to a party that would violate uniqueness constraints.
Usage:
```svelte
<ConflictDialog
bind:open={conflictDialogOpen}
conflict={conflictData}
partyId={party.id}
partyShortcode={party.shortcode}
onResolve={() => { conflictData = null }}
onCancel={() => { conflictData = null }}
/>
```
-->
<script lang="ts">
import Dialog from '$lib/components/ui/Dialog.svelte'
import Button from '$lib/components/ui/Button.svelte'
import type { ConflictData } from '$lib/types/api/conflict'
import type { GridCharacter, GridWeapon } from '$lib/types/api/party'
import type { Character, Weapon } from '$lib/types/api/entities'
import { useResolveCharacterConflict, useResolveWeaponConflict } from '$lib/api/mutations/grid.mutations'
import { getCharacterImageWithPose, getWeaponImage } from '$lib/utils/images'
import { getWeaponSeriesSlug, isOpusDraconicSeries } from '$lib/utils/weaponSeries'
import { getLocale } from '$lib/paraglide/runtime.js'
import * as m from '$lib/paraglide/messages'
import styles from './ConflictDialog.module.scss'
interface Props {
/** Controls dialog visibility */
open: boolean
/** Conflict data from API response */
conflict: ConflictData | null
/** Party ID for resolve request */
partyId: string
/** Party shortcode for cache invalidation */
partyShortcode: string
/** Callback when conflict is resolved */
onResolve?: () => void
/** Callback when dialog is cancelled */
onCancel?: () => void
}
let {
open = $bindable(false),
conflict,
partyId,
partyShortcode,
onResolve,
onCancel
}: Props = $props()
// Mutations for resolving conflicts
const resolveCharacterMutation = useResolveCharacterConflict()
const resolveWeaponMutation = useResolveWeaponConflict()
// Get current locale for name display
const locale = $derived(getLocale() as 'en' | 'ja')
// Loading state - use mutation.current in Svelte 5
let isLoading = $state(false)
// Generate conflict message based on type
const conflictMessage = $derived.by(() => {
if (!conflict) return ''
if (conflict.type === 'character') {
return m.conflict_character_message()
}
// Weapon conflict - check if it's Opus/Draconic
const weapon = conflict.incoming
if (isOpusDraconicSeries(weapon.series)) {
return m.conflict_weapon_opus_draconic()
}
// Get series name for message
const seriesSlug = getWeaponSeriesSlug(weapon.series)
// Use the series slug directly for now - proper i18n can be added later
const seriesName = seriesSlug?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Unknown'
return m.conflict_weapon_series({ series: seriesName })
})
/**
* Handle confirm button click - resolve the conflict
*/
async function handleResolve() {
if (!conflict) return
const resolveParams = {
partyId,
partyShortcode,
incomingId: conflict.incoming.id,
position: conflict.position,
conflictingIds: conflict.conflicts.map((c) => c.id)
}
isLoading = true
try {
if (conflict.type === 'character') {
await resolveCharacterMutation.mutateAsync(resolveParams)
} else {
await resolveWeaponMutation.mutateAsync(resolveParams)
}
open = false
onResolve?.()
} catch (error) {
console.error('[ConflictDialog] Failed to resolve conflict:', error)
// Error will be handled by mutation's error state
} finally {
isLoading = false
}
}
/**
* Handle cancel button click
*/
function handleCancel() {
open = false
onCancel?.()
}
/**
* Handle dialog open change (e.g., clicking overlay or X button)
*/
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
handleCancel()
}
}
/**
* Get character image URL with proper pose
*/
function getCharacterUrl(gridChar: GridCharacter): string {
return getCharacterImageWithPose(
gridChar.character.granblueId,
'square',
gridChar.uncapLevel,
gridChar.transcendenceStep
)
}
/**
* Get incoming character image URL (default pose)
*/
function getIncomingCharacterUrl(character: Character): string {
return getCharacterImageWithPose(character.granblueId, 'square', 0, 0)
}
/**
* Get weapon image URL
*/
function getWeaponUrl(gridWeapon: GridWeapon): string {
return getWeaponImage(gridWeapon.weapon.granblueId, 'square')
}
/**
* Get incoming weapon image URL
*/
function getIncomingWeaponUrl(weapon: Weapon): string {
return getWeaponImage(weapon.granblueId, 'square')
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange} title={m.conflict_title()}>
{#if conflict}
<div class={styles.content}>
<p class={styles.message}>{conflictMessage}</p>
<div class={styles.diagram}>
<!-- Conflicting items (left side) -->
<ul class={styles.conflicts}>
{#if conflict.type === 'character'}
{#each conflict.conflicts as gridChar (gridChar.id)}
<li class={styles.item}>
<img
src={getCharacterUrl(gridChar)}
alt={gridChar.character.name[locale]}
/>
<span>{gridChar.character.name[locale]}</span>
</li>
{/each}
{:else}
{#each conflict.conflicts as gridWeapon (gridWeapon.id)}
<li class={styles.item}>
<img
src={getWeaponUrl(gridWeapon)}
alt={gridWeapon.weapon.name[locale]}
/>
<span>{gridWeapon.weapon.name[locale]}</span>
</li>
{/each}
{/if}
</ul>
<!-- Arrow -->
<span class={styles.arrow}>&rarr;</span>
<!-- Incoming item (right side) -->
<div class={styles.incoming}>
{#if conflict.type === 'character'}
<div class={styles.item}>
<img
src={getIncomingCharacterUrl(conflict.incoming)}
alt={conflict.incoming.name[locale]}
/>
<span>{conflict.incoming.name[locale]}</span>
</div>
{:else}
<div class={styles.item}>
<img
src={getIncomingWeaponUrl(conflict.incoming)}
alt={conflict.incoming.name[locale]}
/>
<span>{conflict.incoming.name[locale]}</span>
</div>
{/if}
</div>
</div>
</div>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}>
{m.conflict_cancel()}
</Button>
<Button variant="primary" onclick={handleResolve} disabled={isLoading}>
{m.conflict_confirm()}
</Button>
{/snippet}
</Dialog>

View file

@ -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
}
}