add drag-drop support with API integration
- created drag-drop composable with touch/mouse support - added DraggableItem and DropZone components - integrated grids with drag-drop functionality - added API endpoints for position updates and swaps - handles cross-container dragging for all grid types
This commit is contained in:
parent
1d0495f1f2
commit
888e53fa62
5 changed files with 431 additions and 12 deletions
|
|
@ -536,6 +536,183 @@ export class APIClient {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update weapon position (drag-drop)
|
||||
*/
|
||||
async updateWeaponPosition(
|
||||
partyId: string,
|
||||
weaponId: string,
|
||||
position: number,
|
||||
container?: string
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_weapons/${weaponId}/position`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position,
|
||||
...(container ? { container } : {})
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update weapon position: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two weapons (drag-drop)
|
||||
*/
|
||||
async swapWeapons(partyId: string, sourceId: string, targetId: string): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_weapons/swap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to swap weapons: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update character position (drag-drop)
|
||||
*/
|
||||
async updateCharacterPosition(
|
||||
partyId: string,
|
||||
characterId: string,
|
||||
position: number,
|
||||
container?: string
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_characters/${characterId}/position`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position,
|
||||
...(container ? { container } : {})
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update character position: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two characters (drag-drop)
|
||||
*/
|
||||
async swapCharacters(partyId: string, sourceId: string, targetId: string): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_characters/swap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to swap characters: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update summon position (drag-drop)
|
||||
*/
|
||||
async updateSummonPosition(
|
||||
partyId: string,
|
||||
summonId: string,
|
||||
position: number,
|
||||
container?: string
|
||||
): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_summons/${summonId}/position`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position,
|
||||
...(container ? { container } : {})
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to update summon position: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap two summons (drag-drop)
|
||||
*/
|
||||
async swapSummons(partyId: string, sourceId: string, targetId: string): Promise<any> {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
|
||||
const response = await fetch(`/api/parties/${partyId}/grid_summons/swap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || `Failed to swap summons: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return transformResponse(data.party || data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local ID for anonymous users
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,18 +4,39 @@
|
|||
import type { GridCharacter } from '$lib/types/api/party'
|
||||
import { getContext } from 'svelte'
|
||||
import type { PartyContext } from '$lib/services/party.service'
|
||||
import type { DragDropContext } from '$lib/composables/drag-drop.svelte'
|
||||
import DraggableItem from '$lib/components/dnd/DraggableItem.svelte'
|
||||
import DropZone from '$lib/components/dnd/DropZone.svelte'
|
||||
|
||||
interface Props {
|
||||
characters?: GridCharacter[]
|
||||
mainWeaponElement?: number | null | undefined
|
||||
partyElement?: number | null | undefined
|
||||
container?: string
|
||||
}
|
||||
|
||||
let { characters = [], mainWeaponElement = undefined, partyElement = undefined }: Props = $props()
|
||||
let {
|
||||
characters = [],
|
||||
mainWeaponElement = undefined,
|
||||
partyElement = undefined,
|
||||
container = 'main-characters'
|
||||
}: Props = $props()
|
||||
|
||||
import CharacterUnit from '$lib/components/units/CharacterUnit.svelte'
|
||||
|
||||
const ctx = getContext<PartyContext>('party')
|
||||
const dragContext = getContext<DragDropContext | undefined>('drag-drop')
|
||||
|
||||
// Create array with proper empty slots
|
||||
let characterSlots = $derived(() => {
|
||||
const slots: (GridCharacter | undefined)[] = Array(5).fill(undefined)
|
||||
characters.forEach(char => {
|
||||
if (char.position >= 0 && char.position < 5) {
|
||||
slots[char.position] = char
|
||||
}
|
||||
})
|
||||
return slots
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
|
|
@ -23,14 +44,33 @@
|
|||
class="characters"
|
||||
aria-label="Character Grid"
|
||||
>
|
||||
{#each Array(5) as _, i}
|
||||
{@const character = characters.find((c) => c.position === i)}
|
||||
{#each characterSlots() as character, i}
|
||||
<li
|
||||
aria-label={`Character slot ${i}`}
|
||||
class:main-character={i === 0}
|
||||
class:Empty={!character}
|
||||
>
|
||||
<CharacterUnit item={character} position={i} {mainWeaponElement} {partyElement} />
|
||||
{#if dragContext}
|
||||
<DropZone
|
||||
{container}
|
||||
position={i}
|
||||
type="character"
|
||||
item={character}
|
||||
canDrop={ctx?.canEdit() ?? false}
|
||||
>
|
||||
<DraggableItem
|
||||
item={character}
|
||||
{container}
|
||||
position={i}
|
||||
type="character"
|
||||
canDrag={!!character && (ctx?.canEdit() ?? false)}
|
||||
>
|
||||
<CharacterUnit item={character} position={i} {mainWeaponElement} {partyElement} />
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{:else}
|
||||
<CharacterUnit item={character} position={i} {mainWeaponElement} {partyElement} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
import type { GridSummon } from '$lib/types/api/party'
|
||||
import { getContext } from 'svelte'
|
||||
import type { PartyContext } from '$lib/services/party.service'
|
||||
import type { DragDropContext } from '$lib/composables/drag-drop.svelte'
|
||||
import DraggableItem from '$lib/components/dnd/DraggableItem.svelte'
|
||||
import DropZone from '$lib/components/dnd/DropZone.svelte'
|
||||
|
||||
interface Props {
|
||||
summons?: GridSummon[]
|
||||
|
|
@ -15,9 +18,21 @@
|
|||
import ExtraSummons from '$lib/components/extra/ExtraSummonsGrid.svelte'
|
||||
|
||||
const ctx = getContext<PartyContext>('party')
|
||||
const dragContext = getContext<DragDropContext | undefined>('drag-drop')
|
||||
|
||||
let main = $derived(summons.find((s) => s.main || s.position === -1))
|
||||
let friend = $derived(summons.find((s) => s.friend || s.position === 6))
|
||||
|
||||
// Create array for sub-summons (positions 0-3)
|
||||
let subSummonSlots = $derived(() => {
|
||||
const slots: (GridSummon | undefined)[] = Array(4).fill(undefined)
|
||||
summons.forEach(summon => {
|
||||
if (summon.position >= 0 && summon.position < 4) {
|
||||
slots[summon.position] = summon
|
||||
}
|
||||
})
|
||||
return slots
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
|
|
@ -30,13 +45,32 @@
|
|||
<section>
|
||||
<div class="label">Summons</div>
|
||||
<ul class="summons">
|
||||
{#each Array(4) as _, i}
|
||||
{@const summon = summons.find((s) => s.position === i)}
|
||||
{#each subSummonSlots() as summon, i}
|
||||
<li
|
||||
aria-label={`Summon slot ${i}`}
|
||||
class:Empty={!summon}
|
||||
>
|
||||
<SummonUnit item={summon} position={i} />
|
||||
{#if dragContext}
|
||||
<DropZone
|
||||
container="main-summons"
|
||||
position={i}
|
||||
type="summon"
|
||||
item={summon}
|
||||
canDrop={ctx?.canEdit() ?? false}
|
||||
>
|
||||
<DraggableItem
|
||||
item={summon}
|
||||
container="main-summons"
|
||||
position={i}
|
||||
type="summon"
|
||||
canDrag={!!summon && (ctx?.canEdit() ?? false)}
|
||||
>
|
||||
<SummonUnit item={summon} position={i} />
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{:else}
|
||||
<SummonUnit item={summon} position={i} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
import type { GridWeapon } from '$lib/types/api/party'
|
||||
import { getContext } from 'svelte'
|
||||
import type { PartyContext } from '$lib/services/party.service'
|
||||
import type { DragDropContext } from '$lib/composables/drag-drop.svelte'
|
||||
import DraggableItem from '$lib/components/dnd/DraggableItem.svelte'
|
||||
import DropZone from '$lib/components/dnd/DropZone.svelte'
|
||||
|
||||
interface Props {
|
||||
weapons?: GridWeapon[]
|
||||
|
|
@ -24,8 +27,20 @@
|
|||
import Guidebooks from '$lib/components/extra/GuidebooksGrid.svelte'
|
||||
|
||||
const ctx = getContext<PartyContext>('party')
|
||||
const dragContext = getContext<DragDropContext | undefined>('drag-drop')
|
||||
|
||||
let mainhand = $derived(weapons.find((w) => (w as any).mainhand || w.position === -1))
|
||||
|
||||
// Create array for sub-weapons (positions 0-8)
|
||||
let subWeaponSlots = $derived(() => {
|
||||
const slots: (GridWeapon | undefined)[] = Array(9).fill(undefined)
|
||||
weapons.forEach(weapon => {
|
||||
if (weapon.position >= 0 && weapon.position < 9) {
|
||||
slots[weapon.position] = weapon
|
||||
}
|
||||
})
|
||||
return slots
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
|
|
@ -35,14 +50,33 @@
|
|||
</div>
|
||||
|
||||
<ul class="weapons" aria-label="Weapon Grid">
|
||||
{#each Array(9) as _, i}
|
||||
{@const weapon = weapons.find((w) => w.position === i)}
|
||||
{#each subWeaponSlots() as weapon, i}
|
||||
<li
|
||||
aria-label={weapon ? `Weapon ${i}` : `Empty slot ${i}`}
|
||||
data-index={i}
|
||||
class={weapon ? '' : 'Empty'}
|
||||
>
|
||||
<WeaponUnit item={weapon} position={i} />
|
||||
{#if dragContext}
|
||||
<DropZone
|
||||
container="main-weapons"
|
||||
position={i}
|
||||
type="weapon"
|
||||
item={weapon}
|
||||
canDrop={ctx?.canEdit() ?? false}
|
||||
>
|
||||
<DraggableItem
|
||||
item={weapon}
|
||||
container="main-weapons"
|
||||
position={i}
|
||||
type="weapon"
|
||||
canDrag={!!weapon && (ctx?.canEdit() ?? false)}
|
||||
>
|
||||
<WeaponUnit item={weapon} position={i} />
|
||||
</DraggableItem>
|
||||
</DropZone>
|
||||
{:else}
|
||||
<WeaponUnit item={weapon} position={i} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount, getContext, setContext } from 'svelte'
|
||||
import type { Party } from '$lib/types/api/party'
|
||||
import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
|
||||
import { PartyService } from '$lib/services/party.service'
|
||||
import { GridService } from '$lib/services/grid.service'
|
||||
import { ConflictService } from '$lib/services/conflict.service'
|
||||
import { apiClient } from '$lib/api/client'
|
||||
import { createDragDropContext, type DragOperation } from '$lib/composables/drag-drop.svelte'
|
||||
import WeaponGrid from '$lib/components/grids/WeaponGrid.svelte'
|
||||
import SummonGrid from '$lib/components/grids/SummonGrid.svelte'
|
||||
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
|
||||
|
|
@ -51,6 +52,136 @@
|
|||
const gridService = new GridService(fetch)
|
||||
const conflictService = new ConflictService(fetch)
|
||||
|
||||
// Create drag-drop context
|
||||
const dragContext = createDragDropContext({
|
||||
onLocalUpdate: async (operation) => {
|
||||
console.log('📝 Drag operation:', operation)
|
||||
await handleDragOperation(operation)
|
||||
},
|
||||
onValidate: (source, target) => {
|
||||
// Type must match
|
||||
if (source.type !== target.type) return false
|
||||
|
||||
// Characters: Sequential filling
|
||||
if (source.type === 'character' && target.container === 'main-characters') {
|
||||
// For now, allow any position (we'll handle sequential filling in the operation)
|
||||
return true
|
||||
}
|
||||
|
||||
// Weapons: Mainhand not draggable
|
||||
if (target.type === 'weapon' && target.position === -1) return false
|
||||
|
||||
// Summons: Main/Friend not draggable
|
||||
if (target.type === 'summon' && (target.position === -1 || target.position === 6)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle drag operations
|
||||
async function handleDragOperation(operation: DragOperation) {
|
||||
if (!canEdit()) return
|
||||
|
||||
const { source, target } = operation
|
||||
|
||||
try {
|
||||
loading = true
|
||||
let updated: Party | null = null
|
||||
|
||||
if (operation.type === 'swap') {
|
||||
// Handle swapping items between positions
|
||||
updated = await handleSwap(source, target)
|
||||
} else if (operation.type === 'move') {
|
||||
// Handle moving to empty position
|
||||
updated = await handleMove(source, target)
|
||||
}
|
||||
|
||||
// Update party with returned data from API
|
||||
if (updated) {
|
||||
party = updated
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to update party'
|
||||
console.error('Drag operation failed:', err)
|
||||
} finally {
|
||||
loading = false
|
||||
dragContext.clearQueue()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwap(source: any, target: any): Promise<Party> {
|
||||
if (!party.id || party.id === 'new') {
|
||||
throw new Error('Cannot swap items in unsaved party')
|
||||
}
|
||||
|
||||
// Both source and target should have items for swap
|
||||
if (!source.itemId || !target.itemId) {
|
||||
throw new Error('Invalid swap operation - missing items')
|
||||
}
|
||||
|
||||
// Call appropriate API based on type
|
||||
if (source.type === 'weapon') {
|
||||
const updated = await apiClient.swapWeapons(party.id, source.itemId, target.itemId)
|
||||
return updated
|
||||
} else if (source.type === 'character') {
|
||||
const updated = await apiClient.swapCharacters(party.id, source.itemId, target.itemId)
|
||||
return updated
|
||||
} else if (source.type === 'summon') {
|
||||
const updated = await apiClient.swapSummons(party.id, source.itemId, target.itemId)
|
||||
return updated
|
||||
}
|
||||
|
||||
throw new Error(`Unknown item type: ${source.type}`)
|
||||
}
|
||||
|
||||
async function handleMove(source: any, target: any): Promise<Party> {
|
||||
if (!party.id || party.id === 'new') {
|
||||
throw new Error('Cannot move items in unsaved party')
|
||||
}
|
||||
|
||||
// Source should have an item, target should be empty
|
||||
if (!source.itemId || target.itemId) {
|
||||
throw new Error('Invalid move operation')
|
||||
}
|
||||
|
||||
// Determine container based on target position
|
||||
let container: string | undefined
|
||||
|
||||
if (source.type === 'character') {
|
||||
// Characters: positions 0-4 are main, 5-6 are extra
|
||||
container = target.position >= 5 ? 'extra' : 'main'
|
||||
const updated = await apiClient.updateCharacterPosition(
|
||||
party.id,
|
||||
source.itemId,
|
||||
target.position,
|
||||
container
|
||||
)
|
||||
return updated
|
||||
} else if (source.type === 'weapon') {
|
||||
// Weapons: positions 0-8 are main, 9+ are extra
|
||||
container = target.position >= 9 ? 'extra' : 'main'
|
||||
const updated = await apiClient.updateWeaponPosition(
|
||||
party.id,
|
||||
source.itemId,
|
||||
target.position,
|
||||
container
|
||||
)
|
||||
return updated
|
||||
} else if (source.type === 'summon') {
|
||||
// Summons: positions 0-3 are sub, 4-5 are subaura
|
||||
container = target.position >= 4 ? 'subaura' : 'main'
|
||||
const updated = await apiClient.updateSummonPosition(
|
||||
party.id,
|
||||
source.itemId,
|
||||
target.position,
|
||||
container
|
||||
)
|
||||
return updated
|
||||
}
|
||||
|
||||
throw new Error(`Unknown item type: ${source.type}`)
|
||||
}
|
||||
|
||||
// Localized name helper: accepts either an object with { name: { en, ja } }
|
||||
// or a direct { en, ja } map, or a plain string.
|
||||
function displayName(input: any): string {
|
||||
|
|
@ -329,7 +460,7 @@
|
|||
// Provide services to child components via context
|
||||
setContext('party', {
|
||||
getParty: () => party,
|
||||
updateParty: (p: PartyView) => party = p,
|
||||
updateParty: (p: Party) => party = p,
|
||||
canEdit: () => canEdit(),
|
||||
getEditKey: () => editKey,
|
||||
services: {
|
||||
|
|
@ -346,6 +477,9 @@
|
|||
pickerOpen = true
|
||||
}
|
||||
})
|
||||
|
||||
// Provide drag-drop context to child components
|
||||
setContext('drag-drop', dragContext)
|
||||
</script>
|
||||
|
||||
<div class="page-wrap" class:with-panel={pickerOpen}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue