From a0754399c7d1b291c1655dc960cc0eddf4e933d2 Mon Sep 17 00:00:00 2001 From: a Date: Mon, 23 Jun 2025 01:33:03 -0500 Subject: [PATCH] noot --- .dockerignore | 26 ++ CLAUDE.md | 74 ++++++ Caddyfile | 13 +- Dockerfile | 8 +- fly.toml | 22 ++ src/components/inventory/InventoryFilters.tsx | 207 +++++++++++++++ .../inventory/MoveConfirmationPopup.tsx | 182 +++++++++++++ src/components/inventory/index.tsx | 242 +++++++++--------- src/components/inventory/movetarget.tsx | 114 +++++++-- src/components/inventory/table.tsx | 23 +- src/lib/lifeto/item_mover.ts | 121 +++++++++ src/lib/lifeto/lifeto.ts | 1 + src/lib/session.ts | 4 +- src/lib/table/tanstack.tsx | 111 +++++++- src/state/atoms.ts | 182 ++++++++++++- 15 files changed, 1152 insertions(+), 178 deletions(-) create mode 100644 .dockerignore create mode 100644 CLAUDE.md create mode 100644 fly.toml create mode 100644 src/components/inventory/InventoryFilters.tsx create mode 100644 src/components/inventory/MoveConfirmationPopup.tsx create mode 100644 src/lib/lifeto/item_mover.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b301d94 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# flyctl launch added from .gitignore +# Logs +**/logs +**/*.log +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +**/pnpm-debug.log* +**/lerna-debug.log* + +**/node_modules +**/dist +**/dist-ssr +**/*.local + +# Editor directories and files +**/.vscode/* +!**/.vscode/extensions.json +**/.idea +**/.DS_Store +**/*.suo +**/*.ntvs* +**/*.njsproj +**/*.sln +**/*.sw? +fly.toml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b188ae0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Essential Commands + +### Development +```bash +yarn dev # Start Vite development server on port 5173 +yarn preview # Preview production build locally +``` + +### Build & Deploy +```bash +yarn build # Create production build with Vite +make build # Build Docker image (tuxpa.in/a/lto:v0.0.2) +make push # Push Docker image to registry +``` + +### Code Quality +```bash +yarn lint # Check code with Biome +yarn lint:fix # Auto-fix linting issues +yarn format # Format code with Biome +``` + +## Architecture Overview + +This is a React-based inventory management system for the game "Trickster Online" via the lifeto.co platform. + +### Key Technologies +- **React 19** with TypeScript +- **Vite** for bundling and dev server +- **Jotai** for atomic state management +- **TanStack Query** for server state and caching +- **Tailwind CSS** for styling +- **Axios** for HTTP requests + +### Core Architecture + +1. **State Management Pattern**: + - Jotai atoms in `src/state/atoms.ts` handle all application state + - Uses `atomWithQuery` for server data integration + - Persistent storage via `atomWithStorage` with superjson serialization + - Actions are implemented as write-only atoms (e.g., `doLoginAtom`, `orderManagerAtom`) + +2. **API Integration**: + - All API calls go through `LTOApi` interface (`src/lib/lifeto/api.ts`) + - Token-based authentication via `TokenSession` + - Development: Vite proxy to `https://beta.lifeto.co` + - Production: Caddy reverse proxy configuration + +3. **Component Structure**: + - Entry: `src/index.tsx` → `App.tsx` + - Main sections: Login, Character Selection, Inventory Management + - Components follow atomic design with clear separation of concerns + +4. **Business Logic**: + - Domain models in `src/lib/trickster.ts` (Character, Item, Inventory) + - Order management via `OrderManager` class for item transfers + - Item filtering uses Fuse.js for fuzzy search + +5. **Data Flow**: + ``` + User Action → Component → Jotai Action Atom → API Call → + Server Response → Query Cache → Atom Update → UI Re-render + ``` + +### Development Notes + +- The app uses a proxy setup to avoid CORS issues with the lifeto.co API +- All API responses are strongly typed with TypeScript interfaces +- State persistence allows users to maintain their session and preferences +- The inventory system supports multi-character management with bulk operations \ No newline at end of file diff --git a/Caddyfile b/Caddyfile index 5722a3a..d707b11 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,11 @@ { admin off + log { + include http.log.access http.handlers.reverse_proxy + output stdout + format console + level debug + } } :{$PORT:8080} { root * {$ROOT:./dist} @@ -9,7 +15,9 @@ handle /lifeto/* { uri strip_prefix /lifeto reverse_proxy https://beta.lifeto.co { + header_up Host {upstream_hostport} header_up X-Forwarded-For {remote_host} + header_up User-Agent "LifetoShop/1.0" header_down -Connection header_down -Keep-Alive header_down -Proxy-Authenticate @@ -20,9 +28,4 @@ header_down -Upgrade } } - - log { - output stdout - format console - } } diff --git a/Dockerfile b/Dockerfile index 538e54d..7f73c41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM node:18.1-alpine as NODEBUILDER +FROM node:24-alpine as NODEBUILDER WORKDIR /wd COPY . . -RUN npm install -RUN npx vite build +RUN corepack yarn install +RUN corepack yarn build -FROM caddyserver/caddy:2.10-alpine +FROM caddy:2.10-alpine WORKDIR /wd COPY Caddyfile /etc/caddy/Caddyfile COPY --from=NODEBUILDER /wd/dist dist diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..172fb4b --- /dev/null +++ b/fly.toml @@ -0,0 +1,22 @@ +# fly.toml app configuration file generated for lifeto on 2025-06-23T01:16:55-05:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'lifeto' +primary_region = 'ord' + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/src/components/inventory/InventoryFilters.tsx b/src/components/inventory/InventoryFilters.tsx new file mode 100644 index 0000000..d64abf5 --- /dev/null +++ b/src/components/inventory/InventoryFilters.tsx @@ -0,0 +1,207 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { useState } from 'react' +import { + FloatingPortal, + autoUpdate, + flip, + offset, + shift, + useFloating, + useHover, + useInteractions, +} from '@floating-ui/react' +import { inventoryFilterAtom, setInventoryFilterTabActionAtom } from '@/state/atoms' + +const sections = [ + { name: 'all', value: '' }, + { name: 'consume', value: '1' }, + { name: 'equip', value: '2' }, + { name: 'drill', value: '3' }, + { name: 'pet', value: '4' }, + { name: 'etc', value: '5' }, +] + +const cardSections = [ + { name: 'skill', value: '10' }, + { name: 'char', value: '11' }, + { name: 'mon', value: '12' }, + { name: 'fortune', value: '13' }, + { name: 'secret', value: '14' }, + { name: 'arcana', value: '15' }, +] + +export const InventoryFilters = () => { + const inventoryFilter = useAtomValue(inventoryFilterAtom) + const setInventoryFilterTab = useSetAtom(setInventoryFilterTabActionAtom) + const [isCardDropdownOpen, setIsCardDropdownOpen] = useState(false) + + const sharedStyle = 'hover:cursor-pointer hover:bg-gray-200 px-2 pr-4 border-t border-l border-r border-gray-200' + const selectedStyle = 'bg-gray-200 border-b-2 border-black-1' + + const { refs, floatingStyles, context } = useFloating({ + open: isCardDropdownOpen, + onOpenChange: setIsCardDropdownOpen, + middleware: [offset(5), flip(), shift()], + whileElementsMounted: autoUpdate, + placement: 'bottom-start', + }) + + const hover = useHover(context, { + delay: { open: 100, close: 300 }, + }) + + const { getReferenceProps, getFloatingProps } = useInteractions([hover]) + + // Check if any card section is selected + const isCardSectionSelected = cardSections.some(x => x.value === inventoryFilter.tab) + + return ( +
+ {sections.map(x => { + return ( + + ) + })} +
+ + {isCardDropdownOpen && ( + +
+ {cardSections.map(x => ( + + ))} +
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/inventory/MoveConfirmationPopup.tsx b/src/components/inventory/MoveConfirmationPopup.tsx new file mode 100644 index 0000000..e350cbe --- /dev/null +++ b/src/components/inventory/MoveConfirmationPopup.tsx @@ -0,0 +1,182 @@ +import { + FloatingFocusManager, + FloatingOverlay, + FloatingPortal, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react' +import { useAtom, useSetAtom } from 'jotai' +import { useState } from 'react' +import { + clearItemSelectionActionAtom, + closeMoveConfirmationAtom, + type MoveItemsResult, + moveConfirmationAtom, + moveSelectedItemsAtom, +} from '@/state/atoms' + +export function MoveConfirmationPopup() { + const [confirmationState] = useAtom(moveConfirmationAtom) + const closeConfirmation = useSetAtom(closeMoveConfirmationAtom) + const moveItems = useSetAtom(moveSelectedItemsAtom) + const clearSelection = useSetAtom(clearItemSelectionActionAtom) + const [isMoving, setIsMoving] = useState(false) + const [moveResult, setMoveResult] = useState(null) + + const { refs, context } = useFloating({ + open: confirmationState.isOpen, + onOpenChange: open => { + if (!open && !isMoving) { + closeConfirmation() + setMoveResult(null) + } + }, + }) + + const click = useClick(context) + const dismiss = useDismiss(context, { + outsidePressEvent: 'mousedown', + escapeKey: !isMoving, + }) + const role = useRole(context) + + const { getFloatingProps } = useInteractions([click, dismiss, role]) + + if (!confirmationState.isOpen) return null + + const { selectedItems, sourceCharacter, targetCharacter } = confirmationState + + const handleConfirm = async () => { + setIsMoving(true) + try { + const result = await moveItems() + setMoveResult(result) + if (result.failedCount === 0) { + clearSelection() + setTimeout(() => { + closeConfirmation() + setMoveResult(null) + }, 1500) + } + } catch (_error) { + // Error handled in UI + } finally { + setIsMoving(false) + } + } + + const handleCancel = () => { + if (!isMoving) { + closeConfirmation() + } + } + + const renderItemPreview = () => { + const itemsArray = Array.from(selectedItems.values()) + const totalUniqueItems = itemsArray.length + const totalQuantity = itemsArray.reduce((sum, { count }) => sum + count, 0) + + if (totalUniqueItems > 5) { + return ( +
+

Moving {totalUniqueItems} different items

+

Total quantity: {totalQuantity.toLocaleString()}

+
+ ) + } + + return ( +
+ {itemsArray.map(({ item, count }) => ( +
+
+ {item.item_name} + {item.item_name} +
+ ×{count.toLocaleString()} +
+ ))} +
+ ) + } + + return ( + + + +
+

Confirm Item Movement

+ +
+
+ From: + {sourceCharacter?.name || 'Unknown'} +
+
+ To: + {targetCharacter?.name || 'Unknown'} +
+
+ +
{renderItemPreview()}
+ + {moveResult && ( +
0 ? 'bg-yellow-50' : 'bg-green-50'}`} + > +

+ {moveResult.failedCount === 0 + ? `Successfully moved ${moveResult.successCount} items!` + : `Moved ${moveResult.successCount} of ${moveResult.totalItems} items`} +

+ {moveResult.errors.length > 0 && ( +
+ {moveResult.errors.slice(0, 3).map(error => ( +

{error.error}

+ ))} + {moveResult.errors.length > 3 && ( +

...and {moveResult.errors.length - 3} more errors

+ )} +
+ )} +
+ )} + +
+ + +
+
+
+
+
+ ) +} diff --git a/src/components/inventory/index.tsx b/src/components/inventory/index.tsx index de59cc7..22c7eee 100644 --- a/src/components/inventory/index.tsx +++ b/src/components/inventory/index.tsx @@ -1,101 +1,71 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' import { FaArrowLeft, FaArrowRight } from 'react-icons/fa' import { clearItemSelectionActionAtom, + currentCharacterInventoryAtom, filteredCharacterItemsAtom, - inventoryFilterAtom, inventoryPageRangeAtom, itemSelectionSelectAllFilterActionAtom, itemSelectionSelectAllPageActionAtom, + moveSelectedItemsAtom, + openMoveConfirmationAtom, paginateInventoryActionAtom, preferenceInventorySearch, selectedCharacterAtom, - setInventoryFilterTabActionAtom, } from '@/state/atoms' +import { MoveConfirmationPopup } from './MoveConfirmationPopup' import { InventoryTargetSelector } from './movetarget' import { InventoryTable } from './table' +import { InventoryFilters } from './InventoryFilters' -const sections = [ - { name: 'all', value: '' }, - { name: 'consume', value: '1' }, - { name: 'equip', value: '2' }, - { name: 'drill', value: '3' }, - { name: 'pet', value: '4' }, - { name: 'etc', value: '5' }, -] - -const cardSections = [ - { name: 'skill', value: '10' }, - { name: 'char', value: '11' }, - { name: 'mon', value: '12' }, - { name: 'fortune', value: '13' }, - { name: 'secret', value: '14' }, - { name: 'arcana', value: '15' }, -] - -const InventoryTabs = () => { - const inventoryFilter = useAtomValue(inventoryFilterAtom) - const setInventoryFilterTab = useSetAtom(setInventoryFilterTabActionAtom) +const InventoryRangeDisplay = () => { const inventoryRange = useAtomValue(inventoryPageRangeAtom) const items = useAtomValue(filteredCharacterItemsAtom) - const sharedStyle = 'hover:cursor-pointer hover:bg-gray-200 px-2 pr-4 border border-gray-200' - const selectedStyle = 'bg-gray-200 border-b-2 border-black-1' + return ( -
-
-
- {sections.map(x => { - return ( - - ) - })} -
-
- {cardSections.map(x => { - return ( - - ) - })} -
-
-
-
- {inventoryRange.start}..{inventoryRange.end}/{items.length}{' '} -
-
+
+ {inventoryRange.start}..{inventoryRange.end}/{items.length}
) } + export const Inventory = () => { const selectedCharacter = useAtomValue(selectedCharacterAtom) const clearItemSelection = useSetAtom(clearItemSelectionActionAtom) + const { refetch: refetchInventory } = useAtomValue(currentCharacterInventoryAtom) const addPageItemSelection = useSetAtom(itemSelectionSelectAllPageActionAtom) const addFilterItemSelection = useSetAtom(itemSelectionSelectAllFilterActionAtom) const [search, setSearch] = useAtom(preferenceInventorySearch) const paginateInventory = useSetAtom(paginateInventoryActionAtom) + const openMoveConfirmation = useSetAtom(openMoveConfirmationAtom) + const moveSelectedItems = useSetAtom(moveSelectedItemsAtom) + + // Add keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't paginate if user is typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + if (e.key === 'ArrowLeft') { + e.preventDefault() + paginateInventory(-1) + } else if (e.key === 'ArrowRight') { + e.preventDefault() + paginateInventory(1) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [paginateInventory]) if (!selectedCharacter) { return
select a character
@@ -103,88 +73,116 @@ export const Inventory = () => { return (
-
-
+
+
+
+ + { + setSearch(e.target.value) + }} + /> + + + +
+
+ + +
+
+
-
-
- - -
-
- -
-
- { - setSearch(e.target.value) - }} - /> - -
- +
+ + +
+
) } diff --git a/src/components/inventory/movetarget.tsx b/src/components/inventory/movetarget.tsx index 79a4294..008d131 100644 --- a/src/components/inventory/movetarget.tsx +++ b/src/components/inventory/movetarget.tsx @@ -13,7 +13,7 @@ import { import Fuse from 'fuse.js' import { useAtom, useAtomValue } from 'jotai' import { forwardRef, useId, useMemo, useRef, useState } from 'react' -import { charactersAtom, selectedTargetInventoryAtom } from '@/state/atoms' +import { charactersAtom, selectedCharacterAtom, selectedTargetInventoryAtom } from '@/state/atoms' interface AccountInventorySelectorItemProps { children: React.ReactNode @@ -25,6 +25,7 @@ const AccountInventorySelectorItem = forwardRef< AccountInventorySelectorItemProps & React.HTMLProps >(({ children, active, ...rest }, ref) => { const id = useId() + const isDisabled = rest['aria-disabled'] return (
@@ -61,7 +64,7 @@ export const InventoryTargetSelector = () => { size({ apply({ rects, availableHeight, elements }) { Object.assign(elements.floating.style, { - width: `${rects.reference.width}px`, + width: `${Math.max(rects.reference.width * 2, 400)}px`, maxHeight: `${availableHeight}px`, }) }, @@ -99,11 +102,14 @@ export const InventoryTargetSelector = () => { } } const { data: subaccounts } = useAtomValue(charactersAtom) + const selectedCharacter = useAtomValue(selectedCharacterAtom) const [selectedTargetInventory, setSelectedTargetInventory] = useAtom(selectedTargetInventoryAtom) const searcher = useMemo(() => { - return new Fuse(subaccounts?.flatMap(x => [x.bank, x.character]) || [], { + const allInventories = subaccounts?.flatMap(x => [x.bank, x.character]) || [] + // Don't filter out current character, we'll disable it in the UI + return new Fuse(allInventories, { keys: ['path', 'name'], findAllMatches: true, threshold: 0.8, @@ -111,15 +117,24 @@ export const InventoryTargetSelector = () => { }) }, [subaccounts]) - const items = searcher.search(inputValue || '!-', { limit: 10 }).map(x => x.item) + const items = inputValue + ? searcher.search(inputValue, { limit: 10 }).map(x => x.item) + : subaccounts?.flatMap(x => [x.bank, x.character]).slice(0, 10) || [] return ( <> { onKeyDown(event) { if (event.key === 'Enter' && activeIndex != null && items[activeIndex]) { setSelectedTargetInventory(items[activeIndex]) - setInputValue(items[activeIndex].name) + setInputValue('') setActiveIndex(null) setOpen(false) } @@ -149,25 +164,68 @@ export const InventoryTargetSelector = () => { }, })} > - {items.map((item, index) => ( - - {item.name} - - ))} +
+
+ {items + .filter(item => item.path.includes('/')) + .map(item => { + const actualIndex = items.indexOf(item) + const isDisabled = item.path === selectedCharacter?.path + return ( + + {item.name} + + ) + })} +
+
+ {items + .filter(item => !item.path.includes('/')) + .map(item => { + const actualIndex = items.indexOf(item) + const isDisabled = item.path === selectedCharacter?.path + return ( + + [Bank] {item.account_name} + + ) + })} +
+
diff --git a/src/components/inventory/table.tsx b/src/components/inventory/table.tsx index daaf818..f333867 100644 --- a/src/components/inventory/table.tsx +++ b/src/components/inventory/table.tsx @@ -1,10 +1,14 @@ import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { atom, useAtomValue } from 'jotai' -import { useMemo } from 'react' +import { atom, useAtomValue, useSetAtom } from 'jotai' +import { useEffect, useMemo } from 'react' import { StatsColumns } from '@/lib/columns' import { ItemWithSelection } from '@/lib/table/defs' import { InventoryColumns } from '@/lib/table/tanstack' -import { inventoryItemsCurrentPageAtom, preferenceInventoryTab } from '@/state/atoms' +import { + inventoryItemsCurrentPageAtom, + mouseDragSelectionStateAtom, + preferenceInventoryTab, +} from '@/state/atoms' const columnVisibilityAtom = atom(get => { const itemTab = get(preferenceInventoryTab) @@ -15,6 +19,7 @@ const columnVisibilityAtom = atom(get => { }) export const InventoryTable = () => { const items = useAtomValue(inventoryItemsCurrentPageAtom) + const setDragState = useSetAtom(mouseDragSelectionStateAtom) const columns = useMemo(() => { return [...Object.values(InventoryColumns)] @@ -32,6 +37,18 @@ export const InventoryTable = () => { getCoreRowModel: getCoreRowModel(), }) + // Handle global mouse up to end drag selection + useEffect(() => { + const handleMouseUp = () => { + setDragState(prev => ({ ...prev, isDragging: false })) + } + + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mouseup', handleMouseUp) + } + }, [setDragState]) + return (
{ + try { + const request = { + item_uid: params.itemUid, + qty: params.count.toString(), + new_char: params.targetCharId, + } + + const response = await this.api.BankAction('internal-xfer-item', request) + + if (response.status !== 'success') { + return { + success: false, + error: response.message || 'Failed to transfer item', + } + } + + return { + success: true, + data: response.data, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error in internalXfer', + } + } + } + + /** + * Move items to bank + * Uses bank-item API + */ + async bankItem(params: BankItemParams): Promise { + try { + const request = { + item_uid: params.itemUid, + qty: params.count.toString(), + account: params.targetAccount, + } + + const response = await this.api.BankAction('bank-item', request) + + if (response.status !== 'success') { + return { + success: false, + error: response.message || 'Failed to bank item', + } + } + + return { + success: true, + data: response.data, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error in bankItem', + } + } + } + + /** + * High-level function that determines whether to use bankItem or internalXfer + * based on whether targetAccountId is provided (bank) or targetCharId (character) + */ + async moveItem( + itemUid: string | 'galders', + count: number, + targetCharId?: string, + targetAccountId?: string, + ): Promise { + if (targetAccountId) { + // Use bank-item when moving to bank (targetAccountId is provided) + return this.bankItem({ + itemUid, + count, + targetAccount: targetAccountId, + }) + } + if (targetCharId) { + // Use internal-xfer when moving between characters + return this.internalXfer({ + itemUid, + count, + targetCharId, + }) + } + return { + success: false, + error: 'Either targetCharId or targetAccountId must be provided', + } + } +} diff --git a/src/lib/lifeto/lifeto.ts b/src/lib/lifeto/lifeto.ts index 37515e4..364a80a 100644 --- a/src/lib/lifeto/lifeto.ts +++ b/src/lib/lifeto/lifeto.ts @@ -36,6 +36,7 @@ export class LTOApiv0 implements LTOApi { endpoint = market_endpoint break case 'sell-item': + //case 'internal-xfer-item': VERB = 'POSTFORM' break default: diff --git a/src/lib/session.ts b/src/lib/session.ts index d50a9b3..079e257 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -5,7 +5,7 @@ import { TricksterAccountInfo } from './trickster' export const SITE_ROOT = '/lifeto/' export const API_ROOT = 'api/lifeto/' -export const BANK_ROOT = 'v2/item-manager/' +export const BANK_ROOT = 'v3/item-manager/' export const MARKET_ROOT = 'marketplace-api/' const raw_endpoint = (name: string): string => { @@ -18,7 +18,7 @@ export const api_endpoint = (name: string): string => { return SITE_ROOT + API_ROOT + name } export const bank_endpoint = (name: string): string => { - return SITE_ROOT + BANK_ROOT + name + return SITE_ROOT + API_ROOT + BANK_ROOT + name } export const market_endpoint = (name: string): string => { diff --git a/src/lib/table/tanstack.tsx b/src/lib/table/tanstack.tsx index a0850fc..cfc8415 100644 --- a/src/lib/table/tanstack.tsx +++ b/src/lib/table/tanstack.tsx @@ -1,7 +1,11 @@ import { createColumnHelper } from '@tanstack/react-table' -import { useAtomValue, useSetAtom } from 'jotai' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useMemo } from 'react' -import { currentItemSelectionAtom, itemSelectionSetActionAtom } from '@/state/atoms' +import { + currentItemSelectionAtom, + itemSelectionSetActionAtom, + mouseDragSelectionStateAtom, +} from '@/state/atoms' import { StatsColumns } from '../columns' import { ItemWithSelection } from './defs' @@ -16,24 +20,51 @@ const columns = { cell: function Component({ row }) { const setItemSelection = useSetAtom(itemSelectionSetActionAtom) const c = useAtomValue(currentItemSelectionAtom) + const [dragState, setDragState] = useAtom(mouseDragSelectionStateAtom) const selected = useMemo(() => { return c[0].has(row.original.item.id) }, [c]) + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + const newSelected = !selected + setItemSelection({ + [row.original.item.id]: newSelected ? row.original.item.item_count : undefined, + }) + setDragState({ + isDragging: true, + lastAction: newSelected ? 'select' : 'deselect', + lastItemId: row.original.item.id, + }) + } + + const handleMouseEnter = () => { + if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) { + if (dragState.lastAction === 'select' && !selected) { + setItemSelection({ + [row.original.item.id]: row.original.item.item_count, + }) + } else if (dragState.lastAction === 'deselect' && selected) { + setItemSelection({ + [row.original.item.id]: undefined, + }) + } + } + } + return ( @@ -48,6 +79,7 @@ const columns = { cell: function Component({ row }) { const c = useAtomValue(currentItemSelectionAtom) const setItemSelection = useSetAtom(itemSelectionSetActionAtom) + const dragState = useAtomValue(mouseDragSelectionStateAtom) const currentValue = useMemo(() => { const got = c[0].get(row.original.item.id) if (got !== undefined) { @@ -56,9 +88,30 @@ const columns = { return '' }, [c]) const itemCount = row.original.item.item_count + const selected = useMemo(() => { + return c[0].has(row.original.item.id) + }, [c]) + + const handleMouseEnter = () => { + if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) { + if (dragState.lastAction === 'select' && !selected) { + setItemSelection({ + [row.original.item.id]: row.original.item.item_count, + }) + } else if (dragState.lastAction === 'deselect' && selected) { + setItemSelection({ + [row.original.item.id]: undefined, + }) + } + } + } + return ( + // biome-ignore lint/a11y/useSemanticElements: Using div for layout with input child + // biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select
name
}, cell: function Component({ row }) { + const c = useAtomValue(currentItemSelectionAtom) + const setItemSelection = useSetAtom(itemSelectionSetActionAtom) + const [dragState, setDragState] = useAtom(mouseDragSelectionStateAtom) + const selected = useMemo(() => { + return c[0].has(row.original.item.id) + }, [c]) + + const handleMouseEnter = () => { + if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) { + if (dragState.lastAction === 'select' && !selected) { + setItemSelection({ + [row.original.item.id]: row.original.item.item_count, + }) + } else if (dragState.lastAction === 'deselect' && selected) { + setItemSelection({ + [row.original.item.id]: undefined, + }) + } + } + } + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + const newSelected = !selected + setItemSelection({ + [row.original.item.id]: newSelected ? row.original.item.item_count : undefined, + }) + setDragState({ + isDragging: true, + lastAction: newSelected ? 'select' : 'deselect', + lastItemId: row.original.item.id, + }) + } + return ( -
+ // biome-ignore lint/a11y/useSemanticElements: Using div for text content + // biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select +
{row.original.item.item_name}
) diff --git a/src/state/atoms.ts b/src/state/atoms.ts index 0788251..e7f3454 100644 --- a/src/state/atoms.ts +++ b/src/state/atoms.ts @@ -6,6 +6,7 @@ import { focusAtom } from 'jotai-optics' import { atomWithQuery } from 'jotai-tanstack-query' import { ItemWithSelection } from '@/lib/table/defs' import { LTOApiv0 } from '../lib/lifeto' +import { ItemMover } from '../lib/lifeto/item_mover' import { LoginHelper, TokenSession } from '../lib/session' import { TricksterCharacter, TricksterItem } from '../lib/trickster' import { createSuperjsonStorage } from './storage' @@ -158,13 +159,19 @@ export const preferenceInventorySortReverse = focusAtom(inventoryFilterAtom, x = x.prop('sort_reverse'), ) -export const setInventoryFilterTabActionAtom = atom(null, (_get, set, tab: string) => { +export const setInventoryFilterTabActionAtom = atom(null, (get, set, tab: string) => { set(inventoryFilterAtom, x => { return { ...x, tab, } }) + // Reset pagination to first page when switching tabs + const pageSize = get(inventoryDisplaySettingsAtoms.pageSize) + set(inventoryPageRangeAtom, { + start: 0, + end: pageSize, + }) }) export const inventoryPageRangeAtom = atom({ @@ -250,6 +257,12 @@ export const rowSelectionLastActionAtom = atom< | undefined >(undefined) +export const mouseDragSelectionStateAtom = atom({ + isDragging: false, + lastAction: null as 'select' | 'deselect' | null, + lastItemId: null as string | null, +}) + export const clearItemSelectionActionAtom = atom(null, (_get, set) => { set(currentItemSelectionAtom, [new Map(), 0]) }) @@ -315,8 +328,10 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number | } } else if (pages < 0) { if (inventoryRange.start <= 0) { + // Wrap around to the last page + const lastPageStart = Math.max(0, filteredItems.length - pageSize) set(inventoryPageRangeAtom, { - start: filteredItems.length - pageSize, + start: lastPageStart, end: filteredItems.length, }) return @@ -325,11 +340,17 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number | const delta = pages * pageSize let newStart = inventoryRange.start + delta let newEnd = inventoryRange.end + delta + + // Handle negative start + if (newStart < 0) { + newStart = 0 + newEnd = Math.min(pageSize, filteredItems.length) + } + + // Handle end beyond items length if (newEnd > filteredItems.length) { newEnd = filteredItems.length - } - if (newEnd - newStart !== pageSize) { - newStart = newEnd - pageSize + newStart = Math.max(0, newEnd - pageSize) } set(inventoryPageRangeAtom, { @@ -337,3 +358,154 @@ export const paginateInventoryActionAtom = atom(null, (get, set, pages: number | end: newEnd, }) }) + +export interface MoveItemsResult { + totalItems: number + successCount: number + failedCount: number + errors: Array<{ itemId: string; error: string }> +} + +export interface MoveConfirmationState { + isOpen: boolean + selectedItems: Map + sourceCharacter?: TricksterCharacter + targetCharacter?: TricksterCharacter +} + +export const moveConfirmationAtom = atom({ + isOpen: false, + selectedItems: new Map(), +}) + +export const openMoveConfirmationAtom = atom(null, (get, set) => { + const [selectedItems] = get(currentItemSelectionAtom) + const sourceCharacter = get(selectedCharacterAtom) + const targetCharacter = get(selectedTargetInventoryAtom) + const { data: inventory } = get(currentCharacterInventoryAtom) + + if (!sourceCharacter || !targetCharacter || !inventory) { + return + } + + const itemsWithDetails = new Map() + + selectedItems.forEach((count, itemId) => { + const item = inventory.items.get(itemId) + if (item) { + itemsWithDetails.set(itemId, { item, count }) + } + }) + + set(moveConfirmationAtom, { + isOpen: true, + selectedItems: itemsWithDetails, + sourceCharacter, + targetCharacter, + }) +}) + +export const closeMoveConfirmationAtom = atom(null, (_get, set) => { + set(moveConfirmationAtom, { + isOpen: false, + selectedItems: new Map(), + }) +}) + +export const moveSelectedItemsAtom = atom(null, async (get, _set): Promise => { + const itemMover = new ItemMover(LTOApi) + const confirmationState = get(moveConfirmationAtom) + const selectedItems = confirmationState.isOpen + ? new Map( + Array.from(confirmationState.selectedItems.entries()).map(([id, { count }]) => [id, count]), + ) + : get(currentItemSelectionAtom)[0] + const sourceCharacter = confirmationState.sourceCharacter || get(selectedCharacterAtom) + const targetCharacter = confirmationState.targetCharacter || get(selectedTargetInventoryAtom) + const { data: sourceInventory } = get(currentCharacterInventoryAtom) + + const result: MoveItemsResult = { + totalItems: selectedItems.size, + successCount: 0, + failedCount: 0, + errors: [], + } + + if (!sourceCharacter || !targetCharacter) { + throw new Error('Source or target character not selected') + } + + if (!sourceInventory) { + throw new Error('Source inventory not loaded') + } + + if (selectedItems.size === 0) { + return result + } + + // Track successful moves to update counts + const successfulMoves: Array<{ itemId: string; count: number }> = [] + + // Process each selected item + const movePromises = Array.from(selectedItems.entries()).map(async ([itemId, count]) => { + const item = sourceInventory.items.get(itemId) + if (!item) { + result.errors.push({ itemId, error: 'Item not found in inventory' }) + result.failedCount++ + return + } + + try { + const isTargetBank = !targetCharacter.path.includes('/') + const moveResult = await itemMover.moveItem( + item.unique_id.toString(), + count, + isTargetBank ? undefined : targetCharacter.id.toString(), + isTargetBank ? targetCharacter.account_id.toString() : undefined, + ) + if (moveResult.success) { + result.successCount++ + successfulMoves.push({ itemId, count }) + } else { + result.errors.push({ itemId, error: moveResult.error || 'Unknown error' }) + result.failedCount++ + } + } catch (error) { + result.errors.push({ + itemId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + result.failedCount++ + } + }) + + await Promise.all(movePromises) + + // Update the inventory optimistically + if (successfulMoves.length > 0 && sourceInventory) { + const updatedItems = new Map(sourceInventory.items) + + for (const { itemId, count } of successfulMoves) { + const item = updatedItems.get(itemId) + if (item) { + const newCount = item.item_count - count + if (newCount <= 0) { + // Remove item if count reaches 0 + updatedItems.delete(itemId) + } else { + // Update item count + updatedItems.set(itemId, { ...item, item_count: newCount }) + } + } + } + + // Update the local inventory state + sourceInventory.items = updatedItems + + // Trigger a refetch to sync with server + const { refetch } = get(currentCharacterInventoryAtom) + refetch() + } + + return result +})