noot
This commit is contained in:
parent
f00708e80d
commit
a0754399c7
26
.dockerignore
Normal file
26
.dockerignore
Normal file
@ -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
|
||||||
74
CLAUDE.md
Normal file
74
CLAUDE.md
Normal file
@ -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
|
||||||
13
Caddyfile
13
Caddyfile
@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
admin off
|
admin off
|
||||||
|
log {
|
||||||
|
include http.log.access http.handlers.reverse_proxy
|
||||||
|
output stdout
|
||||||
|
format console
|
||||||
|
level debug
|
||||||
|
}
|
||||||
}
|
}
|
||||||
:{$PORT:8080} {
|
:{$PORT:8080} {
|
||||||
root * {$ROOT:./dist}
|
root * {$ROOT:./dist}
|
||||||
@ -9,7 +15,9 @@
|
|||||||
handle /lifeto/* {
|
handle /lifeto/* {
|
||||||
uri strip_prefix /lifeto
|
uri strip_prefix /lifeto
|
||||||
reverse_proxy https://beta.lifeto.co {
|
reverse_proxy https://beta.lifeto.co {
|
||||||
|
header_up Host {upstream_hostport}
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up User-Agent "LifetoShop/1.0"
|
||||||
header_down -Connection
|
header_down -Connection
|
||||||
header_down -Keep-Alive
|
header_down -Keep-Alive
|
||||||
header_down -Proxy-Authenticate
|
header_down -Proxy-Authenticate
|
||||||
@ -20,9 +28,4 @@
|
|||||||
header_down -Upgrade
|
header_down -Upgrade
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log {
|
|
||||||
output stdout
|
|
||||||
format console
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
FROM node:18.1-alpine as NODEBUILDER
|
FROM node:24-alpine as NODEBUILDER
|
||||||
WORKDIR /wd
|
WORKDIR /wd
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm install
|
RUN corepack yarn install
|
||||||
RUN npx vite build
|
RUN corepack yarn build
|
||||||
|
|
||||||
FROM caddyserver/caddy:2.10-alpine
|
FROM caddy:2.10-alpine
|
||||||
WORKDIR /wd
|
WORKDIR /wd
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
COPY --from=NODEBUILDER /wd/dist dist
|
COPY --from=NODEBUILDER /wd/dist dist
|
||||||
|
|||||||
22
fly.toml
Normal file
22
fly.toml
Normal file
@ -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
|
||||||
207
src/components/inventory/InventoryFilters.tsx
Normal file
207
src/components/inventory/InventoryFilters.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{sections.map(x => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setInventoryFilterTab(x.value)
|
||||||
|
}}
|
||||||
|
key={x.name}
|
||||||
|
className={`${sharedStyle} ${inventoryFilter.tab === x.value ? selectedStyle : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{x.value === '' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/gel.nri.003.000.png"
|
||||||
|
alt="All"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '1' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/itm000.nri.00c.000.png"
|
||||||
|
alt="Consume"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '2' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/itm_cm_wp_106.nri.000.000.png"
|
||||||
|
alt="Equip"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '3' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/dri001.nri.000.000.png"
|
||||||
|
alt="Drill"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '4' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/pet_inv001.nri.015.000.png"
|
||||||
|
alt="Pet"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '5' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/itm_cm_ear_020.nri.001.000.png"
|
||||||
|
alt="Etc"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
ref={refs.setReference}
|
||||||
|
type="button"
|
||||||
|
className={`${sharedStyle} ${isCardSectionSelected ? selectedStyle : ''}`}
|
||||||
|
{...getReferenceProps()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/card_com_001.nri.000.000.png"
|
||||||
|
alt="Card"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
card
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isCardDropdownOpen && (
|
||||||
|
<FloatingPortal>
|
||||||
|
<div
|
||||||
|
ref={refs.setFloating}
|
||||||
|
style={floatingStyles}
|
||||||
|
className="bg-white border border-gray-300 shadow-lg rounded-md py-1 z-50"
|
||||||
|
{...getFloatingProps()}
|
||||||
|
>
|
||||||
|
{cardSections.map(x => (
|
||||||
|
<button
|
||||||
|
key={x.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
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' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{x.value === '10' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/card_skill_c_202.nri.000.000.png"
|
||||||
|
alt="Skill"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '11' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/cardch001.nri.006.000.png"
|
||||||
|
alt="Character"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '12' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/cardmo001.nri.019.000.png"
|
||||||
|
alt="Monster"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '13' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/card_ftn_001.nri.000.000.png"
|
||||||
|
alt="Fortune"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '14' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/card_scr_001.nri.000.000.png"
|
||||||
|
alt="Secret"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.value === '15' && (
|
||||||
|
<img
|
||||||
|
src="https://beta.lifeto.co/item_img/card_ear_002.nri.001.000.png"
|
||||||
|
alt="Arcana"
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{x.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
src/components/inventory/MoveConfirmationPopup.tsx
Normal file
182
src/components/inventory/MoveConfirmationPopup.tsx
Normal file
@ -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<MoveItemsResult | null>(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 (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-lg font-semibold">Moving {totalUniqueItems} different items</p>
|
||||||
|
<p className="text-sm text-gray-600">Total quantity: {totalQuantity.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{itemsArray.map(({ item, count }) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center justify-between px-2 py-1 hover:bg-gray-50 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={item.item_image || ''}
|
||||||
|
alt={item.item_name}
|
||||||
|
className="w-6 h-6 object-contain"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{item.item_name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-600">×{count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloatingPortal>
|
||||||
|
<FloatingOverlay className="grid place-items-center bg-black/50 z-50" lockScroll>
|
||||||
|
<FloatingFocusManager context={context} initialFocus={-1}>
|
||||||
|
<div
|
||||||
|
ref={refs.setFloating}
|
||||||
|
className="bg-white rounded-lg shadow-xl border border-gray-200 p-6 max-w-md w-full mx-4"
|
||||||
|
{...getFloatingProps()}
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-bold mb-4">Confirm Item Movement</h2>
|
||||||
|
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-600">From:</span>
|
||||||
|
<span className="font-medium">{sourceCharacter?.name || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-600">To:</span>
|
||||||
|
<span className="font-medium">{targetCharacter?.name || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-b border-gray-200 py-4 mb-4">{renderItemPreview()}</div>
|
||||||
|
|
||||||
|
{moveResult && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 p-3 rounded ${moveResult.failedCount > 0 ? 'bg-yellow-50' : 'bg-green-50'}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{moveResult.failedCount === 0
|
||||||
|
? `Successfully moved ${moveResult.successCount} items!`
|
||||||
|
: `Moved ${moveResult.successCount} of ${moveResult.totalItems} items`}
|
||||||
|
</p>
|
||||||
|
{moveResult.errors.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs text-red-600">
|
||||||
|
{moveResult.errors.slice(0, 3).map(error => (
|
||||||
|
<p key={error.itemId}>{error.error}</p>
|
||||||
|
))}
|
||||||
|
{moveResult.errors.length > 3 && (
|
||||||
|
<p>...and {moveResult.errors.length - 3} more errors</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isMoving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isMoving || moveResult !== null}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isMoving ? 'Moving...' : 'Confirm Move'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FloatingFocusManager>
|
||||||
|
</FloatingOverlay>
|
||||||
|
</FloatingPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,101 +1,71 @@
|
|||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
|
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
|
||||||
import {
|
import {
|
||||||
clearItemSelectionActionAtom,
|
clearItemSelectionActionAtom,
|
||||||
|
currentCharacterInventoryAtom,
|
||||||
filteredCharacterItemsAtom,
|
filteredCharacterItemsAtom,
|
||||||
inventoryFilterAtom,
|
|
||||||
inventoryPageRangeAtom,
|
inventoryPageRangeAtom,
|
||||||
itemSelectionSelectAllFilterActionAtom,
|
itemSelectionSelectAllFilterActionAtom,
|
||||||
itemSelectionSelectAllPageActionAtom,
|
itemSelectionSelectAllPageActionAtom,
|
||||||
|
moveSelectedItemsAtom,
|
||||||
|
openMoveConfirmationAtom,
|
||||||
paginateInventoryActionAtom,
|
paginateInventoryActionAtom,
|
||||||
preferenceInventorySearch,
|
preferenceInventorySearch,
|
||||||
selectedCharacterAtom,
|
selectedCharacterAtom,
|
||||||
setInventoryFilterTabActionAtom,
|
|
||||||
} from '@/state/atoms'
|
} from '@/state/atoms'
|
||||||
|
import { MoveConfirmationPopup } from './MoveConfirmationPopup'
|
||||||
import { InventoryTargetSelector } from './movetarget'
|
import { InventoryTargetSelector } from './movetarget'
|
||||||
import { InventoryTable } from './table'
|
import { InventoryTable } from './table'
|
||||||
|
import { InventoryFilters } from './InventoryFilters'
|
||||||
|
|
||||||
const sections = [
|
const InventoryRangeDisplay = () => {
|
||||||
{ 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 inventoryRange = useAtomValue(inventoryPageRangeAtom)
|
const inventoryRange = useAtomValue(inventoryPageRangeAtom)
|
||||||
const items = useAtomValue(filteredCharacterItemsAtom)
|
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 (
|
return (
|
||||||
<div className="flex flex-row gap-1 justify-between">
|
<div className="flex items-center px-2 bg-yellow-100 border border-black-1 whitespace-pre select-none">
|
||||||
<div className="flex flex-col gap-1">
|
{inventoryRange.start}..{inventoryRange.end}/{items.length}
|
||||||
<div className="flex flex-row gap-1">
|
|
||||||
{sections.map(x => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setInventoryFilterTab(x.value)
|
|
||||||
}}
|
|
||||||
key={x.name}
|
|
||||||
className={`${sharedStyle}
|
|
||||||
${inventoryFilter.tab === x.value ? selectedStyle : ''}`}
|
|
||||||
>
|
|
||||||
{x.name}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-1">
|
|
||||||
{cardSections.map(x => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setInventoryFilterTab(x.value)
|
|
||||||
}}
|
|
||||||
key={x.name}
|
|
||||||
className={`${sharedStyle}
|
|
||||||
${inventoryFilter.tab === x.value ? selectedStyle : ''}`}
|
|
||||||
>
|
|
||||||
{x.name}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-1 items-center px-1 bg-yellow-100">
|
|
||||||
<div className="whitespace-pre select-none">
|
|
||||||
{inventoryRange.start}..{inventoryRange.end}/{items.length}{' '}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const Inventory = () => {
|
export const Inventory = () => {
|
||||||
const selectedCharacter = useAtomValue(selectedCharacterAtom)
|
const selectedCharacter = useAtomValue(selectedCharacterAtom)
|
||||||
const clearItemSelection = useSetAtom(clearItemSelectionActionAtom)
|
const clearItemSelection = useSetAtom(clearItemSelectionActionAtom)
|
||||||
|
const { refetch: refetchInventory } = useAtomValue(currentCharacterInventoryAtom)
|
||||||
|
|
||||||
const addPageItemSelection = useSetAtom(itemSelectionSelectAllPageActionAtom)
|
const addPageItemSelection = useSetAtom(itemSelectionSelectAllPageActionAtom)
|
||||||
const addFilterItemSelection = useSetAtom(itemSelectionSelectAllFilterActionAtom)
|
const addFilterItemSelection = useSetAtom(itemSelectionSelectAllFilterActionAtom)
|
||||||
const [search, setSearch] = useAtom(preferenceInventorySearch)
|
const [search, setSearch] = useAtom(preferenceInventorySearch)
|
||||||
|
|
||||||
const paginateInventory = useSetAtom(paginateInventoryActionAtom)
|
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) {
|
if (!selectedCharacter) {
|
||||||
return <div>select a character</div>
|
return <div>select a character</div>
|
||||||
@ -103,52 +73,21 @@ export const Inventory = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full w-full`}>
|
<div className={`flex flex-col h-full w-full`}>
|
||||||
<div className="flex flex-col py-2 flex-0 justify-between h-full">
|
<div className="flex flex-col py-2 flex-0 justify-between h-full">
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-0 items-center justify-between">
|
||||||
|
<div className="flex flex-row gap-0 items-stretch">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="whitespace-pre bg-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
|
className="hover:cursor-pointer border border-black-1 bg-blue-200 hover:bg-blue-300 px-2 py-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
addPageItemSelection()
|
if (selectedCharacter) {
|
||||||
|
refetchInventory()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
title="Refresh inventory"
|
||||||
>
|
>
|
||||||
select filtered
|
↻
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="whitespace-pre bg-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
|
|
||||||
onClick={() => {
|
|
||||||
addFilterItemSelection()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
select page
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="whitespace-pre bg-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
|
|
||||||
onClick={() => {
|
|
||||||
clearItemSelection()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
clear{' '}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row">
|
|
||||||
<InventoryTargetSelector />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={_e => {
|
|
||||||
// sendOrders()
|
|
||||||
}}
|
|
||||||
className="hover:cursor-pointer whitespace-preborder border-black-1 bg-orange-200 hover:bg-orange-300 px-2 py-1"
|
|
||||||
>
|
|
||||||
Move Selected
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-between">
|
|
||||||
<div className="flex flex-row gap-0 items-center">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
@ -160,31 +99,90 @@ export const Inventory = () => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 py-1 h-full flex items-center"
|
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 flex items-center justify-center"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
paginateInventory(-1)
|
paginateInventory(-1)
|
||||||
}}
|
}}
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
|
title="Previous page (← arrow key)"
|
||||||
>
|
>
|
||||||
<FaArrowLeft />
|
<FaArrowLeft />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 py-1 h-full flex items-center"
|
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 flex items-center justify-center"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
paginateInventory(1)
|
paginateInventory(1)
|
||||||
}}
|
}}
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
|
title="Next page (→ arrow key)"
|
||||||
>
|
>
|
||||||
<FaArrowRight />
|
<FaArrowRight />
|
||||||
</button>
|
</button>
|
||||||
|
<InventoryRangeDisplay />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-0">
|
||||||
|
<InventoryTargetSelector />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async e => {
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="whitespace-pre bg-purple-200 px-2 py-1 hover:cursor-pointer hover:bg-purple-300 border border-black-1"
|
||||||
|
onClick={() => {
|
||||||
|
addFilterItemSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
select filtered
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="whitespace-pre bg-cyan-200 px-2 py-1 hover:cursor-pointer hover:bg-cyan-300 border border-black-1"
|
||||||
|
onClick={() => {
|
||||||
|
addPageItemSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
select page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="whitespace-pre bg-red-200 px-2 py-1 hover:cursor-pointer hover:bg-red-300 border border-black-1"
|
||||||
|
onClick={() => {
|
||||||
|
clearItemSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<InventoryTabs />
|
<div className="flex flex-row justify-between items-center">
|
||||||
|
<InventoryFilters />
|
||||||
|
<InventoryRangeDisplay />
|
||||||
|
</div>
|
||||||
<div className="flex flex-col flex-1 h-full border border-black-2">
|
<div className="flex flex-col flex-1 h-full border border-black-2">
|
||||||
<InventoryTable />
|
<InventoryTable />
|
||||||
</div>
|
</div>
|
||||||
|
<MoveConfirmationPopup />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
import { forwardRef, useId, useMemo, useRef, useState } from 'react'
|
import { forwardRef, useId, useMemo, useRef, useState } from 'react'
|
||||||
import { charactersAtom, selectedTargetInventoryAtom } from '@/state/atoms'
|
import { charactersAtom, selectedCharacterAtom, selectedTargetInventoryAtom } from '@/state/atoms'
|
||||||
|
|
||||||
interface AccountInventorySelectorItemProps {
|
interface AccountInventorySelectorItemProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@ -25,6 +25,7 @@ const AccountInventorySelectorItem = forwardRef<
|
|||||||
AccountInventorySelectorItemProps & React.HTMLProps<HTMLDivElement>
|
AccountInventorySelectorItemProps & React.HTMLProps<HTMLDivElement>
|
||||||
>(({ children, active, ...rest }, ref) => {
|
>(({ children, active, ...rest }, ref) => {
|
||||||
const id = useId()
|
const id = useId()
|
||||||
|
const isDisabled = rest['aria-disabled']
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@ -32,12 +33,14 @@ const AccountInventorySelectorItem = forwardRef<
|
|||||||
role="option"
|
role="option"
|
||||||
id={id}
|
id={id}
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
{...rest}
|
{...rest}
|
||||||
style={{
|
style={{
|
||||||
background: active ? 'lightblue' : 'none',
|
background: active && !isDisabled ? 'lightblue' : 'none',
|
||||||
padding: 4,
|
padding: 4,
|
||||||
cursor: 'default',
|
cursor: isDisabled ? 'not-allowed' : 'default',
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
...rest.style,
|
...rest.style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -61,7 +64,7 @@ export const InventoryTargetSelector = () => {
|
|||||||
size({
|
size({
|
||||||
apply({ rects, availableHeight, elements }) {
|
apply({ rects, availableHeight, elements }) {
|
||||||
Object.assign(elements.floating.style, {
|
Object.assign(elements.floating.style, {
|
||||||
width: `${rects.reference.width}px`,
|
width: `${Math.max(rects.reference.width * 2, 400)}px`,
|
||||||
maxHeight: `${availableHeight}px`,
|
maxHeight: `${availableHeight}px`,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -99,11 +102,14 @@ export const InventoryTargetSelector = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { data: subaccounts } = useAtomValue(charactersAtom)
|
const { data: subaccounts } = useAtomValue(charactersAtom)
|
||||||
|
const selectedCharacter = useAtomValue(selectedCharacterAtom)
|
||||||
|
|
||||||
const [selectedTargetInventory, setSelectedTargetInventory] = useAtom(selectedTargetInventoryAtom)
|
const [selectedTargetInventory, setSelectedTargetInventory] = useAtom(selectedTargetInventoryAtom)
|
||||||
|
|
||||||
const searcher = useMemo(() => {
|
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'],
|
keys: ['path', 'name'],
|
||||||
findAllMatches: true,
|
findAllMatches: true,
|
||||||
threshold: 0.8,
|
threshold: 0.8,
|
||||||
@ -111,15 +117,24 @@ export const InventoryTargetSelector = () => {
|
|||||||
})
|
})
|
||||||
}, [subaccounts])
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
className="border border-black-1 bg-gray-100 placeholder-gray-600"
|
className={`border border-black-1 placeholder-gray-600 ${
|
||||||
|
selectedTargetInventory ? 'bg-green-100' : inputValue ? 'bg-yellow-200' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
{...getReferenceProps({
|
{...getReferenceProps({
|
||||||
ref: refs.setReference,
|
ref: refs.setReference,
|
||||||
onChange,
|
onChange,
|
||||||
value: selectedTargetInventory !== undefined ? selectedTargetInventory.name : inputValue,
|
value:
|
||||||
|
selectedTargetInventory !== undefined
|
||||||
|
? !selectedTargetInventory.path.includes('/')
|
||||||
|
? `[Bank] ${selectedTargetInventory.account_name}`
|
||||||
|
: selectedTargetInventory.name
|
||||||
|
: inputValue,
|
||||||
placeholder: 'Target Inventory',
|
placeholder: 'Target Inventory',
|
||||||
'aria-autocomplete': 'list',
|
'aria-autocomplete': 'list',
|
||||||
onFocus() {
|
onFocus() {
|
||||||
@ -128,7 +143,7 @@ export const InventoryTargetSelector = () => {
|
|||||||
onKeyDown(event) {
|
onKeyDown(event) {
|
||||||
if (event.key === 'Enter' && activeIndex != null && items[activeIndex]) {
|
if (event.key === 'Enter' && activeIndex != null && items[activeIndex]) {
|
||||||
setSelectedTargetInventory(items[activeIndex])
|
setSelectedTargetInventory(items[activeIndex])
|
||||||
setInputValue(items[activeIndex].name)
|
setInputValue('')
|
||||||
setActiveIndex(null)
|
setActiveIndex(null)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
@ -149,25 +164,68 @@ export const InventoryTargetSelector = () => {
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '10px', padding: '5px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{items
|
||||||
|
.filter(item => item.path.includes('/'))
|
||||||
|
.map(item => {
|
||||||
|
const actualIndex = items.indexOf(item)
|
||||||
|
const isDisabled = item.path === selectedCharacter?.path
|
||||||
|
return (
|
||||||
<AccountInventorySelectorItem
|
<AccountInventorySelectorItem
|
||||||
key={item.path}
|
key={item.path}
|
||||||
{...getItemProps({
|
{...getItemProps({
|
||||||
ref(node) {
|
ref(node) {
|
||||||
listRef.current[index] = node
|
listRef.current[actualIndex] = node
|
||||||
},
|
},
|
||||||
onClick() {
|
onClick() {
|
||||||
setInputValue(item.name)
|
if (!isDisabled) {
|
||||||
|
setInputValue('')
|
||||||
setSelectedTargetInventory(item)
|
setSelectedTargetInventory(item)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
refs.domReference.current?.focus()
|
refs.domReference.current?.focus()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
active={activeIndex === index}
|
active={activeIndex === actualIndex}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</AccountInventorySelectorItem>
|
</AccountInventorySelectorItem>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{items
|
||||||
|
.filter(item => !item.path.includes('/'))
|
||||||
|
.map(item => {
|
||||||
|
const actualIndex = items.indexOf(item)
|
||||||
|
const isDisabled = item.path === selectedCharacter?.path
|
||||||
|
return (
|
||||||
|
<AccountInventorySelectorItem
|
||||||
|
key={item.path}
|
||||||
|
{...getItemProps({
|
||||||
|
ref(node) {
|
||||||
|
listRef.current[actualIndex] = node
|
||||||
|
},
|
||||||
|
onClick() {
|
||||||
|
if (!isDisabled) {
|
||||||
|
setInputValue('')
|
||||||
|
setSelectedTargetInventory(item)
|
||||||
|
setOpen(false)
|
||||||
|
refs.domReference.current?.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
active={activeIndex === actualIndex}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
>
|
||||||
|
[Bank] {item.account_name}
|
||||||
|
</AccountInventorySelectorItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FloatingFocusManager>
|
</FloatingFocusManager>
|
||||||
</FloatingPortal>
|
</FloatingPortal>
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||||
import { atom, useAtomValue } from 'jotai'
|
import { atom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { StatsColumns } from '@/lib/columns'
|
import { StatsColumns } from '@/lib/columns'
|
||||||
import { ItemWithSelection } from '@/lib/table/defs'
|
import { ItemWithSelection } from '@/lib/table/defs'
|
||||||
import { InventoryColumns } from '@/lib/table/tanstack'
|
import { InventoryColumns } from '@/lib/table/tanstack'
|
||||||
import { inventoryItemsCurrentPageAtom, preferenceInventoryTab } from '@/state/atoms'
|
import {
|
||||||
|
inventoryItemsCurrentPageAtom,
|
||||||
|
mouseDragSelectionStateAtom,
|
||||||
|
preferenceInventoryTab,
|
||||||
|
} from '@/state/atoms'
|
||||||
|
|
||||||
const columnVisibilityAtom = atom(get => {
|
const columnVisibilityAtom = atom(get => {
|
||||||
const itemTab = get(preferenceInventoryTab)
|
const itemTab = get(preferenceInventoryTab)
|
||||||
@ -15,6 +19,7 @@ const columnVisibilityAtom = atom(get => {
|
|||||||
})
|
})
|
||||||
export const InventoryTable = () => {
|
export const InventoryTable = () => {
|
||||||
const items = useAtomValue(inventoryItemsCurrentPageAtom)
|
const items = useAtomValue(inventoryItemsCurrentPageAtom)
|
||||||
|
const setDragState = useSetAtom(mouseDragSelectionStateAtom)
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [...Object.values(InventoryColumns)]
|
return [...Object.values(InventoryColumns)]
|
||||||
@ -32,6 +37,18 @@ export const InventoryTable = () => {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
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 (
|
return (
|
||||||
<div className="overflow-y-auto h-full mb-32">
|
<div className="overflow-y-auto h-full mb-32">
|
||||||
<table
|
<table
|
||||||
|
|||||||
121
src/lib/lifeto/item_mover.ts
Normal file
121
src/lib/lifeto/item_mover.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { LTOApi } from './api'
|
||||||
|
|
||||||
|
export interface InternalXferParams {
|
||||||
|
itemUid: string | 'galders'
|
||||||
|
count: number
|
||||||
|
targetCharId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankItemParams {
|
||||||
|
itemUid: string | 'galders'
|
||||||
|
count: number
|
||||||
|
targetAccount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
data?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ItemMover {
|
||||||
|
constructor(private api: LTOApi) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer items between characters
|
||||||
|
* Uses internal-xfer-item API
|
||||||
|
*/
|
||||||
|
async internalXfer(params: InternalXferParams): Promise<MoveResult> {
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
item_uid: params.itemUid,
|
||||||
|
qty: params.count.toString(),
|
||||||
|
new_char: params.targetCharId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.api.BankAction<any, any>('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<MoveResult> {
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
item_uid: params.itemUid,
|
||||||
|
qty: params.count.toString(),
|
||||||
|
account: params.targetAccount,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.api.BankAction<any, any>('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<MoveResult> {
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@ export class LTOApiv0 implements LTOApi {
|
|||||||
endpoint = market_endpoint
|
endpoint = market_endpoint
|
||||||
break
|
break
|
||||||
case 'sell-item':
|
case 'sell-item':
|
||||||
|
//case 'internal-xfer-item':
|
||||||
VERB = 'POSTFORM'
|
VERB = 'POSTFORM'
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { TricksterAccountInfo } from './trickster'
|
|||||||
export const SITE_ROOT = '/lifeto/'
|
export const SITE_ROOT = '/lifeto/'
|
||||||
|
|
||||||
export const API_ROOT = 'api/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/'
|
export const MARKET_ROOT = 'marketplace-api/'
|
||||||
|
|
||||||
const raw_endpoint = (name: string): string => {
|
const raw_endpoint = (name: string): string => {
|
||||||
@ -18,7 +18,7 @@ export const api_endpoint = (name: string): string => {
|
|||||||
return SITE_ROOT + API_ROOT + name
|
return SITE_ROOT + API_ROOT + name
|
||||||
}
|
}
|
||||||
export const bank_endpoint = (name: string): string => {
|
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 => {
|
export const market_endpoint = (name: string): string => {
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { createColumnHelper } from '@tanstack/react-table'
|
import { createColumnHelper } from '@tanstack/react-table'
|
||||||
import { useAtomValue, useSetAtom } from 'jotai'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { currentItemSelectionAtom, itemSelectionSetActionAtom } from '@/state/atoms'
|
import {
|
||||||
|
currentItemSelectionAtom,
|
||||||
|
itemSelectionSetActionAtom,
|
||||||
|
mouseDragSelectionStateAtom,
|
||||||
|
} from '@/state/atoms'
|
||||||
import { StatsColumns } from '../columns'
|
import { StatsColumns } from '../columns'
|
||||||
import { ItemWithSelection } from './defs'
|
import { ItemWithSelection } from './defs'
|
||||||
|
|
||||||
@ -16,24 +20,51 @@ const columns = {
|
|||||||
cell: function Component({ row }) {
|
cell: function Component({ row }) {
|
||||||
const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
|
const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
|
||||||
const c = useAtomValue(currentItemSelectionAtom)
|
const c = useAtomValue(currentItemSelectionAtom)
|
||||||
|
const [dragState, setDragState] = useAtom(mouseDragSelectionStateAtom)
|
||||||
const selected = useMemo(() => {
|
const selected = useMemo(() => {
|
||||||
return c[0].has(row.original.item.id)
|
return c[0].has(row.original.item.id)
|
||||||
}, [c])
|
}, [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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`no-select flex flex-row ${row.original.status?.selected ? 'animate-pulse' : ''}`}
|
className={`no-select flex flex-row ${row.original.status?.selected ? 'animate-pulse' : ''}`}
|
||||||
onClick={_e => {
|
onMouseDown={handleMouseDown}
|
||||||
setItemSelection({
|
onMouseEnter={handleMouseEnter}
|
||||||
[row.original.item.id]: selected ? undefined : row.original.item.item_count,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-row w-6 h-6 justify-center">
|
<div className="flex flex-row w-6 h-6 justify-center">
|
||||||
<img
|
<img
|
||||||
src={row.original.item.item_image || ''}
|
src={row.original.item.item_image || ''}
|
||||||
alt="icon"
|
alt="icon"
|
||||||
className="select-none object-contain select-none"
|
className="select-none object-contain pointer-events-none"
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -48,6 +79,7 @@ const columns = {
|
|||||||
cell: function Component({ row }) {
|
cell: function Component({ row }) {
|
||||||
const c = useAtomValue(currentItemSelectionAtom)
|
const c = useAtomValue(currentItemSelectionAtom)
|
||||||
const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
|
const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
|
||||||
|
const dragState = useAtomValue(mouseDragSelectionStateAtom)
|
||||||
const currentValue = useMemo(() => {
|
const currentValue = useMemo(() => {
|
||||||
const got = c[0].get(row.original.item.id)
|
const got = c[0].get(row.original.item.id)
|
||||||
if (got !== undefined) {
|
if (got !== undefined) {
|
||||||
@ -56,9 +88,30 @@ const columns = {
|
|||||||
return ''
|
return ''
|
||||||
}, [c])
|
}, [c])
|
||||||
const itemCount = row.original.item.item_count
|
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 (
|
return (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Using div for layout with input child
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select
|
||||||
<div
|
<div
|
||||||
className={`flex flex-row select-none ${row.original.status?.selected ? 'bg-gray-200' : ''}`}
|
className={`flex flex-row select-none ${row.original.status?.selected ? 'bg-gray-200' : ''}`}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="w-10 text-center "
|
className="w-10 text-center "
|
||||||
@ -96,8 +149,48 @@ const columns = {
|
|||||||
return <div className="flex flex-row text-sm">name</div>
|
return <div className="flex flex-row text-sm">name</div>
|
||||||
},
|
},
|
||||||
cell: function Component({ row }) {
|
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 (
|
return (
|
||||||
<div className="flex flex-row whitespace-pre">
|
// biome-ignore lint/a11y/useSemanticElements: Using div for text content
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select
|
||||||
|
<div
|
||||||
|
className="flex flex-row whitespace-pre cursor-pointer select-none hover:bg-gray-100"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
<span>{row.original.item.item_name}</span>
|
<span>{row.original.item.item_name}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { focusAtom } from 'jotai-optics'
|
|||||||
import { atomWithQuery } from 'jotai-tanstack-query'
|
import { atomWithQuery } from 'jotai-tanstack-query'
|
||||||
import { ItemWithSelection } from '@/lib/table/defs'
|
import { ItemWithSelection } from '@/lib/table/defs'
|
||||||
import { LTOApiv0 } from '../lib/lifeto'
|
import { LTOApiv0 } from '../lib/lifeto'
|
||||||
|
import { ItemMover } from '../lib/lifeto/item_mover'
|
||||||
import { LoginHelper, TokenSession } from '../lib/session'
|
import { LoginHelper, TokenSession } from '../lib/session'
|
||||||
import { TricksterCharacter, TricksterItem } from '../lib/trickster'
|
import { TricksterCharacter, TricksterItem } from '../lib/trickster'
|
||||||
import { createSuperjsonStorage } from './storage'
|
import { createSuperjsonStorage } from './storage'
|
||||||
@ -158,13 +159,19 @@ export const preferenceInventorySortReverse = focusAtom(inventoryFilterAtom, x =
|
|||||||
x.prop('sort_reverse'),
|
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 => {
|
set(inventoryFilterAtom, x => {
|
||||||
return {
|
return {
|
||||||
...x,
|
...x,
|
||||||
tab,
|
tab,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Reset pagination to first page when switching tabs
|
||||||
|
const pageSize = get(inventoryDisplaySettingsAtoms.pageSize)
|
||||||
|
set(inventoryPageRangeAtom, {
|
||||||
|
start: 0,
|
||||||
|
end: pageSize,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export const inventoryPageRangeAtom = atom({
|
export const inventoryPageRangeAtom = atom({
|
||||||
@ -250,6 +257,12 @@ export const rowSelectionLastActionAtom = atom<
|
|||||||
| undefined
|
| undefined
|
||||||
>(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) => {
|
export const clearItemSelectionActionAtom = atom(null, (_get, set) => {
|
||||||
set(currentItemSelectionAtom, [new Map<string, number>(), 0])
|
set(currentItemSelectionAtom, [new Map<string, number>(), 0])
|
||||||
})
|
})
|
||||||
@ -315,8 +328,10 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number |
|
|||||||
}
|
}
|
||||||
} else if (pages < 0) {
|
} else if (pages < 0) {
|
||||||
if (inventoryRange.start <= 0) {
|
if (inventoryRange.start <= 0) {
|
||||||
|
// Wrap around to the last page
|
||||||
|
const lastPageStart = Math.max(0, filteredItems.length - pageSize)
|
||||||
set(inventoryPageRangeAtom, {
|
set(inventoryPageRangeAtom, {
|
||||||
start: filteredItems.length - pageSize,
|
start: lastPageStart,
|
||||||
end: filteredItems.length,
|
end: filteredItems.length,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -325,11 +340,17 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number |
|
|||||||
const delta = pages * pageSize
|
const delta = pages * pageSize
|
||||||
let newStart = inventoryRange.start + delta
|
let newStart = inventoryRange.start + delta
|
||||||
let newEnd = inventoryRange.end + 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) {
|
if (newEnd > filteredItems.length) {
|
||||||
newEnd = filteredItems.length
|
newEnd = filteredItems.length
|
||||||
}
|
newStart = Math.max(0, newEnd - pageSize)
|
||||||
if (newEnd - newStart !== pageSize) {
|
|
||||||
newStart = newEnd - pageSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set(inventoryPageRangeAtom, {
|
set(inventoryPageRangeAtom, {
|
||||||
@ -337,3 +358,154 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number |
|
|||||||
end: newEnd,
|
end: newEnd,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export interface MoveItemsResult {
|
||||||
|
totalItems: number
|
||||||
|
successCount: number
|
||||||
|
failedCount: number
|
||||||
|
errors: Array<{ itemId: string; error: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveConfirmationState {
|
||||||
|
isOpen: boolean
|
||||||
|
selectedItems: Map<string, { item: TricksterItem; count: number }>
|
||||||
|
sourceCharacter?: TricksterCharacter
|
||||||
|
targetCharacter?: TricksterCharacter
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveConfirmationAtom = atom<MoveConfirmationState>({
|
||||||
|
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<string, { item: TricksterItem; count: number }>()
|
||||||
|
|
||||||
|
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<MoveItemsResult> => {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user