wire up selection mode and bulk delete in collection pages

This commit is contained in:
Justin Edmund 2025-12-19 00:40:10 -08:00
parent a56d8f1870
commit 856e5017ea
4 changed files with 299 additions and 55 deletions

View file

@ -2,17 +2,53 @@
import type { LayoutData } from './$types'
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { setContext } from 'svelte'
import { DropdownMenu } from 'bits-ui'
import ProfileHeader from '$lib/components/profile/ProfileHeader.svelte'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
import AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
import BulkDeleteConfirmModal from '$lib/components/collection/BulkDeleteConfirmModal.svelte'
import { openAddArtifactSidebar } from '$lib/features/collection/openAddArtifactSidebar'
import {
createSelectionModeContext,
SELECTION_MODE_KEY,
LOADED_IDS_KEY,
type EntityType
} from '$lib/stores/selectionMode.svelte'
import {
useBulkRemoveCharactersFromCollection,
useBulkRemoveWeaponsFromCollection,
useBulkRemoveSummonsFromCollection
} from '$lib/api/mutations/collection.mutations'
import { useBulkDeleteCollectionArtifacts } from '$lib/api/mutations/artifact.mutations'
let { data, children }: { data: LayoutData; children: any } = $props()
// Bulk delete mutations
const bulkDeleteCharacters = useBulkRemoveCharactersFromCollection()
const bulkDeleteWeapons = useBulkRemoveWeaponsFromCollection()
const bulkDeleteSummons = useBulkRemoveSummonsFromCollection()
const bulkDeleteArtifacts = useBulkDeleteCollectionArtifacts()
let addModalOpen = $state(false)
let confirmDeleteOpen = $state(false)
let isDeleting = $state(false)
// Selection mode context
const selectionMode = createSelectionModeContext()
setContext(SELECTION_MODE_KEY, selectionMode)
// Context for child pages to provide their loaded IDs
let loadedIds = $state<string[]>([])
setContext(LOADED_IDS_KEY, {
setIds: (ids: string[]) => {
loadedIds = ids
}
})
// Determine active entity type from URL path
const activeEntityType = $derived.by(() => {
@ -43,12 +79,69 @@
const username = $derived(data.user?.username || $page.params.username)
function handleTabChange(value: string) {
// Exit selection mode when switching entity types
if (selectionMode.isActive) {
selectionMode.exit()
}
goto(`/${username}/collection/${value}`)
}
function handleAddArtifact() {
openAddArtifactSidebar()
}
function handleEnterSelectionMode() {
selectionMode.enter(activeEntityType as EntityType)
}
function handleCancelSelection() {
selectionMode.exit()
}
function handleSelectAll() {
selectionMode.selectAll(loadedIds)
}
function handleDeleteClick() {
if (selectionMode.selectedCount > 0) {
confirmDeleteOpen = true
}
}
async function handleConfirmDelete() {
isDeleting = true
const ids = Array.from(selectionMode.selectedIds)
try {
// Call the appropriate bulk delete mutation based on entity type
switch (activeEntityType) {
case 'characters':
await bulkDeleteCharacters.mutateAsync(ids)
break
case 'weapons':
await bulkDeleteWeapons.mutateAsync(ids)
break
case 'summons':
await bulkDeleteSummons.mutateAsync(ids)
break
case 'artifacts':
await bulkDeleteArtifacts.mutateAsync(ids)
break
}
selectionMode.exit()
confirmDeleteOpen = false
} catch (error) {
console.error('Failed to delete items:', error)
// Keep modal open on error so user can retry
} finally {
isDeleting = false
}
}
function handleCancelDelete() {
confirmDeleteOpen = false
}
</script>
<svelte:head>
@ -72,38 +165,81 @@
<div class="card-container">
<!-- Entity type segmented control -->
<nav class="entity-nav" aria-label="Collection type">
<SegmentedControl
value={activeEntityType}
onValueChange={handleTabChange}
variant="blended"
size="small"
>
<Segment value="characters">Characters</Segment>
<Segment value="weapons">Weapons</Segment>
<Segment value="summons">Summons</Segment>
<Segment value="artifacts">Artifacts</Segment>
</SegmentedControl>
{#if selectionMode.isActive}
<!-- Selection mode UI -->
<div class="selection-controls-left">
<span class="selection-count">{selectionMode.selectedCount} selected</span>
<button class="select-all-link" onclick={handleSelectAll}>Select all</button>
</div>
<div class="selection-controls-right">
<Button
variant="destructive"
size="small"
onclick={handleDeleteClick}
disabled={selectionMode.selectedCount === 0}
>
Delete
</Button>
<Button variant="ghost" size="small" onclick={handleCancelSelection}>
Cancel
</Button>
</div>
{:else}
<!-- Normal UI -->
<SegmentedControl
value={activeEntityType}
onValueChange={handleTabChange}
variant="blended"
size="small"
>
<Segment value="characters">Characters</Segment>
<Segment value="weapons">Weapons</Segment>
<Segment value="summons">Summons</Segment>
<Segment value="artifacts">Artifacts</Segment>
</SegmentedControl>
{#if data.isOwner && supportsAddModal}
<Button
variant="primary"
size="small"
onclick={() => (addModalOpen = true)}
icon="plus"
iconPosition="left"
>
{addButtonText}
</Button>
{:else if data.isOwner && isArtifacts}
<Button
variant="primary"
size="small"
onclick={handleAddArtifact}
icon="plus"
iconPosition="left"
>
Add artifact
</Button>
{#if data.isOwner}
<div class="action-buttons">
{#if supportsAddModal}
<Button
variant="primary"
size="small"
onclick={() => (addModalOpen = true)}
icon="plus"
iconPosition="left"
>
{addButtonText}
</Button>
{:else if isArtifacts}
<Button
variant="primary"
size="small"
onclick={handleAddArtifact}
icon="plus"
iconPosition="left"
>
Add artifact
</Button>
{/if}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="more-menu-trigger">
<Icon name="ellipsis" size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-menu" sideOffset={5} align="end">
<DropdownItem>
<button onclick={handleEnterSelectionMode}>
<Icon name="check" size={14} />
<span>Select...</span>
</button>
</DropdownItem>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/if}
{/if}
</nav>
@ -121,9 +257,19 @@
/>
{/if}
<BulkDeleteConfirmModal
bind:open={confirmDeleteOpen}
count={selectionMode.selectedCount}
entityType={activeEntityType}
deleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/typography' as *;
.collection {
padding: $unit-2x 0;
@ -147,4 +293,69 @@
padding: $unit-2x;
min-height: 400px;
}
// Action buttons container
.action-buttons {
display: flex;
align-items: center;
gap: $unit;
}
// More menu trigger button
:global(.more-menu-trigger) {
display: flex;
align-items: center;
justify-content: center;
width: $unit-4x;
height: $unit-4x;
border-radius: $item-corner;
border: none;
background: var(--button-contained-bg);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--button-contained-bg-hover);
}
&:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
}
// Selection mode controls
.selection-controls-left {
display: flex;
align-items: center;
gap: $unit-2x;
}
.selection-count {
font-size: $font-regular;
font-weight: $medium;
color: var(--text-primary);
}
.select-all-link {
background: none;
border: none;
padding: 0;
font-size: $font-small;
font-weight: $medium;
color: var(--accent-color);
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.selection-controls-right {
display: flex;
align-items: center;
gap: $unit;
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { PageData } from './$types'
import type { CollectionCharacter, CollectionSortKey } from '$lib/types/api/collection'
import { getContext } from 'svelte'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { collectionQueries } from '$lib/api/queries/collection.queries'
import CollectionFilters, {
@ -9,13 +10,19 @@
import CollectionCharacterPane from '$lib/components/collection/CollectionCharacterPane.svelte'
import CollectionCharacterCard from '$lib/components/collection/CollectionCharacterCard.svelte'
import CollectionCharacterRow from '$lib/components/collection/CollectionCharacterRow.svelte'
import SelectableCollectionCard from '$lib/components/collection/SelectableCollectionCard.svelte'
import SelectableCollectionRow from '$lib/components/collection/SelectableCollectionRow.svelte'
import Icon from '$lib/components/Icon.svelte'
import { IsInViewport } from 'runed'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
import { LOADED_IDS_KEY, type LoadedIdsContext } from '$lib/stores/selectionMode.svelte'
const { data }: { data: PageData } = $props()
// Get loaded IDs context from layout
const loadedIdsContext = getContext<LoadedIdsContext | undefined>(LOADED_IDS_KEY)
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
@ -54,6 +61,12 @@
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
})
// Provide loaded IDs to layout for "Select all"
$effect(() => {
const ids = allCharacters.map((c) => c.id)
loadedIdsContext?.setIds(ids)
})
// Infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '200px'
@ -151,19 +164,17 @@
{:else if currentViewMode === 'grid'}
<div class="character-grid">
{#each allCharacters as character (character.id)}
<CollectionCharacterCard
{character}
onClick={() => openCharacterDetails(character)}
/>
<SelectableCollectionCard id={character.id} onClick={() => openCharacterDetails(character)}>
<CollectionCharacterCard {character} />
</SelectableCollectionCard>
{/each}
</div>
{:else}
<div class="character-list">
{#each allCharacters as character (character.id)}
<CollectionCharacterRow
{character}
onClick={() => openCharacterDetails(character)}
/>
<SelectableCollectionRow id={character.id} onClick={() => openCharacterDetails(character)}>
<CollectionCharacterRow {character} />
</SelectableCollectionRow>
{/each}
</div>
{/if}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { PageData } from './$types'
import type { CollectionSummon, CollectionSortKey } from '$lib/types/api/collection'
import { getContext } from 'svelte'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { collectionQueries } from '$lib/api/queries/collection.queries'
import CollectionFilters, {
@ -9,13 +10,19 @@
import CollectionSummonPane from '$lib/components/collection/CollectionSummonPane.svelte'
import CollectionSummonCard from '$lib/components/collection/CollectionSummonCard.svelte'
import CollectionSummonRow from '$lib/components/collection/CollectionSummonRow.svelte'
import SelectableCollectionCard from '$lib/components/collection/SelectableCollectionCard.svelte'
import SelectableCollectionRow from '$lib/components/collection/SelectableCollectionRow.svelte'
import Icon from '$lib/components/Icon.svelte'
import { IsInViewport } from 'runed'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
import { LOADED_IDS_KEY, type LoadedIdsContext } from '$lib/stores/selectionMode.svelte'
const { data }: { data: PageData } = $props()
// Get loaded IDs context from layout
const loadedIdsContext = getContext<LoadedIdsContext | undefined>(LOADED_IDS_KEY)
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
@ -48,6 +55,12 @@
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
})
// Provide loaded IDs to layout for "Select all"
$effect(() => {
const ids = allSummons.map((s) => s.id)
loadedIdsContext?.setIds(ids)
})
// Infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '200px'
@ -131,19 +144,17 @@
{:else if currentViewMode === 'grid'}
<div class="summon-grid">
{#each allSummons as summon (summon.id)}
<CollectionSummonCard
{summon}
onClick={() => openSummonDetails(summon)}
/>
<SelectableCollectionCard id={summon.id} onClick={() => openSummonDetails(summon)}>
<CollectionSummonCard {summon} />
</SelectableCollectionCard>
{/each}
</div>
{:else}
<div class="summon-list">
{#each allSummons as summon (summon.id)}
<CollectionSummonRow
{summon}
onClick={() => openSummonDetails(summon)}
/>
<SelectableCollectionRow id={summon.id} onClick={() => openSummonDetails(summon)}>
<CollectionSummonRow {summon} />
</SelectableCollectionRow>
{/each}
</div>
{/if}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { PageData } from './$types'
import type { CollectionWeapon, CollectionSortKey } from '$lib/types/api/collection'
import { getContext } from 'svelte'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { collectionQueries } from '$lib/api/queries/collection.queries'
import CollectionFilters, {
@ -9,13 +10,19 @@
import CollectionWeaponPane from '$lib/components/collection/CollectionWeaponPane.svelte'
import CollectionWeaponCard from '$lib/components/collection/CollectionWeaponCard.svelte'
import CollectionWeaponRow from '$lib/components/collection/CollectionWeaponRow.svelte'
import SelectableCollectionCard from '$lib/components/collection/SelectableCollectionCard.svelte'
import SelectableCollectionRow from '$lib/components/collection/SelectableCollectionRow.svelte'
import Icon from '$lib/components/Icon.svelte'
import { IsInViewport } from 'runed'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
import { LOADED_IDS_KEY, type LoadedIdsContext } from '$lib/stores/selectionMode.svelte'
const { data }: { data: PageData } = $props()
// Get loaded IDs context from layout
const loadedIdsContext = getContext<LoadedIdsContext | undefined>(LOADED_IDS_KEY)
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
@ -52,6 +59,12 @@
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
})
// Provide loaded IDs to layout for "Select all"
$effect(() => {
const ids = allWeapons.map((w) => w.id)
loadedIdsContext?.setIds(ids)
})
// Infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '200px'
@ -139,19 +152,17 @@
{:else if currentViewMode === 'grid'}
<div class="weapon-grid">
{#each allWeapons as weapon (weapon.id)}
<CollectionWeaponCard
{weapon}
onClick={() => openWeaponDetails(weapon)}
/>
<SelectableCollectionCard id={weapon.id} onClick={() => openWeaponDetails(weapon)}>
<CollectionWeaponCard {weapon} />
</SelectableCollectionCard>
{/each}
</div>
{:else}
<div class="weapon-list">
{#each allWeapons as weapon (weapon.id)}
<CollectionWeaponRow
{weapon}
onClick={() => openWeaponDetails(weapon)}
/>
<SelectableCollectionRow id={weapon.id} onClick={() => openWeaponDetails(weapon)}>
<CollectionWeaponRow {weapon} />
</SelectableCollectionRow>
{/each}
</div>
{/if}