add context menus to units and party

This commit is contained in:
Justin Edmund 2025-09-15 21:24:49 -07:00
parent da4c3d09f9
commit e26b5c2e20
4 changed files with 459 additions and 112 deletions

View file

@ -10,6 +10,7 @@
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 type { SearchResult } from '$lib/api/resources/search' import type { SearchResult } from '$lib/api/resources/search'
import Dialog from '$lib/components/ui/Dialog.svelte'
interface Props { interface Props {
party?: Party party?: Party
@ -42,6 +43,8 @@
let pickerOpen = $state(false) let pickerOpen = $state(false)
let pickerTitle = $state('Search') let pickerTitle = $state('Search')
let selectedSlot = $state<number>(0) let selectedSlot = $state<number>(0)
let editDialogOpen = $state(false)
let editingTitle = $state('')
// Services // Services
const partyService = new PartyService(fetch) const partyService = new PartyService(fetch)
@ -94,22 +97,48 @@
activeTab = key activeTab = key
} }
// Edit dialog functions
function openEditDialog() {
if (!canEdit()) return
editingTitle = party.name || ''
editDialogOpen = true
}
async function savePartyTitle() {
if (!canEdit()) return
try {
loading = true
error = null
// Update party title via API
const updated = await updatePartyDetails({ name: editingTitle })
if (updated) {
party = updated
editDialogOpen = false
}
} catch (err: any) {
error = err.message || 'Failed to update party title'
} finally {
loading = false
}
}
// Party operations // Party operations
async function updatePartyDetails(updates: Partial<Party>) { async function updatePartyDetails(updates: Partial<Party>) {
if (!canEdit()) return if (!canEdit()) return null
loading = true loading = true
error = null error = null
try { try {
const updated = await partyService.update( // Use apiClient for client-side updates (handles edit keys automatically)
party.id, const updated = await apiClient.updateParty(party.id, updates)
updates,
editKey || undefined
)
party = updated party = updated
return updated
} catch (err: any) { } catch (err: any) {
error = err.message || 'Failed to update party' error = err.message || 'Failed to update party'
return null
} finally { } finally {
loading = false loading = false
} }
@ -263,22 +292,37 @@
// Create client-side wrappers for grid operations using API client // Create client-side wrappers for grid operations using API client
const clientGridService = { const clientGridService = {
async removeWeapon(partyId: string, gridWeaponId: string, _editKey?: string) { async removeWeapon(partyId: string, gridWeaponId: string, _editKey?: string) {
await apiClient.removeWeapon(partyId, gridWeaponId) try {
// Reload party data await apiClient.removeWeapon(partyId, gridWeaponId)
const updated = await partyService.getByShortcode(party.shortcode) // Reload party data
return updated const updated = await partyService.getByShortcode(party.shortcode)
return updated
} catch (err) {
console.error('Failed to remove weapon:', err)
throw err
}
}, },
async removeSummon(partyId: string, gridSummonId: string, _editKey?: string) { async removeSummon(partyId: string, gridSummonId: string, _editKey?: string) {
await apiClient.removeSummon(partyId, gridSummonId) try {
// Reload party data await apiClient.removeSummon(partyId, gridSummonId)
const updated = await partyService.getByShortcode(party.shortcode) // Reload party data
return updated const updated = await partyService.getByShortcode(party.shortcode)
return updated
} catch (err) {
console.error('Failed to remove summon:', err)
throw err
}
}, },
async removeCharacter(partyId: string, gridCharacterId: string, _editKey?: string) { async removeCharacter(partyId: string, gridCharacterId: string, _editKey?: string) {
await apiClient.removeCharacter(partyId, gridCharacterId) try {
// Reload party data await apiClient.removeCharacter(partyId, gridCharacterId)
const updated = await partyService.getByShortcode(party.shortcode) // Reload party data
return updated const updated = await partyService.getByShortcode(party.shortcode)
return updated
} catch (err) {
console.error('Failed to remove character:', err)
throw err
}
} }
} }
@ -316,11 +360,22 @@
</div> </div>
<div class="party-actions"> <div class="party-actions">
{#if canEdit()}
<button
class="edit-btn"
onclick={openEditDialog}
disabled={loading}
aria-label="Edit party details"
>
Edit
</button>
{/if}
{#if authUserId} {#if authUserId}
<button <button
class="favorite-btn" class="favorite-btn"
class:favorited={party.favorited} class:favorited={party.favorited}
on:click={toggleFavorite} onclick={toggleFavorite}
disabled={loading} disabled={loading}
aria-label={party.favorited ? 'Remove from favorites' : 'Add to favorites'} aria-label={party.favorited ? 'Remove from favorites' : 'Add to favorites'}
> >
@ -330,7 +385,7 @@
<button <button
class="remix-btn" class="remix-btn"
on:click={remixParty} onclick={remixParty}
disabled={loading} disabled={loading}
aria-label="Remix this party" aria-label="Remix this party"
> >
@ -366,7 +421,7 @@
class="tab-btn" class="tab-btn"
aria-pressed={activeTab === t.key} aria-pressed={activeTab === t.key}
class:active={activeTab === t.key} class:active={activeTab === t.key}
on:click={() => selectTab(t.key)} onclick={() => selectTab(t.key)}
> >
{t.label} {t.label}
{#if t.count > 0} {#if t.count > 0}
@ -410,6 +465,39 @@
</div> </div>
</div> </div>
<!-- Edit Dialog -->
<Dialog bind:open={editDialogOpen} title="Edit Party Details">
{#snippet children()}
<div class="edit-form">
<label for="party-title">Party Title</label>
<input
id="party-title"
type="text"
bind:value={editingTitle}
placeholder="Enter party title..."
disabled={loading}
/>
</div>
{/snippet}
{#snippet footer()}
<button
class="btn-secondary"
onclick={() => (editDialogOpen = false)}
disabled={loading}
>
Cancel
</button>
<button
class="btn-primary"
onclick={savePartyTitle}
disabled={loading || !editingTitle.trim()}
>
{loading ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Dialog>
<style> <style>
.page-wrap { position: relative; --panel-w: 380px; overflow-x: auto; } .page-wrap { position: relative; --panel-w: 380px; overflow-x: auto; }
.track { display: flex; gap: 0; align-items: flex-start; } .track { display: flex; gap: 0; align-items: flex-start; }
@ -437,6 +525,7 @@
gap: 0.5rem; gap: 0.5rem;
} }
.edit-btn,
.favorite-btn, .favorite-btn,
.remix-btn { .remix-btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@ -447,6 +536,10 @@
transition: all 0.2s; transition: all 0.2s;
} }
.edit-btn {
padding: 0.5rem 1rem;
}
.favorite-btn { .favorite-btn {
font-size: 1.2rem; font-size: 1.2rem;
padding: 0.5rem; padding: 0.5rem;
@ -456,11 +549,13 @@
color: gold; color: gold;
} }
.edit-btn:hover,
.favorite-btn:hover, .favorite-btn:hover,
.remix-btn:hover { .remix-btn:hover {
background: #f5f5f5; background: #f5f5f5;
} }
.edit-btn:disabled,
.favorite-btn:disabled, .favorite-btn:disabled,
.remix-btn:disabled { .remix-btn:disabled {
opacity: 0.5; opacity: 0.5;
@ -561,4 +656,75 @@
max-width: 400px; max-width: 400px;
margin: 1rem auto; margin: 1rem auto;
} }
/* Edit form styles */
.edit-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.edit-form label {
font-weight: 500;
font-size: 0.9rem;
color: var(--text-secondary, #666);
}
.edit-form input {
padding: 0.75rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.edit-form input:focus {
outline: none;
border-color: var(--focus-ring, #3366ff);
}
.edit-form input:disabled {
background: #f5f5f5;
opacity: 0.7;
}
/* Dialog buttons */
.btn-primary,
.btn-secondary {
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--button-primary-bg, #3366ff);
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) {
background: var(--button-primary-hover, #2855cc);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: white;
color: var(--text-primary, #333);
border: 1px solid var(--border-color, #ddd);
}
.btn-secondary:hover:not(:disabled) {
background: #f5f5f5;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style> </style>

View file

@ -3,6 +3,8 @@
import type { Party } from '$lib/types/api/party' import type { Party } from '$lib/types/api/party'
import { getContext } from 'svelte' import { getContext } from 'svelte'
import Icon from '$lib/components/Icon.svelte' import Icon from '$lib/components/Icon.svelte'
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
interface Props { interface Props {
item?: GridCharacter item?: GridCharacter
@ -55,35 +57,92 @@
async function remove() { async function remove() {
if (!item?.id) return if (!item?.id) return
const party = ctx.getParty() try {
const editKey = ctx.getEditKey() const party = ctx.getParty()
const updated = await ctx.services.gridService.removeCharacter(party.id, item.id as any, editKey || undefined) const editKey = ctx.getEditKey()
ctx.updateParty(updated) const updated = await ctx.services.gridService.removeCharacter(party.id, item.id as any, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Error removing character:', err)
}
}
function viewDetails() {
// TODO: Implement view details modal
console.log('View details for:', item)
}
function replace() {
if (ctx?.openPicker) {
ctx.openPicker({ type: 'character', position, item })
}
} }
</script> </script>
<div class="unit" class:empty={!item}> <div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)} {#if item}
<div <ContextMenu>
class="frame character cell" {#snippet children()}
class:editable={ctx?.canEdit()} {#key (item as any).id ?? position}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'character', position, item })} <div
> class="frame character cell"
<img class="image" class:placeholder={!item || !(item?.character?.granblueId || (item as any)?.object?.granblueId)} alt={item ? displayName(item?.character || (item as any)?.object) : ''} src={imageUrl()} /> class:editable={ctx?.canEdit()}
{#if !item && ctx?.canEdit()} onclick={() => ctx?.canEdit() && replace()}
<span class="icon"> >
<Icon name="plus" size={24} /> <img
</span> class="image"
{/if} class:placeholder={!(item?.character?.granblueId || (item as any)?.object?.granblueId)}
{#if ctx.canEdit() && item?.id} alt={displayName(item?.character || (item as any)?.object)}
<div class="actions"> src={imageUrl()}
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button> />
</div> {#if ctx?.canEdit() && item?.id}
{/if} <div class="actions">
</div> <button class="remove" title="Remove" onclick={(e) => { e.stopPropagation(); remove() }}>×</button>
{/key} </div>
{/if}
</div>
{/key}
{/snippet}
{#snippet menu()}
<ContextMenuBase.Item class="context-menu-item" onclick={viewDetails}>
View Details
</ContextMenuBase.Item>
{#if ctx?.canEdit()}
<ContextMenuBase.Item class="context-menu-item" onclick={replace}>
Replace
</ContextMenuBase.Item>
<ContextMenuBase.Separator class="context-menu-separator" />
<ContextMenuBase.Item class="context-menu-item danger" onclick={remove}>
Remove
</ContextMenuBase.Item>
{/if}
{/snippet}
</ContextMenu>
{:else}
{#key `empty-${position}`}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
onclick={() => ctx?.canEdit() && ctx?.openPicker && ctx.openPicker({ type: 'character', position, item })}
>
<img
class="image placeholder"
alt=""
src="/images/placeholders/placeholder-weapon-grid.png"
/>
{#if ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
</div>
{/key}
{/if}
<div class="name">{item ? displayName(item?.character || (item as any)?.object) : ''}</div> <div class="name">{item ? displayName(item?.character || (item as any)?.object) : ''}</div>
</div> </div>

View file

@ -3,6 +3,8 @@
import type { Party } from '$lib/types/api/party' import type { Party } from '$lib/types/api/party'
import { getContext } from 'svelte' import { getContext } from 'svelte'
import Icon from '$lib/components/Icon.svelte' import Icon from '$lib/components/Icon.svelte'
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
interface Props { interface Props {
item?: GridSummon item?: GridSummon
@ -49,44 +51,104 @@
async function remove() { async function remove() {
if (!item?.id) return if (!item?.id) return
const party = ctx.getParty() try {
const editKey = ctx.getEditKey() const party = ctx.getParty()
const updated = await ctx.services.gridService.removeSummon(party.id, item.id as any, editKey || undefined) const editKey = ctx.getEditKey()
ctx.updateParty(updated) const updated = await ctx.services.gridService.removeSummon(party.id, item.id as any, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Error removing summon:', err)
}
}
function viewDetails() {
// TODO: Implement view details modal
console.log('View details for:', item)
}
function replace() {
if (ctx?.openPicker) {
ctx.openPicker({ type: 'summon', position, item })
}
} }
</script> </script>
<div class="unit" class:empty={!item}> <div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)} {#if item}
<div <ContextMenu>
class="frame summon" {#snippet children()}
class:main={item?.main || position === -1} {#key (item as any).id ?? position}
class:friend={item?.friend || position === 6} <div
class:cell={!((item?.main || position === -1) || (item?.friend || position === 6))} class="frame summon"
class:editable={ctx?.canEdit()} class:main={item?.main || position === -1}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'summon', position, item })} class:friend={item?.friend || position === 6}
> class:cell={!((item?.main || position === -1) || (item?.friend || position === 6))}
<img class="image" class:placeholder={!item || !(item?.summon?.granblueId || (item as any)?.object?.granblueId)} alt={item ? displayName(item?.summon || (item as any)?.object) : ''} src={imageUrl()} /> class:editable={ctx?.canEdit()}
{#if !item && ctx?.canEdit()} onclick={() => ctx?.canEdit() && replace()}
<span class="icon"> >
<Icon name="plus" size={24} /> <img
</span> class="image"
{/if} class:placeholder={!(item?.summon?.granblueId || (item as any)?.object?.granblueId)}
{#if ctx.canEdit() && item?.id} alt={displayName(item?.summon || (item as any)?.object)}
<div class="actions"> src={imageUrl()}
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button> />
</div> {#if ctx?.canEdit() && item?.id}
{/if} <div class="actions">
{#if item?.main || position === -1} <button class="remove" title="Remove" onclick={(e) => { e.stopPropagation(); remove() }}>×</button>
<span class="badge">Main</span> </div>
{/if} {/if}
{#if item?.friend || position === 6} {#if item?.main || position === -1}
<span class="badge" style="left:auto; right:6px">Friend</span> <span class="badge">Main</span>
{/if} {/if}
</div> {#if item?.friend || position === 6}
{/key} <span class="badge" style="left:auto; right:6px">Friend</span>
{/if}
</div>
{/key}
{/snippet}
{#snippet menu()}
<ContextMenuBase.Item class="context-menu-item" onclick={viewDetails}>
View Details
</ContextMenuBase.Item>
{#if ctx?.canEdit()}
<ContextMenuBase.Item class="context-menu-item" onclick={replace}>
Replace
</ContextMenuBase.Item>
<ContextMenuBase.Separator class="context-menu-separator" />
<ContextMenuBase.Item class="context-menu-item danger" onclick={remove}>
Remove
</ContextMenuBase.Item>
{/if}
{/snippet}
</ContextMenu>
{:else}
{#key `empty-${position}`}
<div
class="frame summon"
class:main={position === -1}
class:friend={position === 6}
class:cell={!(position === -1 || position === 6)}
class:editable={ctx?.canEdit()}
onclick={() => ctx?.canEdit() && ctx?.openPicker && ctx.openPicker({ type: 'summon', position, item })}
>
<img
class="image placeholder"
alt=""
src={position === -1 || position === 6 ? '/images/placeholders/placeholder-summon-main.png' : '/images/placeholders/placeholder-summon-sub.png'}
/>
{#if ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
</div>
{/key}
{/if}
<div class="name">{item ? displayName(item?.summon || (item as any)?.object) : ''}</div> <div class="name">{item ? displayName(item?.summon || (item as any)?.object) : ''}</div>
</div> </div>

View file

@ -3,6 +3,8 @@
import type { Party } from '$lib/types/api/party' import type { Party } from '$lib/types/api/party'
import { getContext } from 'svelte' import { getContext } from 'svelte'
import Icon from '$lib/components/Icon.svelte' import Icon from '$lib/components/Icon.svelte'
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'
import { ContextMenu as ContextMenuBase } from 'bits-ui'
interface Props { interface Props {
item?: GridWeapon item?: GridWeapon
@ -57,40 +59,98 @@
async function remove() { async function remove() {
if (!item?.id) return if (!item?.id) return
const party = ctx.getParty() try {
const editKey = ctx.getEditKey() const party = ctx.getParty()
const updated = await ctx.services.gridService.removeWeapon(party.id, item.id as any, editKey || undefined) const editKey = ctx.getEditKey()
ctx.updateParty(updated) const updated = await ctx.services.gridService.removeWeapon(party.id, item.id as any, editKey || undefined)
if (updated) {
ctx.updateParty(updated)
}
} catch (err) {
console.error('Error removing weapon:', err)
}
}
function viewDetails() {
// TODO: Implement view details modal
console.log('View details for:', item)
}
function replace() {
if (ctx?.openPicker) {
ctx.openPicker({ type: 'weapon', position, item })
}
} }
</script> </script>
<div class="unit" class:empty={!item}> <div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)} {#if item}
<div <ContextMenu>
class="frame weapon" {#snippet children()}
class:main={item?.mainhand || position === -1} {#key (item as any).id ?? position}
class:cell={!(item?.mainhand || position === -1)} <div
class:editable={ctx?.canEdit()} class="frame weapon"
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'weapon', position, item })} class:main={item?.mainhand || position === -1}
> class:cell={!(item?.mainhand || position === -1)}
<img class="image" class:placeholder={!item || !(item?.weapon?.granblueId || (item as any)?.object?.granblueId)} alt={item ? displayName(item?.weapon || (item as any)?.object) : ''} src={imageUrl()} /> class:editable={ctx?.canEdit()}
{#if !item && ctx?.canEdit()} >
<span class="icon"> <img
<Icon name="plus" size={24} /> class="image"
</span> class:placeholder={!(item?.weapon?.granblueId || (item as any)?.object?.granblueId)}
{/if} alt={displayName(item?.weapon || (item as any)?.object)}
{#if ctx.canEdit() && item?.id} src={imageUrl()}
<div class="actions"> />
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button> {#if ctx?.canEdit() && item?.id}
</div> <div class="actions">
{/if} <button class="remove" title="Remove" onclick={(e) => { e.stopPropagation(); remove() }}>×</button>
{#if item?.mainhand || position === -1} </div>
<span class="badge">Main</span> {/if}
{/if} {#if item?.mainhand || position === -1}
</div> <span class="badge">Main</span>
{/key} {/if}
</div>
{/key}
{/snippet}
{#snippet menu()}
<ContextMenuBase.Item class="context-menu-item" onclick={viewDetails}>
View Details
</ContextMenuBase.Item>
{#if ctx?.canEdit()}
<ContextMenuBase.Item class="context-menu-item" onclick={replace}>
Replace
</ContextMenuBase.Item>
<ContextMenuBase.Separator class="context-menu-separator" />
<ContextMenuBase.Item class="context-menu-item danger" onclick={remove}>
Remove
</ContextMenuBase.Item>
{/if}
{/snippet}
</ContextMenu>
{:else}
{#key `empty-${position}`}
<div
class="frame weapon"
class:main={position === -1}
class:cell={position !== -1}
class:editable={ctx?.canEdit()}
onclick={() => ctx?.canEdit() && ctx?.openPicker && ctx.openPicker({ type: 'weapon', position, item })}
>
<img
class="image placeholder"
alt=""
src={position === -1 ? '/images/placeholders/placeholder-weapon-main.png' : '/images/placeholders/placeholder-weapon-grid.png'}
/>
{#if ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
</div>
{/key}
{/if}
<div class="name">{item ? displayName(item?.weapon || (item as any)?.object) : ''}</div> <div class="name">{item ? displayName(item?.weapon || (item as any)?.object) : ''}</div>
</div> </div>