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:
Justin Edmund 2025-09-16 03:45:22 -07:00
parent 1d0495f1f2
commit 888e53fa62
5 changed files with 431 additions and 12 deletions

View file

@ -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
*/

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}>