use visual segmented control in party views

This commit is contained in:
Justin Edmund 2025-09-16 20:09:36 -07:00
parent acf49c718c
commit cc46a695d5
2 changed files with 58 additions and 153 deletions

View file

@ -10,7 +10,9 @@
import SummonGrid from '$lib/components/grids/SummonGrid.svelte' import SummonGrid from '$lib/components/grids/SummonGrid.svelte'
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte' import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
import SearchSidebar from '$lib/components/panels/SearchSidebar.svelte' import SearchSidebar from '$lib/components/panels/SearchSidebar.svelte'
import PartySegmentedControl from '$lib/components/party/PartySegmentedControl.svelte'
import type { SearchResult } from '$lib/api/resources/search' import type { SearchResult } from '$lib/api/resources/search'
import { GridType } from '$lib/types/enums'
import Dialog from '$lib/components/ui/Dialog.svelte' import Dialog from '$lib/components/ui/Dialog.svelte'
interface Props { interface Props {
@ -38,7 +40,7 @@
? initial ? initial
: defaultParty : defaultParty
) )
let activeTab = $state<'weapons' | 'summons' | 'characters'>('weapons') let activeTab = $state<GridType>(GridType.Weapon)
let loading = $state(false) let loading = $state(false)
let error = $state<string | null>(null) let error = $state<string | null>(null)
let pickerOpen = $state(false) let pickerOpen = $state(false)
@ -212,20 +214,13 @@
return result.canEdit return result.canEdit
}) })
// Tab configuration - use function to avoid state capture
const tabs = $derived([
{ key: 'weapons' as const, label: 'Weapons', count: (party?.weapons ?? []).length },
{ key: 'summons' as const, label: 'Summons', count: (party?.summons ?? []).length },
{ key: 'characters' as const, label: 'Characters', count: (party?.characters ?? []).length }
])
// Derived elements for character image logic // Derived elements for character image logic
const mainWeapon = $derived(() => (party?.weapons ?? []).find(w => w?.mainhand || w?.position === -1)) const mainWeapon = $derived(() => (party?.weapons ?? []).find(w => w?.mainhand || w?.position === -1))
const mainWeaponElement = $derived(() => mainWeapon?.element ?? mainWeapon?.weapon?.element) const mainWeaponElement = $derived(() => mainWeapon?.element ?? mainWeapon?.weapon?.element)
const partyElement = $derived(() => party?.element) const partyElement = $derived(() => party?.element)
function selectTab(key: typeof tabs[number]['key']) { function handleTabChange(tab: GridType) {
activeTab = key activeTab = tab
} }
// Edit dialog functions // Edit dialog functions
@ -334,16 +329,16 @@
let targetSlot = selectedSlot let targetSlot = selectedSlot
// Call appropriate API based on current tab // Call appropriate API based on current tab
if (activeTab === 'weapons') { if (activeTab === GridType.Weapon) {
await apiClient.addWeapon(party.id, item.granblue_id, targetSlot, { await apiClient.addWeapon(party.id, item.granblue_id, targetSlot, {
mainhand: targetSlot === -1 mainhand: targetSlot === -1
}) })
} else if (activeTab === 'summons') { } else if (activeTab === GridType.Summon) {
await apiClient.addSummon(party.id, item.granblue_id, targetSlot, { await apiClient.addSummon(party.id, item.granblue_id, targetSlot, {
main: targetSlot === -1, main: targetSlot === -1,
friend: targetSlot === 6 friend: targetSlot === 6
}) })
} else if (activeTab === 'characters') { } else if (activeTab === GridType.Character) {
await apiClient.addCharacter(party.id, item.granblue_id, targetSlot) await apiClient.addCharacter(party.id, item.granblue_id, targetSlot)
} }
@ -354,7 +349,7 @@
// Find next empty slot for continuous adding // Find next empty slot for continuous adding
let nextEmptySlot = -999 // sentinel value meaning no empty slot found let nextEmptySlot = -999 // sentinel value meaning no empty slot found
if (activeTab === 'weapons') { if (activeTab === GridType.Weapon) {
// Check mainhand first (position -1) // Check mainhand first (position -1)
if (!party.weapons.find(w => w.position === -1 || w.mainhand)) { if (!party.weapons.find(w => w.position === -1 || w.mainhand)) {
nextEmptySlot = -1 nextEmptySlot = -1
@ -367,7 +362,7 @@
} }
} }
} }
} else if (activeTab === 'summons') { } else if (activeTab === GridType.Summon) {
// Check main summon first (position -1) // Check main summon first (position -1)
if (!party.summons.find(s => s.position === -1 || s.main)) { if (!party.summons.find(s => s.position === -1 || s.main)) {
nextEmptySlot = -1 nextEmptySlot = -1
@ -384,7 +379,7 @@
nextEmptySlot = 6 nextEmptySlot = 6
} }
} }
} else if (activeTab === 'characters') { } else if (activeTab === GridType.Character) {
// Check character slots 0-4 // Check character slots 0-4
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
if (!party.characters.find(c => c.position === i)) { if (!party.characters.find(c => c.position === i)) {
@ -471,8 +466,8 @@
openPicker: (opts: { type: 'weapon' | 'summon' | 'character'; position: number; item?: any }) => { openPicker: (opts: { type: 'weapon' | 'summon' | 'character'; position: number; item?: any }) => {
if (!canEdit()) return if (!canEdit()) return
selectedSlot = opts.position selectedSlot = opts.position
activeTab = opts.type === 'weapon' ? 'weapons' : activeTab = opts.type === 'weapon' ? GridType.Weapon :
opts.type === 'summon' ? 'summons' : 'characters' opts.type === 'summon' ? GridType.Summon : GridType.Character
pickerTitle = `Search ${opts.type}s` pickerTitle = `Search ${opts.type}s`
pickerOpen = true pickerOpen = true
} }
@ -549,21 +544,12 @@
</div> </div>
{/if} {/if}
<nav class="party-tabs" aria-label="Party sections"> <PartySegmentedControl
{#each tabs as t} selectedTab={activeTab}
<button onTabChange={handleTabChange}
class="tab-btn" party={party}
aria-pressed={activeTab === t.key} class="party-tabs"
class:active={activeTab === t.key} />
onclick={() => selectTab(t.key)}
>
{t.label}
{#if t.count > 0}
<span class="count">({t.count})</span>
{/if}
</button>
{/each}
</nav>
{#if error} {#if error}
<div class="error-message" role="alert"> <div class="error-message" role="alert">
@ -572,14 +558,14 @@
{/if} {/if}
<div class="party-content"> <div class="party-content">
{#if activeTab === 'weapons'} {#if activeTab === GridType.Weapon}
<WeaponGrid <WeaponGrid
weapons={party.weapons} weapons={party.weapons}
raidExtra={(party as any)?.raid?.group?.extra} raidExtra={(party as any)?.raid?.group?.extra}
showGuidebooks={(party as any)?.raid?.group?.guidebooks} showGuidebooks={(party as any)?.raid?.group?.guidebooks}
guidebooks={(party as any)?.guidebooks} guidebooks={(party as any)?.guidebooks}
/> />
{:else if activeTab === 'summons'} {:else if activeTab === GridType.Summon}
<SummonGrid summons={party.summons} /> <SummonGrid summons={party.summons} />
{:else} {:else}
<CharacterGrid characters={party.characters} mainWeaponElement={mainWeaponElement} partyElement={partyElement} /> <CharacterGrid characters={party.characters} mainWeaponElement={mainWeaponElement} partyElement={partyElement} />
@ -590,8 +576,8 @@
</section> </section>
<SearchSidebar <SearchSidebar
open={pickerOpen} open={pickerOpen}
type={activeTab === 'weapons' ? 'weapon' : type={activeTab === GridType.Weapon ? 'weapon' :
activeTab === 'summons' ? 'summon' : 'character'} activeTab === GridType.Summon ? 'summon' : 'character'}
onClose={() => (pickerOpen = false)} onClose={() => (pickerOpen = false)}
onAddItems={handleAddItems} onAddItems={handleAddItems}
canAddMore={true} canAddMore={true}
@ -729,42 +715,7 @@
.raid-name { .raid-name {
font-weight: 600; font-weight: 600;
} }
.party-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid #eee;
}
.tab-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.tab-btn.active {
color: #3366ff;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #3366ff;
}
.tab-btn .count {
color: #999;
font-size: 0.9em;
}
.error-message { .error-message {
padding: 0.75rem; padding: 0.75rem;
background: #fee; background: #fee;
@ -773,23 +724,10 @@
color: #c00; color: #c00;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.party-content { .party-content {
min-height: 400px; min-height: 400px;
} }
.grid-placeholder {
padding: 2rem;
background: #f9f9f9;
border-radius: 8px;
text-align: center;
}
.grid-placeholder ul {
text-align: left;
max-width: 400px;
margin: 1rem auto;
}
/* Edit form styles */ /* Edit form styles */
.edit-form { .edit-form {

View file

@ -5,6 +5,8 @@
import SummonGrid from '$lib/components/grids/SummonGrid.svelte' import SummonGrid from '$lib/components/grids/SummonGrid.svelte'
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte' import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
import SearchSidebar from '$lib/components/panels/SearchSidebar.svelte' import SearchSidebar from '$lib/components/panels/SearchSidebar.svelte'
import PartySegmentedControl from '$lib/components/party/PartySegmentedControl.svelte'
import { GridType } from '$lib/types/enums'
import { setContext } from 'svelte' import { setContext } from 'svelte'
import type { SearchResult } from '$lib/api/resources/search' import type { SearchResult } from '$lib/api/resources/search'
import { apiClient } from '$lib/api/client' import { apiClient } from '$lib/api/client'
@ -17,15 +19,10 @@
const currentUser = $derived($page.data?.currentUser) const currentUser = $derived($page.data?.currentUser)
// Local, client-only state for tab selection (Svelte 5 runes) // Local, client-only state for tab selection (Svelte 5 runes)
let activeTab = $state<'weapons' | 'summons' | 'characters'>('weapons') let activeTab = $state<GridType>(GridType.Weapon)
const tabs = [
{ key: 'weapons', label: 'Weapons' },
{ key: 'summons', label: 'Summons' },
{ key: 'characters', label: 'Characters' }
] as const
function selectTab(key: typeof tabs[number]['key']) { function selectTab(gridType: GridType) {
activeTab = key activeTab = gridType
sidebarOpen = true // Auto-open sidebar when switching tabs sidebarOpen = true // Auto-open sidebar when switching tabs
} }
@ -66,8 +63,8 @@
let isCharacterGridFull = $derived(characters.length >= 5) // 5 character slots let isCharacterGridFull = $derived(characters.length >= 5) // 5 character slots
let canAddMore = $derived( let canAddMore = $derived(
activeTab === 'weapons' ? !isWeaponGridFull : activeTab === GridType.Weapon ? !isWeaponGridFull :
activeTab === 'summons' ? !isSummonGridFull : activeTab === GridType.Summon ? !isSummonGridFull :
!isCharacterGridFull !isCharacterGridFull
) )
@ -118,7 +115,7 @@
try { try {
console.log('Adding item to party:', { partyId, itemId: firstItem.id, type: activeTab, position }) console.log('Adding item to party:', { partyId, itemId: firstItem.id, type: activeTab, position })
if (activeTab === 'weapons') { if (activeTab === GridType.Weapon) {
// Use selectedSlot if available, otherwise default to mainhand // Use selectedSlot if available, otherwise default to mainhand
if (selectedSlot === null) position = -1 if (selectedSlot === null) position = -1
const addResult = await apiClient.addWeapon( const addResult = await apiClient.addWeapon(
@ -141,7 +138,7 @@
}, },
mainhand: position === -1 mainhand: position === -1
}] }]
} else if (activeTab === 'summons') { } else if (activeTab === GridType.Summon) {
// Use selectedSlot if available, otherwise default to main summon // Use selectedSlot if available, otherwise default to main summon
if (selectedSlot === null) position = -1 if (selectedSlot === null) position = -1
const addResult = await apiClient.addSummon( const addResult = await apiClient.addSummon(
@ -165,7 +162,7 @@
main: position === -1, main: position === -1,
friend: position === 6 friend: position === 6
}] }]
} else if (activeTab === 'characters') { } else if (activeTab === GridType.Character) {
// Use selectedSlot if available, otherwise default to first slot // Use selectedSlot if available, otherwise default to first slot
if (selectedSlot === null) position = 0 if (selectedSlot === null) position = 0
const addResult = await apiClient.addCharacter( const addResult = await apiClient.addCharacter(
@ -251,7 +248,7 @@
const item = items[i] const item = items[i]
let position = -1 // Default position let position = -1 // Default position
if (activeTab === 'weapons') { if (activeTab === GridType.Weapon) {
// Use selectedSlot for first item if available // Use selectedSlot for first item if available
if (i === 0 && selectedSlot !== null && !weapons.find(w => w.position === selectedSlot)) { if (i === 0 && selectedSlot !== null && !weapons.find(w => w.position === selectedSlot)) {
position = selectedSlot position = selectedSlot
@ -283,7 +280,7 @@
}, },
mainhand: position === -1 mainhand: position === -1
}] }]
} else if (activeTab === 'summons') { } else if (activeTab === GridType.Summon) {
// Use selectedSlot for first item if available // Use selectedSlot for first item if available
if (i === 0 && selectedSlot !== null && !summons.find(s => s.position === selectedSlot)) { if (i === 0 && selectedSlot !== null && !summons.find(s => s.position === selectedSlot)) {
position = selectedSlot position = selectedSlot
@ -316,7 +313,7 @@
main: position === -1, main: position === -1,
friend: position === 6 friend: position === 6
}] }]
} else if (activeTab === 'characters') { } else if (activeTab === GridType.Character) {
// Use selectedSlot for first item if available // Use selectedSlot for first item if available
if (i === 0 && selectedSlot !== null && !characters.find(c => c.position === selectedSlot)) { if (i === 0 && selectedSlot !== null && !characters.find(c => c.position === selectedSlot)) {
position = selectedSlot position = selectedSlot
@ -359,7 +356,7 @@
} }
// Original local-only adding logic (before party creation) // Original local-only adding logic (before party creation)
if (activeTab === 'weapons') { if (activeTab === GridType.Weapon) {
// Add weapons to empty slots // Add weapons to empty slots
const emptySlots = Array.from({ length: 10 }, (_, i) => i - 1) // -1 for mainhand, 0-8 for grid const emptySlots = Array.from({ length: 10 }, (_, i) => i - 1) // -1 for mainhand, 0-8 for grid
.filter(i => !weapons.find(w => w.position === i)) .filter(i => !weapons.find(w => w.position === i))
@ -391,7 +388,7 @@
weapons = [...weapons, newWeapon] weapons = [...weapons, newWeapon]
}) })
console.log('Updated weapons array:', weapons) console.log('Updated weapons array:', weapons)
} else if (activeTab === 'summons') { } else if (activeTab === GridType.Summon) {
// Add summons to empty slots // Add summons to empty slots
const emptySlots = [-1, 0, 1, 2, 3, 6] // main, 4 grid slots, friend const emptySlots = [-1, 0, 1, 2, 3, 6] // main, 4 grid slots, friend
.filter(i => !summons.find(s => s.position === i)) .filter(i => !summons.find(s => s.position === i))
@ -421,7 +418,7 @@
friend: position === 6 friend: position === 6
}] }]
}) })
} else if (activeTab === 'characters') { } else if (activeTab === GridType.Character) {
// Add characters to empty slots // Add characters to empty slots
const emptySlots = Array.from({ length: 5 }, (_, i) => i) const emptySlots = Array.from({ length: 5 }, (_, i) => i)
.filter(i => !characters.find(c => c.position === i)) .filter(i => !characters.find(c => c.position === i))
@ -515,23 +512,23 @@
</button> </button>
</header> </header>
<nav class="party-tabs" aria-label="Party sections"> <PartySegmentedControl
{#each tabs as t} value={activeTab}
<button onValueChange={selectTab}
class="tab-btn" party={{
aria-pressed={activeTab === t.key} element: 0,
class:active={activeTab === t.key} job: undefined,
on:click={() => selectTab(t.key)} characters,
> weapons,
{t.label} summons
</button> }}
{/each} userGender={currentUser?.gender}
</nav> />
<div class="party-content"> <div class="party-content">
{#if activeTab === 'weapons'} {#if activeTab === GridType.Weapon}
<WeaponGrid {weapons} /> <WeaponGrid {weapons} />
{:else if activeTab === 'summons'} {:else if activeTab === GridType.Summon}
<SummonGrid {summons} /> <SummonGrid {summons} />
{:else} {:else}
<CharacterGrid {characters} /> <CharacterGrid {characters} />
@ -541,7 +538,7 @@
<SearchSidebar <SearchSidebar
open={sidebarOpen} open={sidebarOpen}
type={activeTab === 'weapons' ? 'weapon' : activeTab === 'summons' ? 'summon' : 'character'} type={activeTab === GridType.Weapon ? 'weapon' : activeTab === GridType.Summon ? 'summon' : 'character'}
onClose={() => sidebarOpen = false} onClose={() => sidebarOpen = false}
onAddItems={handleAddItems} onAddItems={handleAddItems}
canAddMore={canAddMore} canAddMore={canAddMore}
@ -629,36 +626,6 @@
background: #2857e0; background: #2857e0;
} }
.party-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid #eee;
}
.tab-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.tab-btn.active {
color: #3366ff;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #3366ff;
}
.party-content { .party-content {
min-height: 400px; min-height: 400px;
} }