From 21b604194146a2e37b3606a80f47e3fbb52d3c12 Mon Sep 17 00:00:00 2001 From: a Date: Tue, 24 Jun 2025 00:37:47 -0500 Subject: [PATCH] noot --- src/App.tsx | 2 +- src/components/characters.tsx | 22 +- src/components/login.tsx | 167 ++++--- src/lib/lifeto/lifeto.ts | 2 + src/lib/trickster.ts | 1 + src/state/atoms.ts | 551 ++------------------- src/state/auth.atoms.ts | 91 ++++ src/state/inventory.atoms.ts | 428 ++++++++++++++++ src/state/state.ts | 86 ---- src/state/{storage.ts => storage/index.ts} | 0 10 files changed, 678 insertions(+), 672 deletions(-) create mode 100644 src/state/auth.atoms.ts create mode 100644 src/state/inventory.atoms.ts delete mode 100644 src/state/state.ts rename src/state/{storage.ts => storage/index.ts} (100%) diff --git a/src/App.tsx b/src/App.tsx index da3fb07..ae5a8ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ export const App: FC = () => { return ( <>
-
+
diff --git a/src/components/characters.tsx b/src/components/characters.tsx index 904f60e..2c45fde 100644 --- a/src/components/characters.tsx +++ b/src/components/characters.tsx @@ -17,7 +17,7 @@ import { useMemo, useState } from 'react' import { TricksterCharacter } from '../lib/trickster' import { charactersAtom, selectedCharacterAtom } from '../state/atoms' -export const CharacterCard = ({ character }: { character: TricksterCharacter }) => { +export const CharacterCard = ({ character, noTopBorder = false }: { character: TricksterCharacter; noTopBorder?: boolean }) => { const [isOpen, setIsOpen] = useState(false) const { refs, floatingStyles, context } = useFloating({ @@ -56,7 +56,7 @@ export const CharacterCard = ({ character }: { character: TricksterCharacter }) ref={refs.setReference} {...getReferenceProps()} className={` - flex flex-col border border-black + flex flex-col ${noTopBorder ? 'border-l border-r border-b' : 'border'} border-black hover:cursor-pointer hover:bg-blue-100 p-2 ${character.path === selectedCharacter?.path ? `bg-blue-200 hover:bg-blue-100` : ''}`} @@ -74,9 +74,7 @@ export const CharacterCard = ({ character }: { character: TricksterCharacter }) { - return ( - <> -
no characters (not logged in?)
- - ) -} - export const CharacterRoulette = () => { const [{ data: rawCharacters }] = useAtom(charactersAtom) @@ -136,8 +126,10 @@ export const CharacterRoulette = () => { }), } }, [rawCharacters]) + + // Return nothing when no characters if (!characters || characters.length === 0) { - return + return null } const searchResults = fuse .search(search || '!-----', { @@ -147,7 +139,7 @@ export const CharacterRoulette = () => { return (
- +
) }) diff --git a/src/components/login.tsx b/src/components/login.tsx index f07c004..4cc57a9 100644 --- a/src/components/login.tsx +++ b/src/components/login.tsx @@ -7,81 +7,126 @@ import { loginStatusAtom } from '../state/atoms' export const LoginWidget = () => { const [username, setUsername] = useLocalStorage('input_username', '', { syncData: false }) const [password, setPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) const [{ data: loginState, refetch: refetchLoginState }] = useAtom(loginStatusAtom) const [loginError, setLoginError] = useState('') + // Handle logged in state if (loginState?.logged_in) { return ( - <> -
-
{loginState.community_name}
-
- -
+
+
+ + {loginState.community_name}
- + +
) } - return ( - <> -
-
{ - login(username, password) - .catch(e => { - setLoginError(e.message) - }) - .finally(() => { - refetchLoginState() - refetchLoginState() - }) - }} - className="flex flex-col gap-1 p-2 justify-left" - > - {loginError ?
{loginError}
: null} -
- { - setUsername(e.target.value) - }} - value={username} - placeholder="username" - className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500" - /> -
-
- { - setPassword(e.target.value) - }} - value={password} - type="password" - placeholder="password" - className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500" - /> -
+ // Handle server maintenance (503) state + if (loginState?.code === 503) { + return ( +
+
+ ⚠️ + Server Maintenance +
+

