Add sync UI to Party and edit sidebars
- Party.svelte: Pass authUserId to openSearchSidebar, link collection items when adding to party via collectionId - DetailsSidebar: Show sync banner for out-of-sync items, add sync functionality for characters, weapons, and summons - EditCharacterSidebar/EditWeaponSidebar: Add sync banner and button for items linked to collection - party.ts types: Add collectionId and outOfSync fields to grid types
This commit is contained in:
parent
bf2bf8663f
commit
f5d0bbe7da
5 changed files with 303 additions and 15 deletions
|
|
@ -42,7 +42,7 @@
|
|||
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
|
||||
import { openSearchSidebar } from '$lib/features/search/openSearchSidebar.svelte'
|
||||
import PartySegmentedControl from '$lib/components/party/PartySegmentedControl.svelte'
|
||||
import type { SearchResult } from '$lib/api/adapters'
|
||||
import type { AddItemResult } from '$lib/types/api/search'
|
||||
import { GridType } from '$lib/types/enums'
|
||||
import Dialog from '$lib/components/ui/Dialog.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
|
|
@ -522,7 +522,7 @@
|
|||
}
|
||||
|
||||
// Handle adding items from the search sidebar
|
||||
async function handleAddItems(items: SearchResult[]) {
|
||||
async function handleAddItems(items: AddItemResult[]) {
|
||||
if (items.length === 0 || !canEdit()) return
|
||||
|
||||
const item = items[0]
|
||||
|
|
@ -535,7 +535,7 @@
|
|||
let targetSlot = selectedSlot
|
||||
|
||||
// Call appropriate create mutation based on current tab
|
||||
// Use granblueId (camelCase) as that's what the SearchResult type uses
|
||||
// Use granblueId (camelCase) as that's what the AddItemResult type uses
|
||||
const itemId = item.granblueId
|
||||
let result: unknown
|
||||
|
||||
|
|
@ -544,7 +544,9 @@
|
|||
partyId: party.id,
|
||||
weaponId: itemId,
|
||||
position: targetSlot,
|
||||
mainhand: targetSlot === -1
|
||||
mainhand: targetSlot === -1,
|
||||
// Link to collection if item was selected from collection
|
||||
collectionWeaponId: item.collectionId
|
||||
})
|
||||
|
||||
// Check if the result is a conflict response
|
||||
|
|
@ -559,13 +561,17 @@
|
|||
summonId: itemId,
|
||||
position: targetSlot,
|
||||
main: targetSlot === -1,
|
||||
friend: targetSlot === 6
|
||||
friend: targetSlot === 6,
|
||||
// Link to collection if item was selected from collection
|
||||
collectionSummonId: item.collectionId
|
||||
})
|
||||
} else if (activeTab === GridType.Character) {
|
||||
result = await createCharacter.mutateAsync({
|
||||
partyId: party.id,
|
||||
characterId: itemId,
|
||||
position: targetSlot
|
||||
position: targetSlot,
|
||||
// Link to collection if item was selected from collection
|
||||
collectionCharacterId: item.collectionId
|
||||
})
|
||||
|
||||
// Check if the result is a conflict response
|
||||
|
|
@ -777,10 +783,12 @@
|
|||
: GridType.Character
|
||||
|
||||
// Open the search sidebar with the appropriate type
|
||||
// Pass authUserId to enable collection mode toggle
|
||||
openSearchSidebar({
|
||||
type: opts.type,
|
||||
onAddItems: handleAddItems,
|
||||
canAddMore: true
|
||||
canAddMore: true,
|
||||
authUserId
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@
|
|||
import StatsSection from './details/StatsSection.svelte'
|
||||
import SkillsSection from './details/SkillsSection.svelte'
|
||||
import TeamView from './details/TeamView.svelte'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
import {
|
||||
useSyncGridCharacter,
|
||||
useSyncGridWeapon,
|
||||
useSyncGridSummon
|
||||
} from '$lib/api/mutations/grid.mutations'
|
||||
|
||||
interface Props {
|
||||
type: 'character' | 'weapon' | 'summon'
|
||||
|
|
@ -17,6 +23,11 @@
|
|||
|
||||
let { type, item: initialItem }: Props = $props()
|
||||
|
||||
// Sync mutations
|
||||
const syncCharacterMutation = useSyncGridCharacter()
|
||||
const syncWeaponMutation = useSyncGridWeapon()
|
||||
const syncSummonMutation = useSyncGridSummon()
|
||||
|
||||
// Derive item from partyStore for reactivity, fall back to prop if not in store
|
||||
// This ensures the sidebar updates when party data changes (e.g., uncap level)
|
||||
let item = $derived.by(() => {
|
||||
|
|
@ -40,14 +51,21 @@
|
|||
// Track selected view - updated reactively based on modifiability
|
||||
let selectedView = $state<'canonical' | 'user'>('user')
|
||||
|
||||
// Update view when switching to items with different modifiability
|
||||
// Track the item ID to detect when switching to a different item
|
||||
let currentItemId = $state<string | undefined>(undefined)
|
||||
|
||||
// Update view when switching to a different item
|
||||
$effect(() => {
|
||||
if (!showSegmentedControl) {
|
||||
// Force canonical view for non-modifiable items
|
||||
selectedView = 'canonical'
|
||||
} else if (showSegmentedControl && selectedView === 'canonical') {
|
||||
// Switch to user view when selecting a modifiable item
|
||||
selectedView = 'user'
|
||||
const itemId = item && 'id' in item ? item.id : undefined
|
||||
if (itemId !== currentItemId) {
|
||||
currentItemId = itemId
|
||||
if (!showSegmentedControl) {
|
||||
// Force canonical view for non-modifiable items
|
||||
selectedView = 'canonical'
|
||||
} else {
|
||||
// Default to user view for modifiable items
|
||||
selectedView = 'user'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -81,11 +99,58 @@
|
|||
? ((item as GridWeapon).transcendenceStep ?? null)
|
||||
: ((item as GridSummon).transcendenceStep ?? null)
|
||||
)
|
||||
|
||||
// Sync status - check if linked to collection and out of sync
|
||||
const isLinkedToCollection = $derived.by(() => {
|
||||
if (type === 'character') return !!(item as GridCharacter).collectionCharacterId
|
||||
if (type === 'weapon') return !!(item as GridWeapon).collectionWeaponId
|
||||
if (type === 'summon') return !!(item as GridSummon).collectionSummonId
|
||||
return false
|
||||
})
|
||||
|
||||
const isOutOfSync = $derived.by(() => {
|
||||
if (type === 'character') return (item as GridCharacter).outOfSync ?? false
|
||||
if (type === 'weapon') return (item as GridWeapon).outOfSync ?? false
|
||||
if (type === 'summon') return (item as GridSummon).outOfSync ?? false
|
||||
return false
|
||||
})
|
||||
|
||||
const isSyncing = $derived(
|
||||
syncCharacterMutation.isPending ||
|
||||
syncWeaponMutation.isPending ||
|
||||
syncSummonMutation.isPending
|
||||
)
|
||||
|
||||
// Handle sync from collection
|
||||
async function handleSync() {
|
||||
const itemId = item && 'id' in item ? item.id : undefined
|
||||
if (!itemId || !isLinkedToCollection) return
|
||||
|
||||
if (type === 'character') {
|
||||
await syncCharacterMutation.mutateAsync({ id: itemId, partyShortcode: '' })
|
||||
} else if (type === 'weapon') {
|
||||
await syncWeaponMutation.mutateAsync({ id: itemId, partyShortcode: '' })
|
||||
} else if (type === 'summon') {
|
||||
await syncSummonMutation.mutateAsync({ id: itemId, partyShortcode: '' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="details-sidebar">
|
||||
<ItemHeader {type} {item} {itemData} {gridUncapLevel} {gridTranscendence} />
|
||||
|
||||
{#if isLinkedToCollection && isOutOfSync}
|
||||
<div class="sync-banner">
|
||||
<div class="sync-message">
|
||||
<Icon name="refresh-cw" size={14} />
|
||||
<span>Out of sync with collection</span>
|
||||
</div>
|
||||
<button class="sync-button" onclick={handleSync} disabled={isSyncing}>
|
||||
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DetailsSidebarSegmentedControl
|
||||
hasModifications={showSegmentedControl}
|
||||
bind:selectedView
|
||||
|
|
@ -116,6 +181,50 @@
|
|||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.sync-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: var(--warning-bg, rgba(255, 193, 7, 0.15));
|
||||
border: 1px solid var(--warning-border, rgba(255, 193, 7, 0.3));
|
||||
border-radius: spacing.$unit;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.sync-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-half;
|
||||
font-size: typography.$font-small;
|
||||
color: var(--warning-text, #b59100);
|
||||
|
||||
:global(svg) {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-button {
|
||||
padding: spacing.$unit-half spacing.$unit;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-primary);
|
||||
background: var(--button-bg);
|
||||
border: 1px solid var(--button-border);
|
||||
border-radius: spacing.$unit-half;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.canonical-view {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
type CharacterEditValues,
|
||||
type CharacterEditUpdates
|
||||
} from './CharacterEditPane.svelte'
|
||||
import { useSyncGridCharacter } from '$lib/api/mutations/grid.mutations'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
|
||||
interface Props {
|
||||
character: GridCharacter
|
||||
|
|
@ -20,12 +22,29 @@
|
|||
|
||||
let { character, onSave, onCancel }: Props = $props()
|
||||
|
||||
// Sync mutation
|
||||
const syncMutation = useSyncGridCharacter()
|
||||
|
||||
// Character data shortcut
|
||||
const characterData = $derived(character.character)
|
||||
|
||||
// Perpetuity is only available for non-MC characters (position > 0)
|
||||
const canHavePerpetuity = $derived(character.position > 0)
|
||||
|
||||
// Sync status
|
||||
const isLinkedToCollection = $derived(!!character.collectionCharacterId)
|
||||
const isOutOfSync = $derived(character.outOfSync ?? false)
|
||||
const isSyncing = $derived(syncMutation.isPending)
|
||||
|
||||
// Handle sync from collection
|
||||
async function handleSync() {
|
||||
if (!character.id || !isLinkedToCollection) return
|
||||
await syncMutation.mutateAsync({
|
||||
id: character.id,
|
||||
partyShortcode: '' // Will be handled by cache invalidation
|
||||
})
|
||||
}
|
||||
|
||||
// Convert GridCharacter data to CharacterEditPane format
|
||||
const currentValues = $derived<CharacterEditValues>({
|
||||
uncapLevel: character.uncapLevel ?? 0,
|
||||
|
|
@ -66,17 +85,34 @@
|
|||
gridTranscendence={character.transcendenceStep}
|
||||
/>
|
||||
|
||||
{#if isLinkedToCollection && isOutOfSync}
|
||||
<div class="sync-banner">
|
||||
<div class="sync-message">
|
||||
<Icon name="refresh-cw" size={14} />
|
||||
<span>Out of sync with collection</span>
|
||||
</div>
|
||||
<button
|
||||
class="sync-button"
|
||||
onclick={handleSync}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CharacterEditPane
|
||||
{characterData}
|
||||
{currentValues}
|
||||
showPerpetuity={canHavePerpetuity}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.character-edit-sidebar {
|
||||
display: flex;
|
||||
|
|
@ -84,4 +120,48 @@
|
|||
height: 100%;
|
||||
gap: spacing.$unit-4x;
|
||||
}
|
||||
|
||||
.sync-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: var(--warning-bg, rgba(255, 193, 7, 0.15));
|
||||
border: 1px solid var(--warning-border, rgba(255, 193, 7, 0.3));
|
||||
border-radius: spacing.$unit;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.sync-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-half;
|
||||
font-size: typography.$font-small;
|
||||
color: var(--warning-text, #b59100);
|
||||
|
||||
:global(svg) {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-button {
|
||||
padding: spacing.$unit-half spacing.$unit;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-primary);
|
||||
background: var(--button-bg);
|
||||
border: 1px solid var(--button-border);
|
||||
border-radius: spacing.$unit-half;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
import AwakeningSelect from './edit/AwakeningSelect.svelte'
|
||||
import AxSkillSelect from './edit/AxSkillSelect.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
import { getElementIcon } from '$lib/utils/images'
|
||||
import { seriesHasWeaponKeys, getSeriesSlug } from '$lib/utils/weaponSeries'
|
||||
import { useSyncGridWeapon } from '$lib/api/mutations/grid.mutations'
|
||||
|
||||
interface Props {
|
||||
weapon: GridWeapon
|
||||
|
|
@ -20,6 +22,23 @@
|
|||
|
||||
let { weapon, onSave, onCancel }: Props = $props()
|
||||
|
||||
// Sync mutation
|
||||
const syncMutation = useSyncGridWeapon()
|
||||
|
||||
// Sync status
|
||||
const isLinkedToCollection = $derived(!!weapon.collectionWeaponId)
|
||||
const isOutOfSync = $derived(weapon.outOfSync ?? false)
|
||||
const isSyncing = $derived(syncMutation.isPending)
|
||||
|
||||
// Handle sync from collection
|
||||
async function handleSync() {
|
||||
if (!weapon.id || !isLinkedToCollection) return
|
||||
await syncMutation.mutateAsync({
|
||||
id: weapon.id,
|
||||
partyShortcode: ''
|
||||
})
|
||||
}
|
||||
|
||||
// Local state for edits
|
||||
let element = $state(weapon.element ?? weapon.weapon?.element ?? 0)
|
||||
|
||||
|
|
@ -210,6 +229,22 @@
|
|||
gridTranscendence={weapon.transcendenceStep}
|
||||
/>
|
||||
|
||||
{#if isLinkedToCollection && isOutOfSync}
|
||||
<div class="sync-banner">
|
||||
<div class="sync-message">
|
||||
<Icon name="refresh-cw" size={14} />
|
||||
<span>Out of sync with collection</span>
|
||||
</div>
|
||||
<button
|
||||
class="sync-button"
|
||||
onclick={handleSync}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="edit-sections">
|
||||
{#if canChangeElement}
|
||||
<DetailsSection title="Element">
|
||||
|
|
@ -312,6 +347,50 @@
|
|||
gap: spacing.$unit-4x;
|
||||
}
|
||||
|
||||
.sync-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: var(--warning-bg, rgba(255, 193, 7, 0.15));
|
||||
border: 1px solid var(--warning-border, rgba(255, 193, 7, 0.3));
|
||||
border-radius: spacing.$unit;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.sync-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-half;
|
||||
font-size: typography.$font-small;
|
||||
color: var(--warning-text, #b59100);
|
||||
|
||||
:global(svg) {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-button {
|
||||
padding: spacing.$unit-half spacing.$unit;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-primary);
|
||||
background: var(--button-bg);
|
||||
border: 1px solid var(--button-border);
|
||||
border-radius: spacing.$unit-half;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.weapon-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ export interface GridWeapon {
|
|||
type?: Awakening
|
||||
level?: number
|
||||
}
|
||||
/** Reference to the source collection weapon if linked */
|
||||
collectionWeaponId?: string
|
||||
/** Whether the grid item is out of sync with its collection source */
|
||||
outOfSync?: boolean
|
||||
}
|
||||
|
||||
// GridCharacter from GridCharacterBlueprint
|
||||
|
|
@ -52,6 +56,10 @@ export interface GridCharacter {
|
|||
overMastery?: Array<{ modifier: number; strength: number }>
|
||||
/** Equipped artifact (can be grid or collection artifact) */
|
||||
artifact?: GridArtifact | CollectionArtifact
|
||||
/** Reference to the source collection character if linked */
|
||||
collectionCharacterId?: string
|
||||
/** Whether the grid item is out of sync with its collection source */
|
||||
outOfSync?: boolean
|
||||
}
|
||||
|
||||
// GridSummon from GridSummonBlueprint
|
||||
|
|
@ -64,6 +72,10 @@ export interface GridSummon {
|
|||
uncapLevel?: number
|
||||
transcendenceStep?: number
|
||||
summon: Summon // Named properly, not "object"
|
||||
/** Reference to the source collection summon if linked */
|
||||
collectionSummonId?: string
|
||||
/** Whether the grid item is out of sync with its collection source */
|
||||
outOfSync?: boolean
|
||||
}
|
||||
|
||||
// JobSkillList for party job skills
|
||||
|
|
|
|||
Loading…
Reference in a new issue