This commit is contained in:
a 2025-06-23 01:33:03 -05:00
parent f00708e80d
commit a0754399c7
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
15 changed files with 1152 additions and 178 deletions

26
.dockerignore Normal file
View 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
View 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

View File

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

View File

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

View 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>
)
}

View 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>
)
}

View File

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

View File

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

View File

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

View 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',
}
}
}

View File

@ -36,6 +36,7 @@ export class LTOApiv0 implements LTOApi {
endpoint = market_endpoint
break
case 'sell-item':
//case 'internal-xfer-item':
VERB = 'POSTFORM'
break
default:

View File

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

View File

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

View File

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