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 SearchSidebar from '$lib/components/panels/SearchSidebar.svelte'
import type { SearchResult } from '$lib/api/resources/search'
import Dialog from '$lib/components/ui/Dialog.svelte'
interface Props {
party?: Party
@ -42,6 +43,8 @@
let pickerOpen = $state(false)
let pickerTitle = $state('Search')
let selectedSlot = $state<number>(0)
let editDialogOpen = $state(false)
let editingTitle = $state('')
// Services
const partyService = new PartyService(fetch)
@ -93,23 +96,49 @@
function selectTab(key: typeof tabs[number]['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
async function updatePartyDetails(updates: Partial<Party>) {
if (!canEdit()) return
if (!canEdit()) return null
loading = true
error = null
try {
const updated = await partyService.update(
party.id,
updates,
editKey || undefined
)
// Use apiClient for client-side updates (handles edit keys automatically)
const updated = await apiClient.updateParty(party.id, updates)
party = updated
return updated
} catch (err: any) {
error = err.message || 'Failed to update party'
return null
} finally {
loading = false
}
@ -263,22 +292,37 @@
// Create client-side wrappers for grid operations using API client
const clientGridService = {
async removeWeapon(partyId: string, gridWeaponId: string, _editKey?: string) {
await apiClient.removeWeapon(partyId, gridWeaponId)
// Reload party data
const updated = await partyService.getByShortcode(party.shortcode)
return updated
try {
await apiClient.removeWeapon(partyId, gridWeaponId)
// Reload party data
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) {
await apiClient.removeSummon(partyId, gridSummonId)
// Reload party data
const updated = await partyService.getByShortcode(party.shortcode)
return updated
try {
await apiClient.removeSummon(partyId, gridSummonId)
// Reload party data
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) {
await apiClient.removeCharacter(partyId, gridCharacterId)
// Reload party data
const updated = await partyService.getByShortcode(party.shortcode)
return updated
try {
await apiClient.removeCharacter(partyId, gridCharacterId)
// Reload party data
const updated = await partyService.getByShortcode(party.shortcode)
return updated
} catch (err) {
console.error('Failed to remove character:', err)
throw err
}
}
}
@ -314,23 +358,34 @@
<p class="description">{party.description}</p>
{/if}
</div>
<div class="party-actions">
{#if canEdit()}
<button
class="edit-btn"
onclick={openEditDialog}
disabled={loading}
aria-label="Edit party details"
>
Edit
</button>
{/if}
{#if authUserId}
<button
<button
class="favorite-btn"
class:favorited={party.favorited}
on:click={toggleFavorite}
onclick={toggleFavorite}
disabled={loading}
aria-label={party.favorited ? 'Remove from favorites' : 'Add to favorites'}
>
{party.favorited ? '★' : '☆'}
</button>
{/if}
<button
<button
class="remix-btn"
on:click={remixParty}
onclick={remixParty}
disabled={loading}
aria-label="Remix this party"
>
@ -366,7 +421,7 @@
class="tab-btn"
aria-pressed={activeTab === t.key}
class:active={activeTab === t.key}
on:click={() => selectTab(t.key)}
onclick={() => selectTab(t.key)}
>
{t.label}
{#if t.count > 0}
@ -410,6 +465,39 @@
</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>
.page-wrap { position: relative; --panel-w: 380px; overflow-x: auto; }
.track { display: flex; gap: 0; align-items: flex-start; }
@ -437,6 +525,7 @@
gap: 0.5rem;
}
.edit-btn,
.favorite-btn,
.remix-btn {
padding: 0.5rem 1rem;
@ -446,6 +535,10 @@
cursor: pointer;
transition: all 0.2s;
}
.edit-btn {
padding: 0.5rem 1rem;
}
.favorite-btn {
font-size: 1.2rem;
@ -456,11 +549,13 @@
color: gold;
}
.edit-btn:hover,
.favorite-btn:hover,
.remix-btn:hover {
background: #f5f5f5;
}
.edit-btn:disabled,
.favorite-btn:disabled,
.remix-btn:disabled {
opacity: 0.5;
@ -561,4 +656,75 @@
max-width: 400px;
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>

View file

@ -3,6 +3,8 @@
import type { Party } from '$lib/types/api/party'
import { getContext } from '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 {
item?: GridCharacter
@ -55,35 +57,92 @@
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.removeCharacter(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
try {
const party = ctx.getParty()
const editKey = ctx.getEditKey()
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>
<div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'character', position, item })}
>
<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()} />
{#if !item && ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button>
</div>
{/if}
</div>
{/key}
{#if item}
<ContextMenu>
{#snippet children()}
{#key (item as any).id ?? position}
<div
class="frame character cell"
class:editable={ctx?.canEdit()}
onclick={() => ctx?.canEdit() && replace()}
>
<img
class="image"
class:placeholder={!(item?.character?.granblueId || (item as any)?.object?.granblueId)}
alt={displayName(item?.character || (item as any)?.object)}
src={imageUrl()}
/>
{#if ctx?.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" onclick={(e) => { e.stopPropagation(); remove() }}>×</button>
</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>

View file

@ -3,6 +3,8 @@
import type { Party } from '$lib/types/api/party'
import { getContext } from '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 {
item?: GridSummon
@ -49,44 +51,104 @@
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.removeSummon(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
try {
const party = ctx.getParty()
const editKey = ctx.getEditKey()
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>
<div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)}
<div
class="frame summon"
class:main={item?.main || position === -1}
class:friend={item?.friend || position === 6}
class:cell={!((item?.main || position === -1) || (item?.friend || position === 6))}
class:editable={ctx?.canEdit()}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'summon', position, item })}
>
<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()} />
{#if !item && ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button>
</div>
{/if}
{#if item?.main || position === -1}
<span class="badge">Main</span>
{/if}
{#if item?.friend || position === 6}
<span class="badge" style="left:auto; right:6px">Friend</span>
{/if}
</div>
{/key}
{#if item}
<ContextMenu>
{#snippet children()}
{#key (item as any).id ?? position}
<div
class="frame summon"
class:main={item?.main || position === -1}
class:friend={item?.friend || position === 6}
class:cell={!((item?.main || position === -1) || (item?.friend || position === 6))}
class:editable={ctx?.canEdit()}
onclick={() => ctx?.canEdit() && replace()}
>
<img
class="image"
class:placeholder={!(item?.summon?.granblueId || (item as any)?.object?.granblueId)}
alt={displayName(item?.summon || (item as any)?.object)}
src={imageUrl()}
/>
{#if ctx?.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" onclick={(e) => { e.stopPropagation(); remove() }}>×</button>
</div>
{/if}
{#if item?.main || position === -1}
<span class="badge">Main</span>
{/if}
{#if item?.friend || position === 6}
<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>

View file

@ -3,6 +3,8 @@
import type { Party } from '$lib/types/api/party'
import { getContext } from '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 {
item?: GridWeapon
@ -57,40 +59,98 @@
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.getEditKey()
const updated = await ctx.services.gridService.removeWeapon(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
try {
const party = ctx.getParty()
const editKey = ctx.getEditKey()
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>
<div class="unit" class:empty={!item}>
{#key (item ? (item as any).id ?? position : `empty-${position}`)}
<div
class="frame weapon"
class:main={item?.mainhand || position === -1}
class:cell={!(item?.mainhand || position === -1)}
class:editable={ctx?.canEdit()}
on:click={() => ctx?.openPicker && ctx.openPicker({ type: 'weapon', position, item })}
>
<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()} />
{#if !item && ctx?.canEdit()}
<span class="icon">
<Icon name="plus" size={24} />
</span>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" on:click|stopPropagation={remove}>×</button>
</div>
{/if}
{#if item?.mainhand || position === -1}
<span class="badge">Main</span>
{/if}
</div>
{/key}
{#if item}
<ContextMenu>
{#snippet children()}
{#key (item as any).id ?? position}
<div
class="frame weapon"
class:main={item?.mainhand || position === -1}
class:cell={!(item?.mainhand || position === -1)}
class:editable={ctx?.canEdit()}
>
<img
class="image"
class:placeholder={!(item?.weapon?.granblueId || (item as any)?.object?.granblueId)}
alt={displayName(item?.weapon || (item as any)?.object)}
src={imageUrl()}
/>
{#if ctx?.canEdit() && item?.id}
<div class="actions">
<button class="remove" title="Remove" onclick={(e) => { e.stopPropagation(); remove() }}>×</button>
</div>
{/if}
{#if item?.mainhand || position === -1}
<span class="badge">Main</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 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>