+ The server is currently unavailable.{' '} -

+

- + ) + } + + // Handle login form (code < 200 or no code) + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoginError('') + setIsLoading(true) + + try { + await login(username, password) + setPassword('') // Clear password on success + } catch (error) { + setLoginError(error instanceof Error ? error.message : 'Login failed') + } finally { + refetchLoginState() + setIsLoading(false) + } + } + + return ( +
+
+

Lifeto Login

+ + {loginError && ( +
+ {loginError} +
+ )} + +
+ setUsername(e.target.value)} + placeholder="Email" + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={isLoading} + required + /> +
+ +
+ setPassword(e.target.value)} + placeholder="Password" + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={isLoading} + required + /> +
+ + +
+
) } diff --git a/src/lib/lifeto/lifeto.ts b/src/lib/lifeto/lifeto.ts index 364a80a..c7d3af8 100644 --- a/src/lib/lifeto/lifeto.ts +++ b/src/lib/lifeto/lifeto.ts @@ -106,6 +106,7 @@ export class LTOApiv0 implements LTOApi { class: -8, base_job: -8, current_job: -8, + current_type: -8, }, ...Object.values(x.characters).map((z: any) => { return { @@ -117,6 +118,7 @@ export class LTOApiv0 implements LTOApi { class: z.class, base_job: z.base_job, current_job: z.current_job, + current_type: z.current_type, } }), ], diff --git a/src/lib/trickster.ts b/src/lib/trickster.ts index 950ba59..817eeb8 100644 --- a/src/lib/trickster.ts +++ b/src/lib/trickster.ts @@ -41,6 +41,7 @@ export interface TricksterCharacter extends Identifier { class: number base_job: number current_job: number + current_type: number } export interface TricksterInventory extends Identifier { diff --git a/src/state/atoms.ts b/src/state/atoms.ts index e7f3454..7d95665 100644 --- a/src/state/atoms.ts +++ b/src/state/atoms.ts @@ -1,511 +1,44 @@ -import { AxiosError } from 'axios' -import Fuse from 'fuse.js' -import { atom } from 'jotai' -import { atomWithStorage } from 'jotai/utils' -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' +// Re-export all atoms from the separate files for backward compatibility -export const LTOApi = new LTOApiv0(new TokenSession()) +// Auth-related atoms +export { + LTOApi, + loginStatusAtom, + charactersAtom, + selectedCharacterAtom, +} from './auth.atoms' -export const loginStatusAtom = atomWithQuery(_get => { - return { - queryKey: ['login_status'], - enabled: true, - placeholderData: { - logged_in: false, - community_name: '...', - }, - queryFn: async () => { - return LoginHelper.info() - .then(info => { - return { - logged_in: true, - community_name: info.community_name, - } - }) - .catch(e => { - if (e instanceof AxiosError) { - return { - logged_in: false, - community_name: '...', - } - } - throw e - }) - }, - } -}) - -export const charactersAtom = atomWithQuery(get => { - const { data: loginStatus } = get(loginStatusAtom) - return { - queryKey: ['characters', loginStatus?.community_name || '...'], - enabled: !!loginStatus?.logged_in, - refetchOnMount: true, - queryFn: async () => { - return LTOApi.GetAccounts().then(x => { - if (!x) { - return undefined - } - const rawCharacters = x.flatMap(x => { - return x?.characters - }) - const characterPairs: Record< - string, - { bank?: TricksterCharacter; character?: TricksterCharacter } - > = {} - rawCharacters.forEach( - x => { - let item = characterPairs[x.account_name] - if (!item) { - item = {} - } - if (x.class === -8) { - item.bank = x - } else { - item.character = x - } - characterPairs[x.account_name] = item - }, - [rawCharacters], - ) - const cleanCharacterPairs = Object.values(characterPairs).filter(x => { - if (!(!!x.bank && !!x.character)) { - return false - } - return true - }) as Array<{ bank: TricksterCharacter; character: TricksterCharacter }> - - return cleanCharacterPairs - }) - }, - } -}) - -export const selectedCharacterAtom = atomWithStorage( - 'lto_state.selected_character', - undefined, -) -export const selectedTargetInventoryAtom = atom(undefined) - -export const currentFilter = atom(undefined) - -export const currentCharacterInventoryAtom = atomWithQuery(get => { - const currentCharacter = get(selectedCharacterAtom) - return { - queryKey: ['inventory', currentCharacter?.path || '-'], - queryFn: async () => { - return LTOApi.GetInventory(currentCharacter?.path || '-') - }, - enabled: !!currentCharacter, - // placeholderData: keepPreviousData, - } -}) - -const inventoryDisplaySettings = atomWithStorage<{ - page_size: number -}>( - 'preference.inventory_display_settings', - { - page_size: 25, - }, - createSuperjsonStorage(), -) - -export const inventoryDisplaySettingsAtoms = { - pageSize: focusAtom(inventoryDisplaySettings, x => x.prop('page_size')), -} - -export const currentCharacterItemsAtom = atom(get => { - const { data: inventory } = get(currentCharacterInventoryAtom) - const items = inventory?.items || new Map() - return { - items, - searcher: new Fuse(Array.from(items.values()), { - keys: ['item_name'], - useExtendedSearch: true, - }), - } -}) - -export interface InventoryFilter { - search: string - tab: string - sort: string - sort_reverse: boolean -} - -export const inventoryFilterAtom = atomWithStorage( - 'preference.inventory_filter', - { - search: '', - tab: '', - sort: '', - sort_reverse: false, - }, - createSuperjsonStorage(), -) - -export const preferenceInventorySearch = focusAtom(inventoryFilterAtom, x => x.prop('search')) -export const preferenceInventoryTab = focusAtom(inventoryFilterAtom, x => x.prop('tab')) -export const preferenceInventorySort = focusAtom(inventoryFilterAtom, x => x.prop('sort')) -export const preferenceInventorySortReverse = focusAtom(inventoryFilterAtom, x => - x.prop('sort_reverse'), -) - -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({ - start: 0, - end: 25, -}) - -export const nextInventoryPageActionAtom = atom(null, (get, set) => { - const { start, end } = get(inventoryPageRangeAtom) - set(inventoryPageRangeAtom, { - start: start + end, - end: end + end, - }) -}) - -export const currentItemSelectionAtom = atom<[Map, number]>([ - new Map(), - 0, -]) -export const currentInventorySearchQueryAtom = atom('') - -export const filteredCharacterItemsAtom = atom(get => { - const { items } = get(currentCharacterItemsAtom) - const [selection] = get(currentItemSelectionAtom) - const filter = get(inventoryFilterAtom) - const out: ItemWithSelection[] = [] - for (const [_, value] of items.entries()) { - if (filter.search !== '') { - if (!value.item_name.toLowerCase().includes(filter.search)) { - continue - } - } - if (filter.tab !== '') { - if (value.item_tab !== parseInt(filter.tab)) { - continue - } - } - let status: { selected: boolean } | undefined - if (selection.has(value.id)) { - status = { - selected: true, - } - } - out.push({ item: value, status }) - } - - switch (filter.sort) { - case 'count': - out.sort((a, b) => { - return b.item.item_count - a.item.item_count - }) - break - case 'type': - out.sort((a, b) => { - return a.item.item_tab - b.item.item_tab - }) - break - case 'name': - out.sort((a, b) => { - return a.item.item_name.localeCompare(b.item.item_name) - }) - break - } - if (filter.sort && filter.sort_reverse) { - out.reverse() - } - return out -}) - -export const inventoryItemsCurrentPageAtom = atom(get => { - const items = get(filteredCharacterItemsAtom) - const { start, end } = get(inventoryPageRangeAtom) - return items.slice(start, end).map((item): ItemWithSelection => { - return item - }) -}) - -export const rowSelectionLastActionAtom = atom< - | { - index: number - action: 'add' | 'remove' - } - | 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]) -}) - -export const itemSelectionSetActionAtom = atom( - null, - (get, set, arg: Record) => { - const cur = get(currentItemSelectionAtom) - for (const [key, value] of Object.entries(arg)) { - if (value === undefined) { - cur[0].delete(key) - } else { - cur[0].set(key, value) - } - } - set(currentItemSelectionAtom, [cur[0], cur[1] + 1]) - }, -) - -export const itemSelectionSelectAllFilterActionAtom = atom(null, (get, set) => { - const cur = get(currentItemSelectionAtom) - const items = get(filteredCharacterItemsAtom) - for (const item of items) { - cur[0].set(item.item.id, item.item.item_count) - } - set(currentItemSelectionAtom, [cur[0], cur[1] + 1]) -}) - -export const itemSelectionSelectAllPageActionAtom = atom(null, (get, set) => { - const cur = get(currentItemSelectionAtom) - const items = get(inventoryItemsCurrentPageAtom) - for (const item of items) { - cur[0].set(item.item.id, item.item.item_count) - } - set(currentItemSelectionAtom, [cur[0], cur[1] + 1]) -}) - -export const paginateInventoryActionAtom = atom(null, (get, set, pages: number | undefined) => { - const inventoryRange = get(inventoryPageRangeAtom) - const pageSize = get(inventoryDisplaySettingsAtoms.pageSize) - const filteredItems = get(filteredCharacterItemsAtom) - if (pages === undefined) { - set(inventoryPageRangeAtom, { - start: 0, - end: pageSize, - }) - return - } - if (pageSize > filteredItems.length) { - set(inventoryPageRangeAtom, { - start: 0, - end: filteredItems.length, - }) - return - } - if (pages > 0) { - if (inventoryRange.end >= filteredItems.length) { - set(inventoryPageRangeAtom, { - start: 0, - end: pageSize, - }) - return - } - } 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: lastPageStart, - end: filteredItems.length, - }) - return - } - } - 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 - newStart = Math.max(0, newEnd - pageSize) - } - - set(inventoryPageRangeAtom, { - start: newStart, - 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 -}) +// Inventory-related atoms +export { + selectedTargetInventoryAtom, + currentFilter, + currentCharacterInventoryAtom, + inventoryDisplaySettingsAtoms, + currentCharacterItemsAtom, + type InventoryFilter, + inventoryFilterAtom, + preferenceInventorySearch, + preferenceInventoryTab, + preferenceInventorySort, + preferenceInventorySortReverse, + setInventoryFilterTabActionAtom, + inventoryPageRangeAtom, + nextInventoryPageActionAtom, + currentItemSelectionAtom, + currentInventorySearchQueryAtom, + filteredCharacterItemsAtom, + inventoryItemsCurrentPageAtom, + rowSelectionLastActionAtom, + mouseDragSelectionStateAtom, + clearItemSelectionActionAtom, + itemSelectionSetActionAtom, + itemSelectionSelectAllFilterActionAtom, + itemSelectionSelectAllPageActionAtom, + paginateInventoryActionAtom, + type MoveItemsResult, + type MoveConfirmationState, + moveConfirmationAtom, + openMoveConfirmationAtom, + closeMoveConfirmationAtom, + moveSelectedItemsAtom, +} from './inventory.atoms' \ No newline at end of file diff --git a/src/state/auth.atoms.ts b/src/state/auth.atoms.ts new file mode 100644 index 0000000..5664ecd --- /dev/null +++ b/src/state/auth.atoms.ts @@ -0,0 +1,91 @@ +import { AxiosError } from 'axios' +import { atomWithStorage } from 'jotai/utils' +import { atomWithQuery } from 'jotai-tanstack-query' +import { LTOApiv0 } from '../lib/lifeto' +import { LoginHelper, TokenSession } from '../lib/session' +import { TricksterCharacter } from '../lib/trickster' + +export const LTOApi = new LTOApiv0(new TokenSession()) + +export const loginStatusAtom = atomWithQuery((_get) => { + return { + queryKey: ['login_status'], + enabled: true, + placeholderData: { + logged_in: false, + community_name: '...', + code: 102, + }, + queryFn: async () => { + return LoginHelper.info() + .then(info => { + return { + logged_in: true, + community_name: info.community_name, + code: 200, + } + }) + .catch(e => { + if (e instanceof AxiosError) { + return { + logged_in: false, + community_name: '...', + code: e.response?.status || 500, + } + } + throw e + }) + }, + } +}) + +export const charactersAtom = atomWithQuery((get) => { + const { data: loginStatus } = get(loginStatusAtom) + return { + queryKey: ['characters', loginStatus?.community_name || '...'], + enabled: !!loginStatus?.logged_in, + refetchOnMount: true, + queryFn: async () => { + return LTOApi.GetAccounts().then(x => { + if (!x) { + return undefined + } + const rawCharacters = x.flatMap(x => { + return x?.characters + }) + const characterPairs: Record< + string, + { bank?: TricksterCharacter; character?: TricksterCharacter } + > = {} + rawCharacters.forEach( + x => { + let item = characterPairs[x.account_name] + if (!item) { + item = {} + } + if (x.class === -8) { + item.bank = x + } else { + item.character = x + } + characterPairs[x.account_name] = item + }, + [rawCharacters], + ) + const cleanCharacterPairs = Object.values(characterPairs).filter(x => { + if (!(!!x.bank && !!x.character)) { + return false + } + return true + }) as Array<{ bank: TricksterCharacter; character: TricksterCharacter }> + + return cleanCharacterPairs + }) + }, + } +}) + +export const selectedCharacterAtom = atomWithStorage( + 'lto_state.selected_character', + undefined, +) diff --git a/src/state/inventory.atoms.ts b/src/state/inventory.atoms.ts new file mode 100644 index 0000000..628c74d --- /dev/null +++ b/src/state/inventory.atoms.ts @@ -0,0 +1,428 @@ +import Fuse from 'fuse.js' +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' +import { focusAtom } from 'jotai-optics' +import { atomWithQuery } from 'jotai-tanstack-query' +import { ItemWithSelection } from '@/lib/table/defs' +import { ItemMover } from '../lib/lifeto/item_mover' +import { TricksterCharacter, TricksterItem } from '../lib/trickster' +import { createSuperjsonStorage } from './storage' +import { LTOApi, selectedCharacterAtom } from './auth.atoms' + +export const selectedTargetInventoryAtom = atom(undefined) + +export const currentFilter = atom(undefined) + +export const currentCharacterInventoryAtom = atomWithQuery(get => { + const currentCharacter = get(selectedCharacterAtom) + return { + queryKey: ['inventory', currentCharacter?.path || '-'], + queryFn: async () => { + return LTOApi.GetInventory(currentCharacter?.path || '-') + }, + enabled: !!currentCharacter, + // placeholderData: keepPreviousData, + } +}) + +const inventoryDisplaySettings = atomWithStorage<{ + page_size: number +}>( + 'preference.inventory_display_settings', + { + page_size: 25, + }, + createSuperjsonStorage(), +) + +export const inventoryDisplaySettingsAtoms = { + pageSize: focusAtom(inventoryDisplaySettings, x => x.prop('page_size')), +} + +export const currentCharacterItemsAtom = atom(get => { + const { data: inventory } = get(currentCharacterInventoryAtom) + const items = inventory?.items || new Map() + return { + items, + searcher: new Fuse(Array.from(items.values()), { + keys: ['item_name'], + useExtendedSearch: true, + }), + } +}) + +export interface InventoryFilter { + search: string + tab: string + sort: string + sort_reverse: boolean +} + +export const inventoryFilterAtom = atomWithStorage( + 'preference.inventory_filter', + { + search: '', + tab: '', + sort: '', + sort_reverse: false, + }, + createSuperjsonStorage(), +) + +export const preferenceInventorySearch = focusAtom(inventoryFilterAtom, x => x.prop('search')) +export const preferenceInventoryTab = focusAtom(inventoryFilterAtom, x => x.prop('tab')) +export const preferenceInventorySort = focusAtom(inventoryFilterAtom, x => x.prop('sort')) +export const preferenceInventorySortReverse = focusAtom(inventoryFilterAtom, x => + x.prop('sort_reverse'), +) + +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({ + start: 0, + end: 25, +}) + +export const nextInventoryPageActionAtom = atom(null, (get, set) => { + const { start, end } = get(inventoryPageRangeAtom) + set(inventoryPageRangeAtom, { + start: start + end, + end: end + end, + }) +}) + +export const currentItemSelectionAtom = atom<[Map, number]>([ + new Map(), + 0, +]) +export const currentInventorySearchQueryAtom = atom('') + +export const filteredCharacterItemsAtom = atom(get => { + const { items } = get(currentCharacterItemsAtom) + const [selection] = get(currentItemSelectionAtom) + const filter = get(inventoryFilterAtom) + const out: ItemWithSelection[] = [] + for (const [_, value] of items.entries()) { + if (filter.search !== '') { + if (!value.item_name.toLowerCase().includes(filter.search)) { + continue + } + } + if (filter.tab !== '') { + if (value.item_tab !== parseInt(filter.tab)) { + continue + } + } + let status: { selected: boolean } | undefined + if (selection.has(value.id)) { + status = { + selected: true, + } + } + out.push({ item: value, status }) + } + + switch (filter.sort) { + case 'count': + out.sort((a, b) => { + return b.item.item_count - a.item.item_count + }) + break + case 'type': + out.sort((a, b) => { + return a.item.item_tab - b.item.item_tab + }) + break + case 'name': + out.sort((a, b) => { + return a.item.item_name.localeCompare(b.item.item_name) + }) + break + } + if (filter.sort && filter.sort_reverse) { + out.reverse() + } + return out +}) + +export const inventoryItemsCurrentPageAtom = atom(get => { + const items = get(filteredCharacterItemsAtom) + const { start, end } = get(inventoryPageRangeAtom) + return items.slice(start, end).map((item): ItemWithSelection => { + return item + }) +}) + +export const rowSelectionLastActionAtom = atom< + | { + index: number + action: 'add' | 'remove' + } + | 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]) +}) + +export const itemSelectionSetActionAtom = atom( + null, + (get, set, arg: Record) => { + const cur = get(currentItemSelectionAtom) + for (const [key, value] of Object.entries(arg)) { + if (value === undefined) { + cur[0].delete(key) + } else { + cur[0].set(key, value) + } + } + set(currentItemSelectionAtom, [cur[0], cur[1] + 1]) + }, +) + +export const itemSelectionSelectAllFilterActionAtom = atom(null, (get, set) => { + const cur = get(currentItemSelectionAtom) + const items = get(filteredCharacterItemsAtom) + for (const item of items) { + cur[0].set(item.item.id, item.item.item_count) + } + set(currentItemSelectionAtom, [cur[0], cur[1] + 1]) +}) + +export const itemSelectionSelectAllPageActionAtom = atom(null, (get, set) => { + const cur = get(currentItemSelectionAtom) + const items = get(inventoryItemsCurrentPageAtom) + for (const item of items) { + cur[0].set(item.item.id, item.item.item_count) + } + set(currentItemSelectionAtom, [cur[0], cur[1] + 1]) +}) + +export const paginateInventoryActionAtom = atom(null, (get, set, pages: number | undefined) => { + const inventoryRange = get(inventoryPageRangeAtom) + const pageSize = get(inventoryDisplaySettingsAtoms.pageSize) + const filteredItems = get(filteredCharacterItemsAtom) + if (pages === undefined) { + set(inventoryPageRangeAtom, { + start: 0, + end: pageSize, + }) + return + } + if (pageSize > filteredItems.length) { + set(inventoryPageRangeAtom, { + start: 0, + end: filteredItems.length, + }) + return + } + if (pages > 0) { + if (inventoryRange.end >= filteredItems.length) { + set(inventoryPageRangeAtom, { + start: 0, + end: pageSize, + }) + return + } + } 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: lastPageStart, + end: filteredItems.length, + }) + return + } + } + 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 + newStart = Math.max(0, newEnd - pageSize) + } + + set(inventoryPageRangeAtom, { + start: newStart, + 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 +}) \ No newline at end of file diff --git a/src/state/state.ts b/src/state/state.ts deleted file mode 100644 index 66c5aac..0000000 --- a/src/state/state.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { defineStore, storeToRefs } from 'pinia' -import { BasicColumns, ColumnInfo, ColumnName, DetailsColumns, MoveColumns } from '../lib/columns' -import { OrderTracker } from '../lib/lifeto/order_manager' -import { StoreAccounts, StoreChars, StoreColSet, StoreStr } from '../lib/storage' -import { ColumnSet } from '../lib/table' -import { TricksterAccount, TricksterCharacter, TricksterInventory } from '../lib/trickster' -import { nameCookie } from '../session_storage' - -const _defaultColumn: (ColumnInfo | ColumnName)[] = [ - ...BasicColumns, - ...MoveColumns, - ...DetailsColumns, -] - -// if you wish for the thing to persist -export const StoreReviver = { - chars: StoreChars, - accs: StoreAccounts, - activeTable: StoreStr, - screen: StoreStr, - columns: StoreColSet, - tags: StoreColSet, - // orders: StoreSerializable(OrderTracker) -} - -export interface StoreProps { - invs: Map - chars: Map - accs: Map - orders: OrderTracker - activeTable: string - screen: string - columns: ColumnSet - tags: ColumnSet - dirty: number - currentSearch: string -} - -export const useStore = defineStore('state', { - state: () => { - const store = { - invs: new Map() as Map, - chars: new Map() as Map, - accs: new Map() as Map, - orders: new OrderTracker(), - activeTable: 'none', - screen: 'default', - columns: new ColumnSet(_defaultColumn), - tags: new ColumnSet(), - dirty: 0, - currentSearch: '', - } - return store - }, -}) - -export const loadStore = () => { - const store = useStoreRef() - for (const [k, v] of Object.entries(StoreReviver)) { - const coke = localStorage.getItem(nameCookie(`last_${k}`)) - if (coke) { - if (store[k as keyof RefStore] !== undefined) { - store[k as keyof RefStore].value = v.Revive(coke) as any - } - } - } -} -export const saveStore = () => { - const store = useStoreRef() - for (const [k, v] of Object.entries(StoreReviver)) { - let coke: string | undefined - if (store[k as keyof RefStore] !== undefined) { - coke = v.Murder(store[k as keyof RefStore].value as any) - } - if (coke) { - localStorage.setItem(nameCookie(`last_${k}`), coke) - } - } -} - -export const useStoreRef = () => { - const refs = storeToRefs(useStore()) - return refs -} - -export type RefStore = ReturnType diff --git a/src/state/storage.ts b/src/state/storage/index.ts similarity index 100% rename from src/state/storage.ts rename to src/state/storage/index.ts