Add sync from collection menu and highlight owned items in search

- Party.svelte: Add 'Sync from collection' to overflow menu for parties
  with linked collection items, with proper menu grouping
- SearchContent.svelte: Highlight items user owns in 'All Items' mode
  with subtle green background and checkmark indicator
This commit is contained in:
Justin Edmund 2025-12-03 23:23:24 -08:00
parent f5d0bbe7da
commit 0a2a3894bf
2 changed files with 87 additions and 7 deletions

View file

@ -20,7 +20,8 @@
useUpdateSummonUncap,
useSwapWeapons,
useSwapCharacters,
useSwapSummons
useSwapSummons,
useSyncAllPartyItems
} from '$lib/api/mutations/grid.mutations'
// TanStack Query mutations - Party
@ -132,6 +133,7 @@
const swapWeapons = useSwapWeapons()
const swapCharacters = useSwapCharacters()
const swapSummons = useSwapSummons()
const syncAllItems = useSyncAllPartyItems()
// TanStack Query mutations - Party
const updatePartyMutation = useUpdateParty()
@ -269,6 +271,17 @@
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
const partyElement = $derived((party as any)?.element)
// Check if any items in the party are linked to collection (for sync menu option)
const hasCollectionLinks = $derived.by(() => {
const hasLinkedWeapons = (party?.weapons ?? []).some((w) => w?.collectionWeaponId)
const hasLinkedCharacters = (party?.characters ?? []).some((c) => c?.collectionCharacterId)
const hasLinkedSummons = (party?.summons ?? []).some((s) => s?.collectionSummonId)
return hasLinkedWeapons || hasLinkedCharacters || hasLinkedSummons
})
// Check if syncing is in progress
const isSyncingAll = $derived(syncAllItems.isPending)
function handleTabChange(tab: GridType) {
activeTab = tab // Instant UI update
@ -368,6 +381,25 @@
}
}
async function syncFromCollection() {
if (!canEdit() || !hasCollectionLinks) return
loading = true
error = null
try {
await syncAllItems.mutateAsync({
partyId: party.id,
partyShortcode: party.shortcode
})
// Party will be updated via cache invalidation
} catch (err: any) {
error = err.message || 'Failed to sync from collection'
} finally {
loading = false
}
}
let deleteDialogOpen = $state(false)
let deleting = $state(false)
@ -846,6 +878,14 @@
<DropdownItem>
<button onclick={openEditDialog} disabled={loading}>Edit</button>
</DropdownItem>
{#if hasCollectionLinks}
<DropdownItem>
<button onclick={syncFromCollection} disabled={loading || isSyncingAll}>
{isSyncingAll ? 'Syncing...' : 'Sync from collection'}
</button>
</DropdownItem>
{/if}
<DropdownMenu.Separator class="dropdown-separator" />
{/if}
{#if authUserId}

View file

@ -151,7 +151,8 @@
}
})
// Collection query - only enabled when in collection mode and authUserId is provided
// Collection query - enabled when authUserId is provided
// Used both for collection mode AND for highlighting owned items in "all" mode
// Type assertion needed because different types have different query result types
// but they all share the same structure with different content types
const collectionQueryResult = createInfiniteQuery(() => {
@ -163,26 +164,27 @@
} as ReturnType<typeof collectionQueries.characters>
}
const currentFilters = {
// For collection mode, apply filters; for "all" mode, fetch all to build owned set
const currentFilters = searchMode === 'collection' ? {
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined
}
} : {}
switch (type) {
case 'weapon':
return {
...collectionQueries.weapons(authUserId, currentFilters),
enabled: searchMode === 'collection'
enabled: true // Always enabled when authUserId exists
} as unknown as ReturnType<typeof collectionQueries.characters>
case 'character':
return {
...collectionQueries.characters(authUserId, currentFilters),
enabled: searchMode === 'collection'
enabled: true
}
case 'summon':
return {
...collectionQueries.summons(authUserId, currentFilters),
enabled: searchMode === 'collection'
enabled: true
} as unknown as ReturnType<typeof collectionQueries.characters>
}
})
@ -199,6 +201,25 @@
return filterCollectionByQuery(allItems, debouncedSearchQuery)
})
// Set of owned item granblue IDs for fast lookup (used in "all" mode to highlight owned items)
const ownedItemIds = $derived.by(() => {
const pages = collectionQueryResult.data?.pages ?? []
const allItems = pages.flatMap((page) => page.results)
const ids = new Set<string>()
for (const item of allItems) {
const entity = 'character' in item ? item.character : 'weapon' in item ? item.weapon : item.summon
if (entity.granblueId) {
ids.add(String(entity.granblueId))
}
}
return ids
})
// Helper to check if an item is owned (in user's collection)
function isOwned(item: AddItemResult): boolean {
return ownedItemIds.has(String(item.granblueId))
}
// Deduplicate by id - needed because the API may return the same item across pages
// (e.g., due to items being added/removed between page fetches)
const searchResults = $derived.by<AddItemResult[]>(() => {
@ -404,11 +425,13 @@
{:else if searchResults.length > 0}
<ul class="results-list">
{#each searchResults as item (item.id)}
{@const owned = searchMode === 'all' && authUserId && isOwned(item)}
<li class="result-item">
<button
class="result-button"
class:disabled={!canAddMore}
class:from-collection={item.collectionId}
class:owned={owned}
onclick={() => handleItemClick(item)}
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
disabled={!canAddMore}
@ -422,6 +445,8 @@
<span class="result-name">{getItemName(item)}</span>
{#if item.collectionId}
<Icon name="bookmark" size={14} class="collection-indicator" />
{:else if owned}
<Icon name="check" size={14} class="owned-indicator" />
{/if}
{#if item.element !== undefined}
<span
@ -680,6 +705,21 @@
color: var(--accent-blue);
flex-shrink: 0;
}
:global(.owned-indicator) {
color: var(--success, #4caf50);
flex-shrink: 0;
opacity: 0.7;
}
// Subtle highlight for owned items in "all" mode
.result-button.owned {
background: var(--owned-bg, rgba(76, 175, 80, 0.08));
&:hover {
background: var(--owned-bg-hover, rgba(76, 175, 80, 0.15));
}
}
}
.loading {