feat: add conflict dialog for duplicate characters/weapons
This commit is contained in:
parent
ff7199fbbb
commit
8b078cdfd8
5 changed files with 459 additions and 8 deletions
3
src/assets/icons/loader-2.svg
Normal file
3
src/assets/icons/loader-2.svg
Normal 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 |
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
83
src/lib/components/dialogs/ConflictDialog.module.scss
Normal file
83
src/lib/components/dialogs/ConflictDialog.module.scss
Normal 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%;
|
||||
}
|
||||
239
src/lib/components/dialogs/ConflictDialog.svelte
Normal file
239
src/lib/components/dialogs/ConflictDialog.svelte
Normal 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}>→</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>
|
||||
92
src/lib/types/api/conflict.ts
Normal file
92
src/lib/types/api/conflict.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue