diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b301d94
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,26 @@
+# flyctl launch added from .gitignore
+# Logs
+**/logs
+**/*.log
+**/npm-debug.log*
+**/yarn-debug.log*
+**/yarn-error.log*
+**/pnpm-debug.log*
+**/lerna-debug.log*
+
+**/node_modules
+**/dist
+**/dist-ssr
+**/*.local
+
+# Editor directories and files
+**/.vscode/*
+!**/.vscode/extensions.json
+**/.idea
+**/.DS_Store
+**/*.suo
+**/*.ntvs*
+**/*.njsproj
+**/*.sln
+**/*.sw?
+fly.toml
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b188ae0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,74 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Essential Commands
+
+### Development
+```bash
+yarn dev # Start Vite development server on port 5173
+yarn preview # Preview production build locally
+```
+
+### Build & Deploy
+```bash
+yarn build # Create production build with Vite
+make build # Build Docker image (tuxpa.in/a/lto:v0.0.2)
+make push # Push Docker image to registry
+```
+
+### Code Quality
+```bash
+yarn lint # Check code with Biome
+yarn lint:fix # Auto-fix linting issues
+yarn format # Format code with Biome
+```
+
+## Architecture Overview
+
+This is a React-based inventory management system for the game "Trickster Online" via the lifeto.co platform.
+
+### Key Technologies
+- **React 19** with TypeScript
+- **Vite** for bundling and dev server
+- **Jotai** for atomic state management
+- **TanStack Query** for server state and caching
+- **Tailwind CSS** for styling
+- **Axios** for HTTP requests
+
+### Core Architecture
+
+1. **State Management Pattern**:
+ - Jotai atoms in `src/state/atoms.ts` handle all application state
+ - Uses `atomWithQuery` for server data integration
+ - Persistent storage via `atomWithStorage` with superjson serialization
+ - Actions are implemented as write-only atoms (e.g., `doLoginAtom`, `orderManagerAtom`)
+
+2. **API Integration**:
+ - All API calls go through `LTOApi` interface (`src/lib/lifeto/api.ts`)
+ - Token-based authentication via `TokenSession`
+ - Development: Vite proxy to `https://beta.lifeto.co`
+ - Production: Caddy reverse proxy configuration
+
+3. **Component Structure**:
+ - Entry: `src/index.tsx` → `App.tsx`
+ - Main sections: Login, Character Selection, Inventory Management
+ - Components follow atomic design with clear separation of concerns
+
+4. **Business Logic**:
+ - Domain models in `src/lib/trickster.ts` (Character, Item, Inventory)
+ - Order management via `OrderManager` class for item transfers
+ - Item filtering uses Fuse.js for fuzzy search
+
+5. **Data Flow**:
+ ```
+ User Action → Component → Jotai Action Atom → API Call →
+ Server Response → Query Cache → Atom Update → UI Re-render
+ ```
+
+### Development Notes
+
+- The app uses a proxy setup to avoid CORS issues with the lifeto.co API
+- All API responses are strongly typed with TypeScript interfaces
+- State persistence allows users to maintain their session and preferences
+- The inventory system supports multi-character management with bulk operations
\ No newline at end of file
diff --git a/Caddyfile b/Caddyfile
index 5722a3a..d707b11 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -1,5 +1,11 @@
{
admin off
+ log {
+ include http.log.access http.handlers.reverse_proxy
+ output stdout
+ format console
+ level debug
+ }
}
:{$PORT:8080} {
root * {$ROOT:./dist}
@@ -9,7 +15,9 @@
handle /lifeto/* {
uri strip_prefix /lifeto
reverse_proxy https://beta.lifeto.co {
+ header_up Host {upstream_hostport}
header_up X-Forwarded-For {remote_host}
+ header_up User-Agent "LifetoShop/1.0"
header_down -Connection
header_down -Keep-Alive
header_down -Proxy-Authenticate
@@ -20,9 +28,4 @@
header_down -Upgrade
}
}
-
- log {
- output stdout
- format console
- }
}
diff --git a/Dockerfile b/Dockerfile
index 538e54d..7f73c41 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,10 @@
-FROM node:18.1-alpine as NODEBUILDER
+FROM node:24-alpine as NODEBUILDER
WORKDIR /wd
COPY . .
-RUN npm install
-RUN npx vite build
+RUN corepack yarn install
+RUN corepack yarn build
-FROM caddyserver/caddy:2.10-alpine
+FROM caddy:2.10-alpine
WORKDIR /wd
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=NODEBUILDER /wd/dist dist
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..172fb4b
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,22 @@
+# fly.toml app configuration file generated for lifeto on 2025-06-23T01:16:55-05:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = 'lifeto'
+primary_region = 'ord'
+
+[build]
+
+[http_service]
+ internal_port = 8080
+ force_https = true
+ auto_stop_machines = 'stop'
+ auto_start_machines = true
+ min_machines_running = 0
+ processes = ['app']
+
+[[vm]]
+ memory = '1gb'
+ cpu_kind = 'shared'
+ cpus = 1
diff --git a/src/components/inventory/InventoryFilters.tsx b/src/components/inventory/InventoryFilters.tsx
new file mode 100644
index 0000000..d64abf5
--- /dev/null
+++ b/src/components/inventory/InventoryFilters.tsx
@@ -0,0 +1,207 @@
+import { useAtomValue, useSetAtom } from 'jotai'
+import { useState } from 'react'
+import {
+ FloatingPortal,
+ autoUpdate,
+ flip,
+ offset,
+ shift,
+ useFloating,
+ useHover,
+ useInteractions,
+} from '@floating-ui/react'
+import { inventoryFilterAtom, setInventoryFilterTabActionAtom } from '@/state/atoms'
+
+const sections = [
+ { name: 'all', value: '' },
+ { name: 'consume', value: '1' },
+ { name: 'equip', value: '2' },
+ { name: 'drill', value: '3' },
+ { name: 'pet', value: '4' },
+ { name: 'etc', value: '5' },
+]
+
+const cardSections = [
+ { name: 'skill', value: '10' },
+ { name: 'char', value: '11' },
+ { name: 'mon', value: '12' },
+ { name: 'fortune', value: '13' },
+ { name: 'secret', value: '14' },
+ { name: 'arcana', value: '15' },
+]
+
+export const InventoryFilters = () => {
+ const inventoryFilter = useAtomValue(inventoryFilterAtom)
+ const setInventoryFilterTab = useSetAtom(setInventoryFilterTabActionAtom)
+ const [isCardDropdownOpen, setIsCardDropdownOpen] = useState(false)
+
+ const sharedStyle = 'hover:cursor-pointer hover:bg-gray-200 px-2 pr-4 border-t border-l border-r border-gray-200'
+ const selectedStyle = 'bg-gray-200 border-b-2 border-black-1'
+
+ const { refs, floatingStyles, context } = useFloating({
+ open: isCardDropdownOpen,
+ onOpenChange: setIsCardDropdownOpen,
+ middleware: [offset(5), flip(), shift()],
+ whileElementsMounted: autoUpdate,
+ placement: 'bottom-start',
+ })
+
+ const hover = useHover(context, {
+ delay: { open: 100, close: 300 },
+ })
+
+ const { getReferenceProps, getFloatingProps } = useInteractions([hover])
+
+ // Check if any card section is selected
+ const isCardSectionSelected = cardSections.some(x => x.value === inventoryFilter.tab)
+
+ return (
+
+ {sections.map(x => {
+ return (
+
{
+ setInventoryFilterTab(x.value)
+ }}
+ key={x.name}
+ className={`${sharedStyle} ${inventoryFilter.tab === x.value ? selectedStyle : ''}`}
+ >
+
+ {x.value === '' && (
+
+ )}
+ {x.value === '1' && (
+
+ )}
+ {x.value === '2' && (
+
+ )}
+ {x.value === '3' && (
+
+ )}
+ {x.value === '4' && (
+
+ )}
+ {x.value === '5' && (
+
+ )}
+ {x.name}
+
+
+ )
+ })}
+
+
+
+
+ card
+
+
+ {isCardDropdownOpen && (
+
+
+ {cardSections.map(x => (
+
{
+ setInventoryFilterTab(x.value)
+ setIsCardDropdownOpen(false)
+ }}
+ className={`block w-full text-left px-4 py-2 hover:bg-gray-100 ${
+ inventoryFilter.tab === x.value ? 'bg-gray-200 font-semibold' : ''
+ }`}
+ >
+
+ {x.value === '10' && (
+
+ )}
+ {x.value === '11' && (
+
+ )}
+ {x.value === '12' && (
+
+ )}
+ {x.value === '13' && (
+
+ )}
+ {x.value === '14' && (
+
+ )}
+ {x.value === '15' && (
+
+ )}
+ {x.name}
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/inventory/MoveConfirmationPopup.tsx b/src/components/inventory/MoveConfirmationPopup.tsx
new file mode 100644
index 0000000..e350cbe
--- /dev/null
+++ b/src/components/inventory/MoveConfirmationPopup.tsx
@@ -0,0 +1,182 @@
+import {
+ FloatingFocusManager,
+ FloatingOverlay,
+ FloatingPortal,
+ useClick,
+ useDismiss,
+ useFloating,
+ useInteractions,
+ useRole,
+} from '@floating-ui/react'
+import { useAtom, useSetAtom } from 'jotai'
+import { useState } from 'react'
+import {
+ clearItemSelectionActionAtom,
+ closeMoveConfirmationAtom,
+ type MoveItemsResult,
+ moveConfirmationAtom,
+ moveSelectedItemsAtom,
+} from '@/state/atoms'
+
+export function MoveConfirmationPopup() {
+ const [confirmationState] = useAtom(moveConfirmationAtom)
+ const closeConfirmation = useSetAtom(closeMoveConfirmationAtom)
+ const moveItems = useSetAtom(moveSelectedItemsAtom)
+ const clearSelection = useSetAtom(clearItemSelectionActionAtom)
+ const [isMoving, setIsMoving] = useState(false)
+ const [moveResult, setMoveResult] = useState(null)
+
+ const { refs, context } = useFloating({
+ open: confirmationState.isOpen,
+ onOpenChange: open => {
+ if (!open && !isMoving) {
+ closeConfirmation()
+ setMoveResult(null)
+ }
+ },
+ })
+
+ const click = useClick(context)
+ const dismiss = useDismiss(context, {
+ outsidePressEvent: 'mousedown',
+ escapeKey: !isMoving,
+ })
+ const role = useRole(context)
+
+ const { getFloatingProps } = useInteractions([click, dismiss, role])
+
+ if (!confirmationState.isOpen) return null
+
+ const { selectedItems, sourceCharacter, targetCharacter } = confirmationState
+
+ const handleConfirm = async () => {
+ setIsMoving(true)
+ try {
+ const result = await moveItems()
+ setMoveResult(result)
+ if (result.failedCount === 0) {
+ clearSelection()
+ setTimeout(() => {
+ closeConfirmation()
+ setMoveResult(null)
+ }, 1500)
+ }
+ } catch (_error) {
+ // Error handled in UI
+ } finally {
+ setIsMoving(false)
+ }
+ }
+
+ const handleCancel = () => {
+ if (!isMoving) {
+ closeConfirmation()
+ }
+ }
+
+ const renderItemPreview = () => {
+ const itemsArray = Array.from(selectedItems.values())
+ const totalUniqueItems = itemsArray.length
+ const totalQuantity = itemsArray.reduce((sum, { count }) => sum + count, 0)
+
+ if (totalUniqueItems > 5) {
+ return (
+
+
Moving {totalUniqueItems} different items
+
Total quantity: {totalQuantity.toLocaleString()}
+
+ )
+ }
+
+ return (
+
+ {itemsArray.map(({ item, count }) => (
+
+
+
+
{item.item_name}
+
+
×{count.toLocaleString()}
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
+
+
Confirm Item Movement
+
+
+
+ From:
+ {sourceCharacter?.name || 'Unknown'}
+
+
+ To:
+ {targetCharacter?.name || 'Unknown'}
+
+
+
+
{renderItemPreview()}
+
+ {moveResult && (
+
0 ? 'bg-yellow-50' : 'bg-green-50'}`}
+ >
+
+ {moveResult.failedCount === 0
+ ? `Successfully moved ${moveResult.successCount} items!`
+ : `Moved ${moveResult.successCount} of ${moveResult.totalItems} items`}
+
+ {moveResult.errors.length > 0 && (
+
+ {moveResult.errors.slice(0, 3).map(error => (
+
{error.error}
+ ))}
+ {moveResult.errors.length > 3 && (
+
...and {moveResult.errors.length - 3} more errors
+ )}
+
+ )}
+
+ )}
+
+
+
+ Cancel
+
+
+ {isMoving ? 'Moving...' : 'Confirm Move'}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/inventory/index.tsx b/src/components/inventory/index.tsx
index de59cc7..22c7eee 100644
--- a/src/components/inventory/index.tsx
+++ b/src/components/inventory/index.tsx
@@ -1,101 +1,71 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
+import { useEffect } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import {
clearItemSelectionActionAtom,
+ currentCharacterInventoryAtom,
filteredCharacterItemsAtom,
- inventoryFilterAtom,
inventoryPageRangeAtom,
itemSelectionSelectAllFilterActionAtom,
itemSelectionSelectAllPageActionAtom,
+ moveSelectedItemsAtom,
+ openMoveConfirmationAtom,
paginateInventoryActionAtom,
preferenceInventorySearch,
selectedCharacterAtom,
- setInventoryFilterTabActionAtom,
} from '@/state/atoms'
+import { MoveConfirmationPopup } from './MoveConfirmationPopup'
import { InventoryTargetSelector } from './movetarget'
import { InventoryTable } from './table'
+import { InventoryFilters } from './InventoryFilters'
-const sections = [
- { name: 'all', value: '' },
- { name: 'consume', value: '1' },
- { name: 'equip', value: '2' },
- { name: 'drill', value: '3' },
- { name: 'pet', value: '4' },
- { name: 'etc', value: '5' },
-]
-
-const cardSections = [
- { name: 'skill', value: '10' },
- { name: 'char', value: '11' },
- { name: 'mon', value: '12' },
- { name: 'fortune', value: '13' },
- { name: 'secret', value: '14' },
- { name: 'arcana', value: '15' },
-]
-
-const InventoryTabs = () => {
- const inventoryFilter = useAtomValue(inventoryFilterAtom)
- const setInventoryFilterTab = useSetAtom(setInventoryFilterTabActionAtom)
+const InventoryRangeDisplay = () => {
const inventoryRange = useAtomValue(inventoryPageRangeAtom)
const items = useAtomValue(filteredCharacterItemsAtom)
- const sharedStyle = 'hover:cursor-pointer hover:bg-gray-200 px-2 pr-4 border border-gray-200'
- const selectedStyle = 'bg-gray-200 border-b-2 border-black-1'
+
return (
-
-
-
- {sections.map(x => {
- return (
- {
- setInventoryFilterTab(x.value)
- }}
- key={x.name}
- className={`${sharedStyle}
-${inventoryFilter.tab === x.value ? selectedStyle : ''}`}
- >
- {x.name}
-
- )
- })}
-
-
- {cardSections.map(x => {
- return (
- {
- setInventoryFilterTab(x.value)
- }}
- key={x.name}
- className={`${sharedStyle}
-${inventoryFilter.tab === x.value ? selectedStyle : ''}`}
- >
- {x.name}
-
- )
- })}
-
-
-
-
- {inventoryRange.start}..{inventoryRange.end}/{items.length}{' '}
-
-
+
+ {inventoryRange.start}..{inventoryRange.end}/{items.length}
)
}
+
export const Inventory = () => {
const selectedCharacter = useAtomValue(selectedCharacterAtom)
const clearItemSelection = useSetAtom(clearItemSelectionActionAtom)
+ const { refetch: refetchInventory } = useAtomValue(currentCharacterInventoryAtom)
const addPageItemSelection = useSetAtom(itemSelectionSelectAllPageActionAtom)
const addFilterItemSelection = useSetAtom(itemSelectionSelectAllFilterActionAtom)
const [search, setSearch] = useAtom(preferenceInventorySearch)
const paginateInventory = useSetAtom(paginateInventoryActionAtom)
+ const openMoveConfirmation = useSetAtom(openMoveConfirmationAtom)
+ const moveSelectedItems = useSetAtom(moveSelectedItemsAtom)
+
+ // Add keyboard navigation
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Don't paginate if user is typing in an input
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
+ return
+ }
+
+ if (e.key === 'ArrowLeft') {
+ e.preventDefault()
+ paginateInventory(-1)
+ } else if (e.key === 'ArrowRight') {
+ e.preventDefault()
+ paginateInventory(1)
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [paginateInventory])
if (!selectedCharacter) {
return
select a character
@@ -103,88 +73,116 @@ export const Inventory = () => {
return (
-
-
+
+
+
+ {
+ if (selectedCharacter) {
+ refetchInventory()
+ }
+ }}
+ title="Refresh inventory"
+ >
+ ↻
+
+ {
+ setSearch(e.target.value)
+ }}
+ />
+ {
+ paginateInventory(-1)
+ }}
+ aria-label="Previous page"
+ title="Previous page (← arrow key)"
+ >
+
+
+ {
+ paginateInventory(1)
+ }}
+ aria-label="Next page"
+ title="Next page (→ arrow key)"
+ >
+
+
+
+
+
+
+ {
+ if (e.shiftKey) {
+ // Shift+click: skip confirmation
+ const result = await moveSelectedItems()
+ if (result.successCount > 0) {
+ clearItemSelection()
+ }
+ } else {
+ // Normal click: show confirmation
+ openMoveConfirmation()
+ }
+ }}
+ className="hover:cursor-pointer whitespace-preborder border-black-1 bg-orange-200 hover:bg-orange-300 px-2 py-1"
+ title="Click to move with confirmation, Shift+Click to move immediately"
+ >
+ Move Selected
+
+
+
+
{
- addPageItemSelection()
+ addFilterItemSelection()
}}
>
select filtered
{
- addFilterItemSelection()
+ addPageItemSelection()
}}
>
select page
{
clearItemSelection()
}}
>
- clear{' '}
-
-
-
-
- {
- // sendOrders()
- }}
- className="hover:cursor-pointer whitespace-preborder border-black-1 bg-orange-200 hover:bg-orange-300 px-2 py-1"
- >
- Move Selected
-
-
-
-
-
-
+
+
+
+
+
)
}
diff --git a/src/components/inventory/movetarget.tsx b/src/components/inventory/movetarget.tsx
index 79a4294..008d131 100644
--- a/src/components/inventory/movetarget.tsx
+++ b/src/components/inventory/movetarget.tsx
@@ -13,7 +13,7 @@ import {
import Fuse from 'fuse.js'
import { useAtom, useAtomValue } from 'jotai'
import { forwardRef, useId, useMemo, useRef, useState } from 'react'
-import { charactersAtom, selectedTargetInventoryAtom } from '@/state/atoms'
+import { charactersAtom, selectedCharacterAtom, selectedTargetInventoryAtom } from '@/state/atoms'
interface AccountInventorySelectorItemProps {
children: React.ReactNode
@@ -25,6 +25,7 @@ const AccountInventorySelectorItem = forwardRef<
AccountInventorySelectorItemProps & React.HTMLProps
>(({ children, active, ...rest }, ref) => {
const id = useId()
+ const isDisabled = rest['aria-disabled']
return (
@@ -61,7 +64,7 @@ export const InventoryTargetSelector = () => {
size({
apply({ rects, availableHeight, elements }) {
Object.assign(elements.floating.style, {
- width: `${rects.reference.width}px`,
+ width: `${Math.max(rects.reference.width * 2, 400)}px`,
maxHeight: `${availableHeight}px`,
})
},
@@ -99,11 +102,14 @@ export const InventoryTargetSelector = () => {
}
}
const { data: subaccounts } = useAtomValue(charactersAtom)
+ const selectedCharacter = useAtomValue(selectedCharacterAtom)
const [selectedTargetInventory, setSelectedTargetInventory] = useAtom(selectedTargetInventoryAtom)
const searcher = useMemo(() => {
- return new Fuse(subaccounts?.flatMap(x => [x.bank, x.character]) || [], {
+ const allInventories = subaccounts?.flatMap(x => [x.bank, x.character]) || []
+ // Don't filter out current character, we'll disable it in the UI
+ return new Fuse(allInventories, {
keys: ['path', 'name'],
findAllMatches: true,
threshold: 0.8,
@@ -111,15 +117,24 @@ export const InventoryTargetSelector = () => {
})
}, [subaccounts])
- const items = searcher.search(inputValue || '!-', { limit: 10 }).map(x => x.item)
+ const items = inputValue
+ ? searcher.search(inputValue, { limit: 10 }).map(x => x.item)
+ : subaccounts?.flatMap(x => [x.bank, x.character]).slice(0, 10) || []
return (
<>
{
onKeyDown(event) {
if (event.key === 'Enter' && activeIndex != null && items[activeIndex]) {
setSelectedTargetInventory(items[activeIndex])
- setInputValue(items[activeIndex].name)
+ setInputValue('')
setActiveIndex(null)
setOpen(false)
}
@@ -149,25 +164,68 @@ export const InventoryTargetSelector = () => {
},
})}
>
- {items.map((item, index) => (
-
- {item.name}
-
- ))}
+
+
+ {items
+ .filter(item => item.path.includes('/'))
+ .map(item => {
+ const actualIndex = items.indexOf(item)
+ const isDisabled = item.path === selectedCharacter?.path
+ return (
+
+ {item.name}
+
+ )
+ })}
+
+
+ {items
+ .filter(item => !item.path.includes('/'))
+ .map(item => {
+ const actualIndex = items.indexOf(item)
+ const isDisabled = item.path === selectedCharacter?.path
+ return (
+
+ [Bank] {item.account_name}
+
+ )
+ })}
+
+
diff --git a/src/components/inventory/table.tsx b/src/components/inventory/table.tsx
index daaf818..f333867 100644
--- a/src/components/inventory/table.tsx
+++ b/src/components/inventory/table.tsx
@@ -1,10 +1,14 @@
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
-import { atom, useAtomValue } from 'jotai'
-import { useMemo } from 'react'
+import { atom, useAtomValue, useSetAtom } from 'jotai'
+import { useEffect, useMemo } from 'react'
import { StatsColumns } from '@/lib/columns'
import { ItemWithSelection } from '@/lib/table/defs'
import { InventoryColumns } from '@/lib/table/tanstack'
-import { inventoryItemsCurrentPageAtom, preferenceInventoryTab } from '@/state/atoms'
+import {
+ inventoryItemsCurrentPageAtom,
+ mouseDragSelectionStateAtom,
+ preferenceInventoryTab,
+} from '@/state/atoms'
const columnVisibilityAtom = atom(get => {
const itemTab = get(preferenceInventoryTab)
@@ -15,6 +19,7 @@ const columnVisibilityAtom = atom(get => {
})
export const InventoryTable = () => {
const items = useAtomValue(inventoryItemsCurrentPageAtom)
+ const setDragState = useSetAtom(mouseDragSelectionStateAtom)
const columns = useMemo(() => {
return [...Object.values(InventoryColumns)]
@@ -32,6 +37,18 @@ export const InventoryTable = () => {
getCoreRowModel: getCoreRowModel(),
})
+ // Handle global mouse up to end drag selection
+ useEffect(() => {
+ const handleMouseUp = () => {
+ setDragState(prev => ({ ...prev, isDragging: false }))
+ }
+
+ document.addEventListener('mouseup', handleMouseUp)
+ return () => {
+ document.removeEventListener('mouseup', handleMouseUp)
+ }
+ }, [setDragState])
+
return (
{
+ try {
+ const request = {
+ item_uid: params.itemUid,
+ qty: params.count.toString(),
+ new_char: params.targetCharId,
+ }
+
+ const response = await this.api.BankAction('internal-xfer-item', request)
+
+ if (response.status !== 'success') {
+ return {
+ success: false,
+ error: response.message || 'Failed to transfer item',
+ }
+ }
+
+ return {
+ success: true,
+ data: response.data,
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error in internalXfer',
+ }
+ }
+ }
+
+ /**
+ * Move items to bank
+ * Uses bank-item API
+ */
+ async bankItem(params: BankItemParams): Promise {
+ try {
+ const request = {
+ item_uid: params.itemUid,
+ qty: params.count.toString(),
+ account: params.targetAccount,
+ }
+
+ const response = await this.api.BankAction('bank-item', request)
+
+ if (response.status !== 'success') {
+ return {
+ success: false,
+ error: response.message || 'Failed to bank item',
+ }
+ }
+
+ return {
+ success: true,
+ data: response.data,
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error in bankItem',
+ }
+ }
+ }
+
+ /**
+ * High-level function that determines whether to use bankItem or internalXfer
+ * based on whether targetAccountId is provided (bank) or targetCharId (character)
+ */
+ async moveItem(
+ itemUid: string | 'galders',
+ count: number,
+ targetCharId?: string,
+ targetAccountId?: string,
+ ): Promise {
+ if (targetAccountId) {
+ // Use bank-item when moving to bank (targetAccountId is provided)
+ return this.bankItem({
+ itemUid,
+ count,
+ targetAccount: targetAccountId,
+ })
+ }
+ if (targetCharId) {
+ // Use internal-xfer when moving between characters
+ return this.internalXfer({
+ itemUid,
+ count,
+ targetCharId,
+ })
+ }
+ return {
+ success: false,
+ error: 'Either targetCharId or targetAccountId must be provided',
+ }
+ }
+}
diff --git a/src/lib/lifeto/lifeto.ts b/src/lib/lifeto/lifeto.ts
index 37515e4..364a80a 100644
--- a/src/lib/lifeto/lifeto.ts
+++ b/src/lib/lifeto/lifeto.ts
@@ -36,6 +36,7 @@ export class LTOApiv0 implements LTOApi {
endpoint = market_endpoint
break
case 'sell-item':
+ //case 'internal-xfer-item':
VERB = 'POSTFORM'
break
default:
diff --git a/src/lib/session.ts b/src/lib/session.ts
index d50a9b3..079e257 100644
--- a/src/lib/session.ts
+++ b/src/lib/session.ts
@@ -5,7 +5,7 @@ import { TricksterAccountInfo } from './trickster'
export const SITE_ROOT = '/lifeto/'
export const API_ROOT = 'api/lifeto/'
-export const BANK_ROOT = 'v2/item-manager/'
+export const BANK_ROOT = 'v3/item-manager/'
export const MARKET_ROOT = 'marketplace-api/'
const raw_endpoint = (name: string): string => {
@@ -18,7 +18,7 @@ export const api_endpoint = (name: string): string => {
return SITE_ROOT + API_ROOT + name
}
export const bank_endpoint = (name: string): string => {
- return SITE_ROOT + BANK_ROOT + name
+ return SITE_ROOT + API_ROOT + BANK_ROOT + name
}
export const market_endpoint = (name: string): string => {
diff --git a/src/lib/table/tanstack.tsx b/src/lib/table/tanstack.tsx
index a0850fc..cfc8415 100644
--- a/src/lib/table/tanstack.tsx
+++ b/src/lib/table/tanstack.tsx
@@ -1,7 +1,11 @@
import { createColumnHelper } from '@tanstack/react-table'
-import { useAtomValue, useSetAtom } from 'jotai'
+import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useMemo } from 'react'
-import { currentItemSelectionAtom, itemSelectionSetActionAtom } from '@/state/atoms'
+import {
+ currentItemSelectionAtom,
+ itemSelectionSetActionAtom,
+ mouseDragSelectionStateAtom,
+} from '@/state/atoms'
import { StatsColumns } from '../columns'
import { ItemWithSelection } from './defs'
@@ -16,24 +20,51 @@ const columns = {
cell: function Component({ row }) {
const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
const c = useAtomValue(currentItemSelectionAtom)
+ const [dragState, setDragState] = useAtom(mouseDragSelectionStateAtom)
const selected = useMemo(() => {
return c[0].has(row.original.item.id)
}, [c])
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault()
+ const newSelected = !selected
+ setItemSelection({
+ [row.original.item.id]: newSelected ? row.original.item.item_count : undefined,
+ })
+ setDragState({
+ isDragging: true,
+ lastAction: newSelected ? 'select' : 'deselect',
+ lastItemId: row.original.item.id,
+ })
+ }
+
+ const handleMouseEnter = () => {
+ if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) {
+ if (dragState.lastAction === 'select' && !selected) {
+ setItemSelection({
+ [row.original.item.id]: row.original.item.item_count,
+ })
+ } else if (dragState.lastAction === 'deselect' && selected) {
+ setItemSelection({
+ [row.original.item.id]: undefined,
+ })
+ }
+ }
+ }
+
return (
{
- setItemSelection({
- [row.original.item.id]: selected ? undefined : row.original.item.item_count,
- })
- }}
+ onMouseDown={handleMouseDown}
+ onMouseEnter={handleMouseEnter}
>
@@ -48,6 +79,7 @@ const columns = {
cell: function Component({ row }) {
const c = useAtomValue(currentItemSelectionAtom)
const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
+ const dragState = useAtomValue(mouseDragSelectionStateAtom)
const currentValue = useMemo(() => {
const got = c[0].get(row.original.item.id)
if (got !== undefined) {
@@ -56,9 +88,30 @@ const columns = {
return ''
}, [c])
const itemCount = row.original.item.item_count
+ const selected = useMemo(() => {
+ return c[0].has(row.original.item.id)
+ }, [c])
+
+ const handleMouseEnter = () => {
+ if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) {
+ if (dragState.lastAction === 'select' && !selected) {
+ setItemSelection({
+ [row.original.item.id]: row.original.item.item_count,
+ })
+ } else if (dragState.lastAction === 'deselect' && selected) {
+ setItemSelection({
+ [row.original.item.id]: undefined,
+ })
+ }
+ }
+ }
+
return (
+ // biome-ignore lint/a11y/useSemanticElements: Using div for layout with input child
+ // biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select
name
},
cell: function Component({ row }) {
+ const c = useAtomValue(currentItemSelectionAtom)
+ const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
+ const [dragState, setDragState] = useAtom(mouseDragSelectionStateAtom)
+ const selected = useMemo(() => {
+ return c[0].has(row.original.item.id)
+ }, [c])
+
+ const handleMouseEnter = () => {
+ if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) {
+ if (dragState.lastAction === 'select' && !selected) {
+ setItemSelection({
+ [row.original.item.id]: row.original.item.item_count,
+ })
+ } else if (dragState.lastAction === 'deselect' && selected) {
+ setItemSelection({
+ [row.original.item.id]: undefined,
+ })
+ }
+ }
+ }
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault()
+ const newSelected = !selected
+ setItemSelection({
+ [row.original.item.id]: newSelected ? row.original.item.item_count : undefined,
+ })
+ setDragState({
+ isDragging: true,
+ lastAction: newSelected ? 'select' : 'deselect',
+ lastItemId: row.original.item.id,
+ })
+ }
+
return (
-
+ // biome-ignore lint/a11y/useSemanticElements: Using div for text content
+ // biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select
+
{row.original.item.item_name}
)
diff --git a/src/state/atoms.ts b/src/state/atoms.ts
index 0788251..e7f3454 100644
--- a/src/state/atoms.ts
+++ b/src/state/atoms.ts
@@ -6,6 +6,7 @@ import { focusAtom } from 'jotai-optics'
import { atomWithQuery } from 'jotai-tanstack-query'
import { ItemWithSelection } from '@/lib/table/defs'
import { LTOApiv0 } from '../lib/lifeto'
+import { ItemMover } from '../lib/lifeto/item_mover'
import { LoginHelper, TokenSession } from '../lib/session'
import { TricksterCharacter, TricksterItem } from '../lib/trickster'
import { createSuperjsonStorage } from './storage'
@@ -158,13 +159,19 @@ export const preferenceInventorySortReverse = focusAtom(inventoryFilterAtom, x =
x.prop('sort_reverse'),
)
-export const setInventoryFilterTabActionAtom = atom(null, (_get, set, tab: string) => {
+export const setInventoryFilterTabActionAtom = atom(null, (get, set, tab: string) => {
set(inventoryFilterAtom, x => {
return {
...x,
tab,
}
})
+ // Reset pagination to first page when switching tabs
+ const pageSize = get(inventoryDisplaySettingsAtoms.pageSize)
+ set(inventoryPageRangeAtom, {
+ start: 0,
+ end: pageSize,
+ })
})
export const inventoryPageRangeAtom = atom({
@@ -250,6 +257,12 @@ export const rowSelectionLastActionAtom = atom<
| undefined
>(undefined)
+export const mouseDragSelectionStateAtom = atom({
+ isDragging: false,
+ lastAction: null as 'select' | 'deselect' | null,
+ lastItemId: null as string | null,
+})
+
export const clearItemSelectionActionAtom = atom(null, (_get, set) => {
set(currentItemSelectionAtom, [new Map
(), 0])
})
@@ -315,8 +328,10 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number |
}
} else if (pages < 0) {
if (inventoryRange.start <= 0) {
+ // Wrap around to the last page
+ const lastPageStart = Math.max(0, filteredItems.length - pageSize)
set(inventoryPageRangeAtom, {
- start: filteredItems.length - pageSize,
+ start: lastPageStart,
end: filteredItems.length,
})
return
@@ -325,11 +340,17 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number |
const delta = pages * pageSize
let newStart = inventoryRange.start + delta
let newEnd = inventoryRange.end + delta
+
+ // Handle negative start
+ if (newStart < 0) {
+ newStart = 0
+ newEnd = Math.min(pageSize, filteredItems.length)
+ }
+
+ // Handle end beyond items length
if (newEnd > filteredItems.length) {
newEnd = filteredItems.length
- }
- if (newEnd - newStart !== pageSize) {
- newStart = newEnd - pageSize
+ newStart = Math.max(0, newEnd - pageSize)
}
set(inventoryPageRangeAtom, {
@@ -337,3 +358,154 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number |
end: newEnd,
})
})
+
+export interface MoveItemsResult {
+ totalItems: number
+ successCount: number
+ failedCount: number
+ errors: Array<{ itemId: string; error: string }>
+}
+
+export interface MoveConfirmationState {
+ isOpen: boolean
+ selectedItems: Map
+ sourceCharacter?: TricksterCharacter
+ targetCharacter?: TricksterCharacter
+}
+
+export const moveConfirmationAtom = atom({
+ isOpen: false,
+ selectedItems: new Map(),
+})
+
+export const openMoveConfirmationAtom = atom(null, (get, set) => {
+ const [selectedItems] = get(currentItemSelectionAtom)
+ const sourceCharacter = get(selectedCharacterAtom)
+ const targetCharacter = get(selectedTargetInventoryAtom)
+ const { data: inventory } = get(currentCharacterInventoryAtom)
+
+ if (!sourceCharacter || !targetCharacter || !inventory) {
+ return
+ }
+
+ const itemsWithDetails = new Map()
+
+ selectedItems.forEach((count, itemId) => {
+ const item = inventory.items.get(itemId)
+ if (item) {
+ itemsWithDetails.set(itemId, { item, count })
+ }
+ })
+
+ set(moveConfirmationAtom, {
+ isOpen: true,
+ selectedItems: itemsWithDetails,
+ sourceCharacter,
+ targetCharacter,
+ })
+})
+
+export const closeMoveConfirmationAtom = atom(null, (_get, set) => {
+ set(moveConfirmationAtom, {
+ isOpen: false,
+ selectedItems: new Map(),
+ })
+})
+
+export const moveSelectedItemsAtom = atom(null, async (get, _set): Promise => {
+ const itemMover = new ItemMover(LTOApi)
+ const confirmationState = get(moveConfirmationAtom)
+ const selectedItems = confirmationState.isOpen
+ ? new Map(
+ Array.from(confirmationState.selectedItems.entries()).map(([id, { count }]) => [id, count]),
+ )
+ : get(currentItemSelectionAtom)[0]
+ const sourceCharacter = confirmationState.sourceCharacter || get(selectedCharacterAtom)
+ const targetCharacter = confirmationState.targetCharacter || get(selectedTargetInventoryAtom)
+ const { data: sourceInventory } = get(currentCharacterInventoryAtom)
+
+ const result: MoveItemsResult = {
+ totalItems: selectedItems.size,
+ successCount: 0,
+ failedCount: 0,
+ errors: [],
+ }
+
+ if (!sourceCharacter || !targetCharacter) {
+ throw new Error('Source or target character not selected')
+ }
+
+ if (!sourceInventory) {
+ throw new Error('Source inventory not loaded')
+ }
+
+ if (selectedItems.size === 0) {
+ return result
+ }
+
+ // Track successful moves to update counts
+ const successfulMoves: Array<{ itemId: string; count: number }> = []
+
+ // Process each selected item
+ const movePromises = Array.from(selectedItems.entries()).map(async ([itemId, count]) => {
+ const item = sourceInventory.items.get(itemId)
+ if (!item) {
+ result.errors.push({ itemId, error: 'Item not found in inventory' })
+ result.failedCount++
+ return
+ }
+
+ try {
+ const isTargetBank = !targetCharacter.path.includes('/')
+ const moveResult = await itemMover.moveItem(
+ item.unique_id.toString(),
+ count,
+ isTargetBank ? undefined : targetCharacter.id.toString(),
+ isTargetBank ? targetCharacter.account_id.toString() : undefined,
+ )
+ if (moveResult.success) {
+ result.successCount++
+ successfulMoves.push({ itemId, count })
+ } else {
+ result.errors.push({ itemId, error: moveResult.error || 'Unknown error' })
+ result.failedCount++
+ }
+ } catch (error) {
+ result.errors.push({
+ itemId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ })
+ result.failedCount++
+ }
+ })
+
+ await Promise.all(movePromises)
+
+ // Update the inventory optimistically
+ if (successfulMoves.length > 0 && sourceInventory) {
+ const updatedItems = new Map(sourceInventory.items)
+
+ for (const { itemId, count } of successfulMoves) {
+ const item = updatedItems.get(itemId)
+ if (item) {
+ const newCount = item.item_count - count
+ if (newCount <= 0) {
+ // Remove item if count reaches 0
+ updatedItems.delete(itemId)
+ } else {
+ // Update item count
+ updatedItems.set(itemId, { ...item, item_count: newCount })
+ }
+ }
+ }
+
+ // Update the local inventory state
+ sourceInventory.items = updatedItems
+
+ // Trigger a refetch to sync with server
+ const { refetch } = get(currentCharacterInventoryAtom)
+ refetch()
+ }
+
+ return result
+})