forked from a/lifeto-shop
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
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 { 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 (
|
||||
<div className="flex flex-row gap-1 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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 className="flex items-center px-2 bg-yellow-100 border border-black-1 whitespace-pre select-none">
|
||||
{inventoryRange.start}..{inventoryRange.end}/{items.length}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 <div>select a character</div>
|
||||
@ -103,88 +73,116 @@ export const Inventory = () => {
|
||||
return (
|
||||
<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-row justify-between">
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-0 items-center justify-between">
|
||||
<div className="flex flex-row gap-0 items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:cursor-pointer border border-black-1 bg-blue-200 hover:bg-blue-300 px-2 py-1"
|
||||
onClick={() => {
|
||||
if (selectedCharacter) {
|
||||
refetchInventory()
|
||||
}
|
||||
}}
|
||||
title="Refresh inventory"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
className="border border-black-1 px-2 py-1"
|
||||
placeholder="search..."
|
||||
onChange={e => {
|
||||
setSearch(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 flex items-center justify-center"
|
||||
onClick={() => {
|
||||
paginateInventory(-1)
|
||||
}}
|
||||
aria-label="Previous page"
|
||||
title="Previous page (← arrow key)"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 flex items-center justify-center"
|
||||
onClick={() => {
|
||||
paginateInventory(1)
|
||||
}}
|
||||
aria-label="Next page"
|
||||
title="Next page (→ arrow key)"
|
||||
>
|
||||
<FaArrowRight />
|
||||
</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-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
|
||||
className="whitespace-pre bg-purple-200 px-2 py-1 hover:cursor-pointer hover:bg-purple-300 border border-black-1"
|
||||
onClick={() => {
|
||||
addPageItemSelection()
|
||||
addFilterItemSelection()
|
||||
}}
|
||||
>
|
||||
select filtered
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="whitespace-pre bg-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
|
||||
className="whitespace-pre bg-cyan-200 px-2 py-1 hover:cursor-pointer hover:bg-cyan-300 border border-black-1"
|
||||
onClick={() => {
|
||||
addFilterItemSelection()
|
||||
addPageItemSelection()
|
||||
}}
|
||||
>
|
||||
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"
|
||||
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 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
|
||||
type="text"
|
||||
value={search}
|
||||
className="border border-black-1 px-2 py-1"
|
||||
placeholder="search..."
|
||||
onChange={e => {
|
||||
setSearch(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
onClick={() => {
|
||||
paginateInventory(-1)
|
||||
}}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</button>
|
||||
<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"
|
||||
onClick={() => {
|
||||
paginateInventory(1)
|
||||
}}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<FaArrowRight />
|
||||
clear
|
||||
</button>
|
||||
</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">
|
||||
<InventoryTable />
|
||||
</div>
|
||||
<MoveConfirmationPopup />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<HTMLDivElement>
|
||||
>(({ children, active, ...rest }, ref) => {
|
||||
const id = useId()
|
||||
const isDisabled = rest['aria-disabled']
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@ -32,12 +33,14 @@ const AccountInventorySelectorItem = forwardRef<
|
||||
role="option"
|
||||
id={id}
|
||||
aria-selected={active}
|
||||
aria-disabled={isDisabled}
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
style={{
|
||||
background: active ? 'lightblue' : 'none',
|
||||
background: active && !isDisabled ? 'lightblue' : 'none',
|
||||
padding: 4,
|
||||
cursor: 'default',
|
||||
cursor: isDisabled ? 'not-allowed' : 'default',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
...rest.style,
|
||||
}}
|
||||
>
|
||||
@ -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 (
|
||||
<>
|
||||
<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({
|
||||
ref: refs.setReference,
|
||||
onChange,
|
||||
value: selectedTargetInventory !== undefined ? selectedTargetInventory.name : inputValue,
|
||||
value:
|
||||
selectedTargetInventory !== undefined
|
||||
? !selectedTargetInventory.path.includes('/')
|
||||
? `[Bank] ${selectedTargetInventory.account_name}`
|
||||
: selectedTargetInventory.name
|
||||
: inputValue,
|
||||
placeholder: 'Target Inventory',
|
||||
'aria-autocomplete': 'list',
|
||||
onFocus() {
|
||||
@ -128,7 +143,7 @@ export const InventoryTargetSelector = () => {
|
||||
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) => (
|
||||
<AccountInventorySelectorItem
|
||||
key={item.path}
|
||||
{...getItemProps({
|
||||
ref(node) {
|
||||
listRef.current[index] = node
|
||||
},
|
||||
onClick() {
|
||||
setInputValue(item.name)
|
||||
setSelectedTargetInventory(item)
|
||||
setOpen(false)
|
||||
refs.domReference.current?.focus()
|
||||
},
|
||||
})}
|
||||
active={activeIndex === index}
|
||||
>
|
||||
{item.name}
|
||||
</AccountInventorySelectorItem>
|
||||
))}
|
||||
<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
|
||||
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}
|
||||
>
|
||||
{item.name}
|
||||
</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>
|
||||
</FloatingFocusManager>
|
||||
</FloatingPortal>
|
||||
|
||||
@ -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 (
|
||||
<div className="overflow-y-auto h-full mb-32">
|
||||
<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
|
||||
break
|
||||
case 'sell-item':
|
||||
//case 'internal-xfer-item':
|
||||
VERB = 'POSTFORM'
|
||||
break
|
||||
default:
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`no-select flex flex-row ${row.original.status?.selected ? 'animate-pulse' : ''}`}
|
||||
onClick={_e => {
|
||||
setItemSelection({
|
||||
[row.original.item.id]: selected ? undefined : row.original.item.item_count,
|
||||
})
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<div className="flex flex-row w-6 h-6 justify-center">
|
||||
<img
|
||||
src={row.original.item.item_image || ''}
|
||||
alt="icon"
|
||||
className="select-none object-contain select-none"
|
||||
className="select-none object-contain pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@ -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
|
||||
<div
|
||||
className={`flex flex-row select-none ${row.original.status?.selected ? 'bg-gray-200' : ''}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<input
|
||||
className="w-10 text-center "
|
||||
@ -96,8 +149,48 @@ const columns = {
|
||||
return <div className="flex flex-row text-sm">name</div>
|
||||
},
|
||||
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 (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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<string, number>(), 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<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