Low-risk cleanup: unused imports, gitignore, auth types, test routes (#439)

## Summary

This PR performs low-risk cleanup tasks on the svelte-main branch:

1. **Remove unused imports** from `base.adapter.ts` - `snakeToCamel` and
`camelToSnake` were imported but never used
2. **Add `/.next` to `.gitignore`** - This is a SvelteKit project but
had a stale Next.js build artifact showing in git status
3. **Fix broken auth import** - `map.ts` was importing
`UserInfoResponse` from `$lib/api/resources/users` which never existed.
Created the type in `oauth.ts` based on actual usage in `buildCookies()`
4. **Remove test/example routes** - Deleted development scaffolding that
had no references elsewhere:
   - `src/lib/components/examples/SearchExample.svelte`
   - `src/routes/test-sidebar/+page.svelte`
   - `src/routes/test/drag-drop/+page.svelte`

## Review & Testing Checklist for Human

- [ ] **Verify `UserInfoResponse` type matches actual API response** - I
inferred the type from usage in `map.ts`, but haven't verified against
the actual `/users/info` endpoint response from hensei-api. Fields:
`id`, `username`, `role`, `avatar.picture`, `avatar.element`,
`language`, `gender`, `theme`
- [ ] **Confirm test routes are not needed** - These appeared to be dev
scaffolding with no code references, but verify they're not used in any
manual QA workflows
- [ ] **Test auth flow** - Login/signup should still work correctly with
the new type location

**Recommended test plan:** Log in to the app and verify user info
(avatar, language, theme preferences) loads correctly after
authentication.

### Notes

- The broken `$lib/api/resources/users` import was pre-existing (the
file never existed), not caused by the previous API layer cleanup PR
- Running `pnpm check` confirms the auth/map error is resolved;
remaining type errors are unrelated pre-existing issues

Link to Devin run:
https://app.devin.ai/sessions/611580bc2db94e20a48c3692d3cbd432
Requested by: Justin Edmund (justin@jedmund.com) / @jedmund

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Justin Edmund <justin@jedmund.com>
This commit is contained in:
devin-ai-integration[bot] 2025-11-28 11:21:24 -08:00 committed by GitHub
parent 9b54039a15
commit 7a0e3b4f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 18 additions and 1372 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ node_modules
.netlify
.wrangler
/.svelte-kit
/.next
/build
# OS

View file

@ -8,7 +8,7 @@
* @module adapters/base
*/
import { snakeToCamel, camelToSnake, transformResponse, transformRequest } from '../schemas/transforms'
import { transformResponse, transformRequest } from '../schemas/transforms'
import type { AdapterOptions, RequestOptions, AdapterError } from './types'
import {
createErrorFromStatus,

View file

@ -1,5 +1,4 @@
import type { OAuthLoginResponse } from './oauth'
import type { UserInfoResponse } from '$lib/api/resources/users'
import type { OAuthLoginResponse, UserInfoResponse } from './oauth'
import type { AccountCookie } from '$lib/types/AccountCookie'
import type { UserCookie } from '$lib/types/UserCookie'

View file

@ -13,6 +13,20 @@ export interface OAuthLoginResponse {
}
}
// Response from user info endpoint used during auth flow
export interface UserInfoResponse {
id: string
username: string
role: number
avatar: {
picture: string | null
element: string | null
}
language: string | null
gender: number | null
theme: string | null
}
export async function passwordGrantLogin(
fetchFn: typeof fetch,
body: { email: string; password: string; grant_type: 'password' }

View file

@ -1,430 +0,0 @@
<!--
Example Component: Search with Adapters
This component demonstrates how to use the SearchAdapter and SearchResource
for reactive search functionality with Svelte 5 runes.
-->
<script lang="ts">
import { createSearchResource } from '$lib/api/adapters'
import type { SearchResult } from '$lib/api/adapters'
// Create a search resource with debouncing
const search = createSearchResource({
debounceMs: 300,
initialParams: {
locale: 'en',
per: 20
}
})
// Reactive state for the search query
let query = $state('')
let selectedType = $state<'all' | 'weapons' | 'characters' | 'summons'>('all')
let selectedElement = $state<number[]>([])
let selectedRarity = $state<number[]>([])
// Element and rarity options
const elements = [
{ value: 1, label: '🔥 Fire' },
{ value: 2, label: '💧 Water' },
{ value: 3, label: '🌍 Earth' },
{ value: 4, label: '🌪️ Wind' },
{ value: 5, label: '⚡ Light' },
{ value: 6, label: '🌙 Dark' }
]
const rarities = [
{ value: 3, label: 'SSR' },
{ value: 2, label: 'SR' },
{ value: 1, label: 'R' }
]
// Reactive search effect
$effect(() => {
const params = {
query,
filters: {
element: selectedElement.length > 0 ? selectedElement : undefined,
rarity: selectedRarity.length > 0 ? selectedRarity : undefined
}
}
// Perform search based on selected type
switch (selectedType) {
case 'all':
search.searchAll(params)
break
case 'weapons':
search.searchWeapons(params)
break
case 'characters':
search.searchCharacters(params)
break
case 'summons':
search.searchSummons(params)
break
}
})
// Get current search state based on selected type
$derived.by(() => {
switch (selectedType) {
case 'all':
return search.all
case 'weapons':
return search.weapons
case 'characters':
return search.characters
case 'summons':
return search.summons
}
}) as currentSearch
// Format result for display
function getResultIcon(result: SearchResult): string {
switch (result.searchableType) {
case 'Weapon':
return '⚔️'
case 'Character':
return '👤'
case 'Summon':
return '🐉'
default:
return '❓'
}
}
function getElementIcon(element?: number): string {
return elements.find(e => e.value === element)?.label.split(' ')[0] || ''
}
</script>
<div class="search-example">
<h2>Search Example with Adapters</h2>
<div class="search-controls">
<div class="search-input-group">
<input
type="text"
bind:value={query}
placeholder="Search for items..."
class="search-input"
/>
<select bind:value={selectedType} class="type-selector">
<option value="all">All Types</option>
<option value="weapons">Weapons Only</option>
<option value="characters">Characters Only</option>
<option value="summons">Summons Only</option>
</select>
</div>
<div class="filters">
<div class="filter-group">
<label>Elements:</label>
<div class="checkbox-group">
{#each elements as element}
<label class="checkbox-label">
<input
type="checkbox"
value={element.value}
on:change={(e) => {
if (e.currentTarget.checked) {
selectedElement = [...selectedElement, element.value]
} else {
selectedElement = selectedElement.filter(v => v !== element.value)
}
}}
/>
<span>{element.label}</span>
</label>
{/each}
</div>
</div>
<div class="filter-group">
<label>Rarity:</label>
<div class="checkbox-group">
{#each rarities as rarity}
<label class="checkbox-label">
<input
type="checkbox"
value={rarity.value}
on:change={(e) => {
if (e.currentTarget.checked) {
selectedRarity = [...selectedRarity, rarity.value]
} else {
selectedRarity = selectedRarity.filter(v => v !== rarity.value)
}
}}
/>
<span>{rarity.label}</span>
</label>
{/each}
</div>
</div>
</div>
<div class="action-buttons">
<button onclick={() => search.clearAll()}>Clear All Results</button>
<button onclick={() => search.clearCache()}>Clear Cache</button>
</div>
</div>
<div class="search-results">
{#if currentSearch.loading}
<div class="loading">
<p>Searching...</p>
</div>
{:else if currentSearch.error}
<div class="error">
<p>❌ Error: {currentSearch.error.message}</p>
<p class="error-code">Code: {currentSearch.error.code}</p>
</div>
{:else if currentSearch.data}
{#if currentSearch.data.results.length === 0}
<div class="no-results">
<p>No results found</p>
</div>
{:else}
<div class="results-header">
<p>Found {currentSearch.data.total || currentSearch.data.results.length} results</p>
{#if currentSearch.data.totalPages && currentSearch.data.totalPages > 1}
<p class="pagination-info">
Page {currentSearch.data.page || 1} of {currentSearch.data.totalPages}
</p>
{/if}
</div>
<div class="results-grid">
{#each currentSearch.data.results as result}
<div class="result-card">
<div class="result-header">
<span class="result-icon">{getResultIcon(result)}</span>
<span class="result-element">{getElementIcon(result.element)}</span>
</div>
<h3 class="result-name">{result.name.en || result.name.ja || 'Unknown'}</h3>
<div class="result-meta">
<span class="result-type">{result.searchableType}</span>
{#if result.rarity}
<span class="result-rarity rarity-{result.rarity}">
{result.rarity === 3 ? 'SSR' : result.rarity === 2 ? 'SR' : 'R'}
</span>
{/if}
</div>
{#if result.imageUrl}
<img src={result.imageUrl} alt={result.name.en} class="result-image" />
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<div class="empty-state">
<p>Enter a search term to begin</p>
</div>
{/if}
</div>
</div>
<style>
.search-example {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
h2 {
margin-bottom: 1.5rem;
color: #333;
}
.search-controls {
margin-bottom: 2rem;
}
.search-input-group {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.search-input {
flex: 1;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.type-selector {
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.filters {
display: flex;
gap: 2rem;
margin: 1rem 0;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
}
.filter-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: normal;
}
.action-buttons {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
button {
padding: 0.5rem 1rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
button:hover {
background: #0056b3;
}
.search-results {
min-height: 200px;
}
.loading, .error, .no-results, .empty-state {
padding: 2rem;
text-align: center;
background: #f8f9fa;
border-radius: 4px;
}
.error {
background: #fee;
color: #c00;
}
.error-code {
font-size: 0.875rem;
margin-top: 0.5rem;
opacity: 0.8;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.5rem;
background: #e9ecef;
border-radius: 4px;
}
.pagination-info {
font-size: 0.875rem;
color: #666;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.result-card {
padding: 1rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
transition: transform 0.2s;
}
.result-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.result-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.result-icon, .result-element {
font-size: 1.25rem;
}
.result-name {
font-size: 1rem;
margin: 0.5rem 0;
color: #333;
}
.result-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.result-type {
color: #666;
}
.result-rarity {
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-weight: bold;
font-size: 0.75rem;
}
.rarity-3 {
background: linear-gradient(135deg, #ffd700, #ffed4e);
color: #333;
}
.rarity-2 {
background: linear-gradient(135deg, #c0c0c0, #e8e8e8);
color: #333;
}
.rarity-1 {
background: linear-gradient(135deg, #cd7f32, #e4a05e);
color: white;
}
.result-image {
width: 100%;
height: auto;
margin-top: 0.5rem;
border-radius: 4px;
}
</style>

View file

@ -1,250 +0,0 @@
<svelte:options runes={true} />
<script lang="ts">
import { sidebar } from '$lib/stores/sidebar.svelte'
import Button from '$lib/components/ui/Button.svelte'
import { openSearchSidebar } from '$lib/features/search/openSearchSidebar.svelte'
import type { SearchResult } from '$lib/api/adapters/search.adapter'
let selectedItems = $state<SearchResult[]>([])
function handleAddItems(items: SearchResult[]) {
selectedItems = [...selectedItems, ...items]
console.log('Added items:', items)
}
function openWeaponSearch() {
openSearchSidebar({
type: 'weapon',
onAddItems: handleAddItems,
canAddMore: true
})
}
function openCharacterSearch() {
openSearchSidebar({
type: 'character',
onAddItems: handleAddItems,
canAddMore: true
})
}
function openSummonSearch() {
openSearchSidebar({
type: 'summon',
onAddItems: handleAddItems,
canAddMore: true
})
}
function openDetailsSidebar() {
sidebar.open('Item Details', detailsContent)
}
function openFilterSidebar() {
sidebar.open('Filters', filterContent)
}
</script>
<div class="container">
<h1>Sidebar Test Page</h1>
<p>Click the buttons below to test different sidebar configurations:</p>
<div class="button-group">
<Button variant="primary" onclick={openWeaponSearch}>
Search Weapons
</Button>
<Button variant="primary" onclick={openCharacterSearch}>
Search Characters
</Button>
<Button variant="primary" onclick={openSummonSearch}>
Search Summons
</Button>
<Button variant="secondary" onclick={openDetailsSidebar}>
Open Details Sidebar
</Button>
<Button variant="secondary" onclick={openFilterSidebar}>
Open Filter Sidebar
</Button>
</div>
{#if selectedItems.length > 0}
<div class="selected-items">
<h3>Selected Items ({selectedItems.length})</h3>
<ul>
{#each selectedItems as item}
<li>{item.name?.en || item.name?.ja || item.name || 'Unknown'}</li>
{/each}
</ul>
</div>
{/if}
<div class="content">
<h2>Main Content Area</h2>
<p>This content will shrink when the sidebar opens, creating a two-pane layout.</p>
<p>All sidebars have a standard width of 420px for consistency.</p>
<p>On mobile devices, the sidebar will overlay the main content instead of shrinking it.</p>
</div>
</div>
{#snippet detailsContent()}
<div class="sidebar-demo-content">
<h3>Item Name</h3>
<p>This is a detailed view of an item with lots of information.</p>
<div class="detail-section">
<h4>Statistics</h4>
<ul>
<li>Attack: 1000</li>
<li>HP: 500</li>
<li>Element: Fire</li>
</ul>
</div>
<div class="detail-section">
<h4>Description</h4>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
</div>
</div>
{/snippet}
{#snippet filterContent()}
<div class="sidebar-demo-content">
<div class="filter-group">
<h4>Element</h4>
<label><input type="checkbox" /> Fire</label>
<label><input type="checkbox" /> Water</label>
<label><input type="checkbox" /> Earth</label>
<label><input type="checkbox" /> Wind</label>
</div>
<div class="filter-group">
<h4>Rarity</h4>
<label><input type="checkbox" /> SSR</label>
<label><input type="checkbox" /> SR</label>
<label><input type="checkbox" /> R</label>
</div>
<Button variant="primary" fullWidth onclick={() => sidebar.close()}>
Apply Filters
</Button>
</div>
{/snippet}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.container {
padding: $unit-3x;
max-width: 1200px;
margin: 0 auto;
}
.button-group {
display: flex;
gap: $unit;
margin: $unit-2x 0;
flex-wrap: wrap;
}
.selected-items {
margin: $unit-2x 0;
padding: $unit-2x;
background: var(--bg-secondary);
border-radius: 8px;
h3 {
margin: 0 0 $unit 0;
font-size: $font-medium;
color: var(--text-primary);
}
ul {
margin: 0;
padding-left: $unit-2x;
li {
color: var(--text-secondary);
margin-bottom: $unit-half;
}
}
}
.content {
margin-top: $unit-3x;
padding: $unit-2x;
background: var(--bg-secondary);
border-radius: 8px;
}
:global(.sidebar-demo-content) {
.search-input {
width: 100%;
padding: $unit;
border: 1px solid var(--border-primary);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: $font-regular;
margin-bottom: $unit-2x;
&:focus {
outline: none;
border-color: var(--accent-blue);
}
}
.search-results {
display: flex;
flex-direction: column;
gap: $unit;
}
.result-item {
padding: $unit;
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
&:hover {
background: var(--bg-tertiary);
}
}
.detail-section {
margin: $unit-2x 0;
h4 {
margin-bottom: $unit;
color: var(--text-secondary);
}
ul {
list-style: none;
padding: 0;
margin: 0;
li {
padding: $unit-half 0;
}
}
}
.filter-group {
margin-bottom: $unit-2x;
h4 {
margin-bottom: $unit;
color: var(--text-secondary);
}
label {
display: block;
padding: $unit-half 0;
cursor: pointer;
input {
margin-right: $unit;
}
}
}
}
</style>

View file

@ -1,688 +0,0 @@
<svelte:options runes={true} />
<script lang="ts">
import { setContext } from 'svelte'
import { createDragDropContext, type DragOperation } from '$lib/composables/drag-drop.svelte'
import type { GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import DraggableItem from '$lib/components/dnd/DraggableItem.svelte'
import DropZone from '$lib/components/dnd/DropZone.svelte'
// Create mock data
const mockCharacters = [
{ id: '1', name: { en: 'Katalina', ja: 'カタリナ' }, granblueId: 3040001000 },
{ id: '2', name: { en: 'Rosetta', ja: 'ロゼッタ' }, granblueId: 3040002000 },
{ id: '3', name: { en: 'Io', ja: 'イオ' }, granblueId: 3040003000 },
{ id: '4', name: { en: 'Rackam', ja: 'ラカム' }, granblueId: 3040004000 },
{ id: '5', name: { en: 'Ferry', ja: 'フェリ' }, granblueId: 3040005000 }
]
const mockWeapons = [
{ id: 'w1', name: { en: 'Murgleis', ja: 'ミュルグレス' }, granblueId: 1040001000 },
{ id: 'w2', name: { en: 'Love Eternal', ja: 'ラブ・エターナル' }, granblueId: 1040002000 },
{ id: 'w3', name: { en: 'Certificus', ja: 'ケルティケウス' }, granblueId: 1040003000 },
{ id: 'w4', name: { en: 'Blue Sphere', ja: 'ブルースフィア' }, granblueId: 1040004000 },
{ id: 'w5', name: { en: 'Ichigo Hitofuri', ja: '一期一振' }, granblueId: 1040005000 }
]
const mockSummons = [
{ id: 's1', name: { en: 'Bahamut', ja: 'バハムート' }, granblueId: 2040001000 },
{ id: 's2', name: { en: 'Lucifer', ja: 'ルシフェル' }, granblueId: 2040002000 },
{ id: 's3', name: { en: 'Europa', ja: 'エウロペ' }, granblueId: 2040003000 },
{ id: 's4', name: { en: 'Shiva', ja: 'シヴァ' }, granblueId: 2040004000 }
]
// Grid states
let characters = $state<(GridCharacter | undefined)[]>([
{ id: 'gc1', position: 0, character: mockCharacters[0] },
{ id: 'gc2', position: 1, character: mockCharacters[1] },
{ id: 'gc3', position: 2, character: mockCharacters[2] },
undefined,
undefined
])
let weapons = $state<(GridWeapon | undefined)[]>([
{ id: 'gw1', position: -1, mainhand: true, weapon: mockWeapons[0] }, // Mainhand
{ id: 'gw2', position: 0, weapon: mockWeapons[1] },
undefined,
{ id: 'gw3', position: 2, weapon: mockWeapons[2] },
undefined,
undefined,
{ id: 'gw4', position: 5, weapon: mockWeapons[3] },
undefined,
undefined,
undefined
])
let summons = $state<(GridSummon | undefined)[]>([
{ id: 'gs1', position: -1, main: true, summon: mockSummons[0] }, // Main
{ id: 'gs2', position: 0, summon: mockSummons[1] },
undefined,
{ id: 'gs3', position: 2, summon: mockSummons[2] },
undefined,
undefined, // positions 4-5 for subaura
{ id: 'gs4', position: 6, friend: true, summon: mockSummons[3] } // Friend
])
// Extra containers
let extraCharacters = $state<(GridCharacter | undefined)[]>([
{ id: 'egc1', position: 5, character: mockCharacters[3] },
{ id: 'egc2', position: 6, character: mockCharacters[4] }
])
let subauras = $state<(GridSummon | undefined)[]>([
undefined,
undefined
])
let extraWeapons = $state<(GridWeapon | undefined)[]>([
{ id: 'egw1', position: 9, weapon: mockWeapons[4] },
undefined,
undefined
])
// Operation tracking
let operations = $state<DragOperation[]>([])
// Create drag-drop context
const dragContext = createDragDropContext({
onLocalUpdate: (operation) => {
console.log('📝 Local update:', operation)
operations = [...operations, operation]
handleOperation(operation)
// Clear the operation from queue after processing
setTimeout(() => {
const processed = dragContext.getQueuedOperations()
if (processed.length > 0) {
// Mark as processed by clearing from context
dragContext.clearQueue()
}
}, 100)
},
onValidate: (source, target) => {
// Custom validation rules
if (source.type !== target.type) return false
// Characters: Sequential filling
if (source.type === 'character' && target.container === 'main-characters') {
// Allow drops only in sequential order
const filledCount = characters.filter(c => c).length
if (target.position >= filledCount) return false
}
// 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
}
})
setContext('drag-drop', dragContext)
function handleOperation(operation: DragOperation) {
const { source, target } = operation
if (operation.type === 'swap') {
handleSwap(source, target)
} else if (operation.type === 'move') {
handleMove(source, target)
}
}
function handleSwap(source: any, target: any) {
console.log('🔄 Swapping:', source, target)
// Get container info with position mapping
const sourceInfo = getContainerInfo(source.container, source.position)
const targetInfo = getContainerInfo(target.container, target.position)
if (!sourceInfo || !targetInfo) {
console.error('Invalid container', source.container, target.container)
return
}
// Find the items using mapped indices
const sourceItem = sourceInfo.array[sourceInfo.index]
const targetItem = targetInfo.array[targetInfo.index]
if (!sourceItem) {
console.error('Source item not found')
return
}
// If there's no target item, this is actually a move, not a swap
if (!targetItem) {
handleMove(source, target)
return
}
// Perform the swap
if (sourceInfo.container === target.container) {
// Same container - update the appropriate array
const updatedArray = getUpdatedArray(source.container, (arr) => {
const temp = [...arr]
temp[sourceInfo.index] = targetItem
temp[targetInfo.index] = sourceItem
// Preserve the original position properties
if (temp[sourceInfo.index]) temp[sourceInfo.index].position = source.position
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
return temp
})
setArrayForContainer(source.container, updatedArray)
} else {
// Different containers - cross-container swap
const updatedSource = getUpdatedArray(source.container, (arr) => {
const temp = [...arr]
temp[sourceInfo.index] = targetItem
if (temp[sourceInfo.index]) temp[sourceInfo.index].position = source.position
return temp
})
const updatedTarget = getUpdatedArray(target.container, (arr) => {
const temp = [...arr]
temp[targetInfo.index] = sourceItem
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
return temp
})
setArrayForContainer(source.container, updatedSource)
setArrayForContainer(target.container, updatedTarget)
}
}
function handleMove(source: any, target: any) {
console.log('📦 Moving:', source, target)
const sourceInfo = getContainerInfo(source.container, source.position)
const targetInfo = getContainerInfo(target.container, target.position)
if (!sourceInfo || !targetInfo) {
console.error('Invalid container', source.container, target.container)
return
}
const sourceItem = sourceInfo.array[sourceInfo.index]
if (!sourceItem) {
console.error('Source item not found')
return
}
if (source.container === target.container) {
// Same container - move within
const updatedArray = getUpdatedArray(source.container, (arr) => {
const temp = [...arr]
temp[sourceInfo.index] = undefined
temp[targetInfo.index] = sourceItem
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
return temp
})
setArrayForContainer(source.container, updatedArray)
} else {
// Different containers - move across
const updatedSource = getUpdatedArray(source.container, (arr) => {
const temp = [...arr]
temp[sourceInfo.index] = undefined
return temp
})
const updatedTarget = getUpdatedArray(target.container, (arr) => {
const temp = [...arr]
temp[targetInfo.index] = sourceItem
if (temp[targetInfo.index]) temp[targetInfo.index].position = target.position
return temp
})
setArrayForContainer(source.container, updatedSource)
setArrayForContainer(target.container, updatedTarget)
}
}
function getContainerInfo(container: string, position: number) {
switch (container) {
case 'main-characters':
return { array: characters, index: position, container }
case 'extra-characters':
// Extra characters have positions 5-6 but array indices 0-1
return { array: extraCharacters, index: position - 5, container }
case 'main-weapons':
// Main weapons are positions 0-8, but need to account for mainhand
// The actual weapons array has mainhand at index 0, sub-weapons at 1-9
return { array: weapons.slice(1, 10), index: position, container }
case 'extra-weapons':
// Extra weapons have positions 9-11 but array indices 0-2
return { array: extraWeapons, index: position - 9, container }
case 'main-summons':
// Main summons are positions 0-3, need to account for main summon
// The actual summons array has main at 0, subs at 1-4
return { array: summons.slice(1, 5), index: position, container }
case 'subaura':
// Subaura have positions 4-5 but array indices 0-1
return { array: subauras, index: position - 4, container }
default:
return null
}
}
function getUpdatedArray(container: string, updateFn: (arr: any[]) => any[]) {
switch (container) {
case 'main-characters':
return updateFn(characters)
case 'extra-characters':
return updateFn(extraCharacters)
case 'main-weapons':
// For weapons, we need to work with the sub-weapons only
const subWeapons = weapons.slice(1, 10)
return updateFn(subWeapons)
case 'extra-weapons':
return updateFn(extraWeapons)
case 'main-summons':
// For summons, we need to work with the sub-summons only
const subSummons = summons.slice(1, 5)
return updateFn(subSummons)
case 'subaura':
return updateFn(subauras)
default:
return []
}
}
function getArrayForContainer(container: string) {
switch (container) {
case 'main-characters': return characters
case 'extra-characters': return extraCharacters
case 'main-weapons': return weapons.slice(1, 10) // Skip mainhand
case 'extra-weapons': return extraWeapons
case 'main-summons': return summons.slice(1, 5) // Skip main, get sub-summons
case 'subaura': return subauras
default: return null
}
}
function setArrayForContainer(container: string, newArray: any[]) {
switch (container) {
case 'main-characters':
characters = newArray
break
case 'extra-characters':
extraCharacters = newArray
break
case 'main-weapons':
// Update weapons array (preserving mainhand)
weapons = [weapons[0], ...newArray]
break
case 'extra-weapons':
extraWeapons = newArray
break
case 'main-summons':
// Update summons (preserving main and friend)
summons = [summons[0], ...newArray, summons[5], summons[6]]
break
case 'subaura':
subauras = newArray
break
}
}
function handleCharacterDrop(fromPos: number, toPos: number) {
const temp = [...characters]
const item = temp[fromPos]
if (!item) return
// Remove from source
temp[fromPos] = undefined
// Insert at target
if (temp[toPos]) {
// Swap
temp[fromPos] = temp[toPos]
}
temp[toPos] = item
// Update positions
temp.forEach((char, idx) => {
if (char) char.position = idx
})
// Ensure sequential filling
characters = temp.filter(c => c).concat(temp.filter(c => !c))
}
// Sync status
let syncStatus = $derived(
dragContext.getQueuedOperations().length > 0 ? 'pending' : 'idle'
)
</script>
<div class="test-container">
<header>
<h1>Drag & Drop Test</h1>
<div class="status">
{#if syncStatus === 'pending'}
<span class="pending">{dragContext.getQueuedOperations().length} pending operations</span>
{:else}
<span class="idle">✅ All synced</span>
{/if}
</div>
</header>
<section class="grid-section">
<h2>Character Grid</h2>
<div class="character-grid">
{#each characters as char, idx}
<DropZone
container="main-characters"
position={idx}
type="character"
item={char}
>
<DraggableItem
item={char}
container="main-characters"
position={idx}
type="character"
canDrag={!!char}
>
<div class="unit character-unit">
{#if char}
<div class="image">👤</div>
<div class="name">{char.character.name?.en}</div>
{:else}
<div class="empty-slot">Empty</div>
{/if}
</div>
</DraggableItem>
</DropZone>
{/each}
</div>
<h3>Extra Characters</h3>
<div class="extra-grid">
{#each extraCharacters as char, idx}
<DropZone
container="extra-characters"
position={idx + 5}
type="character"
item={char}
>
<DraggableItem
item={char}
container="extra-characters"
position={idx + 5}
type="character"
canDrag={!!char}
>
<div class="unit character-unit">
{#if char}
<div class="image">👤</div>
<div class="name">{char.character.name?.en}</div>
{:else}
<div class="empty-slot">Empty</div>
{/if}
</div>
</DraggableItem>
</DropZone>
{/each}
</div>
</section>
<section class="grid-section">
<h2>Weapon Grid</h2>
<div class="weapon-grid">
<div class="mainhand">
<h4>Mainhand</h4>
<div class="unit weapon-unit mainhand-unit">
{#if weapons[0]}
<div class="image">⚔️</div>
<div class="name">{weapons[0].weapon.name?.en}</div>
{:else}
<div class="empty-slot">Empty</div>
{/if}
</div>
</div>
<div class="subweapons">
{#each weapons.slice(1, 10) as weapon, idx}
<DropZone
container="main-weapons"
position={idx}
type="weapon"
item={weapon}
>
<DraggableItem
item={weapon}
container="main-weapons"
position={idx}
type="weapon"
canDrag={!!weapon}
>
<div class="unit weapon-unit">
{#if weapon}
<div class="image">⚔️</div>
<div class="name">{weapon.weapon.name?.en}</div>
{:else}
<div class="empty-slot">Empty</div>
{/if}
</div>
</DraggableItem>
</DropZone>
{/each}
</div>
</div>
</section>
<section class="grid-section">
<h2>Summon Grid</h2>
<div class="summon-grid">
<div class="main-summon">
<h4>Main</h4>
<div class="unit summon-unit">
{#if summons[0]}
<div class="image">🐉</div>
<div class="name">{summons[0].summon.name?.en}</div>
{/if}
</div>
</div>
<div class="subsummons">
{#each summons.slice(1, 5) as summon, idx}
<DropZone
container="main-summons"
position={idx}
type="summon"
item={summon}
>
<DraggableItem
item={summon}
container="main-summons"
position={idx}
type="summon"
canDrag={!!summon}
>
<div class="unit summon-unit">
{#if summon}
<div class="image">🐉</div>
<div class="name">{summon.summon.name?.en}</div>
{:else}
<div class="empty-slot">Empty</div>
{/if}
</div>
</DraggableItem>
</DropZone>
{/each}
</div>
<div class="friend-summon">
<h4>Friend</h4>
<div class="unit summon-unit">
{#if summons[6]}
<div class="image">🐉</div>
<div class="name">{summons[6].summon.name?.en}</div>
{/if}
</div>
</div>
</div>
<h3>Subaura</h3>
<div class="subaura-grid">
{#each subauras as summon, idx}
<DropZone
container="subaura"
position={idx + 4}
type="summon"
item={summon}
>
<DraggableItem
item={summon}
container="subaura"
position={idx + 4}
type="summon"
canDrag={!!summon}
>
<div class="unit summon-unit">
{#if summon}
<div class="image">🐉</div>
<div class="name">{summon.summon.name?.en}</div>
{:else}
<div class="empty-slot">Empty</div>
{/if}
</div>
</DraggableItem>
</DropZone>
{/each}
</div>
</section>
<section class="operations">
<h2>Operations Log</h2>
<pre>{JSON.stringify(operations, null, 2)}</pre>
</section>
</div>
<style lang="scss">
.test-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
.status {
.pending {
color: orange;
}
.idle {
color: green;
}
}
}
.grid-section {
margin-bottom: 40px;
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
h2 {
margin-bottom: 20px;
}
h3 {
margin-top: 20px;
margin-bottom: 10px;
}
}
.character-grid, .extra-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
}
.weapon-grid {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 20px;
.subweapons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
}
.summon-grid {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 20px;
.subsummons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
}
.subaura-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
max-width: 300px;
}
.unit {
padding: 15px;
background: white;
border: 2px solid #ddd;
border-radius: 8px;
text-align: center;
min-height: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.image {
font-size: 32px;
margin-bottom: 5px;
}
.name {
font-size: 12px;
color: #666;
}
.empty-slot {
color: #999;
}
}
.mainhand-unit, .main-summon .unit, .friend-summon .unit {
background: #e3f2fd;
border-color: #2196f3;
}
.operations {
margin-top: 40px;
padding: 20px;
background: #263238;
color: #fff;
border-radius: 8px;
pre {
max-height: 300px;
overflow-y: auto;
font-size: 12px;
}
}
</style>