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, useUpdateSummonUncap,
useSwapWeapons, useSwapWeapons,
useSwapCharacters, useSwapCharacters,
useSwapSummons useSwapSummons,
useSyncAllPartyItems
} from '$lib/api/mutations/grid.mutations' } from '$lib/api/mutations/grid.mutations'
// TanStack Query mutations - Party // TanStack Query mutations - Party
@ -132,6 +133,7 @@
const swapWeapons = useSwapWeapons() const swapWeapons = useSwapWeapons()
const swapCharacters = useSwapCharacters() const swapCharacters = useSwapCharacters()
const swapSummons = useSwapSummons() const swapSummons = useSwapSummons()
const syncAllItems = useSyncAllPartyItems()
// TanStack Query mutations - Party // TanStack Query mutations - Party
const updatePartyMutation = useUpdateParty() const updatePartyMutation = useUpdateParty()
@ -269,6 +271,17 @@
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element) const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
const partyElement = $derived((party as any)?.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) { function handleTabChange(tab: GridType) {
activeTab = tab // Instant UI update 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 deleteDialogOpen = $state(false)
let deleting = $state(false) let deleting = $state(false)
@ -846,6 +878,14 @@
<DropdownItem> <DropdownItem>
<button onclick={openEditDialog} disabled={loading}>Edit</button> <button onclick={openEditDialog} disabled={loading}>Edit</button>
</DropdownItem> </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}
{#if authUserId} {#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 // Type assertion needed because different types have different query result types
// but they all share the same structure with different content types // but they all share the same structure with different content types
const collectionQueryResult = createInfiniteQuery(() => { const collectionQueryResult = createInfiniteQuery(() => {
@ -163,26 +164,27 @@
} as ReturnType<typeof collectionQueries.characters> } 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, element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined rarity: rarityFilters.length > 0 ? rarityFilters : undefined
} } : {}
switch (type) { switch (type) {
case 'weapon': case 'weapon':
return { return {
...collectionQueries.weapons(authUserId, currentFilters), ...collectionQueries.weapons(authUserId, currentFilters),
enabled: searchMode === 'collection' enabled: true // Always enabled when authUserId exists
} as unknown as ReturnType<typeof collectionQueries.characters> } as unknown as ReturnType<typeof collectionQueries.characters>
case 'character': case 'character':
return { return {
...collectionQueries.characters(authUserId, currentFilters), ...collectionQueries.characters(authUserId, currentFilters),
enabled: searchMode === 'collection' enabled: true
} }
case 'summon': case 'summon':
return { return {
...collectionQueries.summons(authUserId, currentFilters), ...collectionQueries.summons(authUserId, currentFilters),
enabled: searchMode === 'collection' enabled: true
} as unknown as ReturnType<typeof collectionQueries.characters> } as unknown as ReturnType<typeof collectionQueries.characters>
} }
}) })
@ -199,6 +201,25 @@
return filterCollectionByQuery(allItems, debouncedSearchQuery) 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 // 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) // (e.g., due to items being added/removed between page fetches)
const searchResults = $derived.by<AddItemResult[]>(() => { const searchResults = $derived.by<AddItemResult[]>(() => {
@ -404,11 +425,13 @@
{:else if searchResults.length > 0} {:else if searchResults.length > 0}
<ul class="results-list"> <ul class="results-list">
{#each searchResults as item (item.id)} {#each searchResults as item (item.id)}
{@const owned = searchMode === 'all' && authUserId && isOwned(item)}
<li class="result-item"> <li class="result-item">
<button <button
class="result-button" class="result-button"
class:disabled={!canAddMore} class:disabled={!canAddMore}
class:from-collection={item.collectionId} class:from-collection={item.collectionId}
class:owned={owned}
onclick={() => handleItemClick(item)} onclick={() => handleItemClick(item)}
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}" aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
disabled={!canAddMore} disabled={!canAddMore}
@ -422,6 +445,8 @@
<span class="result-name">{getItemName(item)}</span> <span class="result-name">{getItemName(item)}</span>
{#if item.collectionId} {#if item.collectionId}
<Icon name="bookmark" size={14} class="collection-indicator" /> <Icon name="bookmark" size={14} class="collection-indicator" />
{:else if owned}
<Icon name="check" size={14} class="owned-indicator" />
{/if} {/if}
{#if item.element !== undefined} {#if item.element !== undefined}
<span <span
@ -680,6 +705,21 @@
color: var(--accent-blue); color: var(--accent-blue);
flex-shrink: 0; 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 { .loading {