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:
Justin Edmund 2025-12-03 23:13:59 -08:00
parent bf2bf8663f
commit f5d0bbe7da
5 changed files with 303 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

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