1
0
forked from a/lifeto-shop
This commit is contained in:
a 2025-06-24 00:37:47 -05:00
parent 4d10b45b1e
commit 21b6041941
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
10 changed files with 678 additions and 672 deletions

View File

@ -7,7 +7,7 @@ export const App: FC = () => {
return (
<>
<div className="flex flex-row mx-auto p-4 gap-8 w-full h-full">
<div className="flex flex-col">
<div className="flex flex-col max-w-64">
<LoginWidget />
<CharacterRoulette />
</div>

View File

@ -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 })
<img
className="h-16"
src={`https://knowledge.lifeto.co/animations/character/chr${(
character.current_job -
character.base_job -
1
character.current_type || character.base_job
)
.toString()
.padStart(3, '0')}_13.png`}
@ -105,14 +103,6 @@ export const CharacterCard = ({ character }: { character: TricksterCharacter })
)
}
const PleaseLogin = () => {
return (
<>
<div className="align-center">no characters (not logged in?)</div>
</>
)
}
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 <PleaseLogin />
return null
}
const searchResults = fuse
.search(search || '!-----', {
@ -147,7 +139,7 @@ export const CharacterRoulette = () => {
return (
<div className="flex flex-col" key={`${x.item.character.account_id}`}>
<CharacterCard key={x.item.bank.id} character={x.item.bank} />
<CharacterCard key={x.item.character.id} character={x.item.character} />
<CharacterCard key={x.item.character.id} character={x.item.character} noTopBorder={true} />
</div>
)
})

View File

@ -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 (
<>
<div className="flex flex-row justify-between px-2">
<div>{loginState.community_name}</div>
<div className="flex flex-row gap-2">
<button
type="button"
onClick={() => {
logout().finally(() => {
refetchLoginState()
})
return
}}
className="text-blue-400 text-xs hover:cursor-pointer hover:text-blue-600"
>
logout
</button>
</div>
<div className="flex flex-row justify-between items-center px-4 py-2 bg-green-50 border border-green-200 rounded">
<div className="flex items-center gap-2">
<span className="text-green-600"></span>
<span className="font-medium">{loginState.community_name}</span>
</div>
</>
<button
type="button"
onClick={() => {
setIsLoading(true)
logout().finally(() => {
refetchLoginState()
setIsLoading(false)
})
}}
disabled={isLoading}
className="text-blue-600 text-sm hover:text-blue-800 hover:underline disabled:opacity-50"
>
{isLoading ? 'Logging out...' : 'Logout'}
</button>
</div>
)
}
return (
<>
<div className="flex flex-col">
<form
action={() => {
login(username, password)
.catch(e => {
setLoginError(e.message)
})
.finally(() => {
refetchLoginState()
refetchLoginState()
})
}}
className="flex flex-col gap-1 p-2 justify-left"
>
{loginError ? <div className="text-red-500 text-xs">{loginError}</div> : null}
<div>
<input
onChange={e => {
setUsername(e.target.value)
}}
value={username}
placeholder="username"
className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500"
/>
</div>
<div>
<input
onChange={e => {
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"
/>
</div>
// Handle server maintenance (503) state
if (loginState?.code === 503) {
return (
<div className="flex flex-col gap-2 p-4 bg-yellow-50 border border-yellow-200 rounded">
<div className="flex items-center gap-2 justify-center">
<span className="text-yellow-600"></span>
<span className="font-medium text-yellow-800">Server Maintenance</span>
</div>
<p className="text-sm text-yellow-700 ml-4">
The server is currently unavailable.{' '}
<button
type="submit"
className="border-b border-gray-600 px-2 py-1 hover:text-gray-600 hover:cursor-pointer"
type="button"
onClick={() => {
setIsLoading(true)
refetchLoginState()
// Add a small delay to show loading state
setTimeout(() => setIsLoading(false), 500)
}}
disabled={isLoading}
className="text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50"
>
login
{isLoading ? 'Checking...' : 'Retry'}
</button>
</form>
</p>
</div>
</>
)
}
// 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 (
<div className="p-4 bg-gray-50 border border-gray-200 rounded">
<form onSubmit={handleLogin} className="flex flex-col gap-3">
<h3 className="font-medium text-gray-700">Lifeto Login</h3>
{loginError && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
{loginError}
</div>
)}
<div>
<input
type="email"
value={username}
onChange={e => 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
/>
</div>
<div>
<input
type="password"
value={password}
onChange={e => 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
/>
</div>
<button
type="submit"
disabled={isLoading || !username || !password}
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
)
}

View File

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

View File

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

View File

@ -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<TricksterCharacter | undefined>(
'lto_state.selected_character',
undefined,
)
export const selectedTargetInventoryAtom = atom<TricksterCharacter | undefined>(undefined)
export const currentFilter = atom<undefined>(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<string, TricksterItem>()
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<InventoryFilter>(
'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<string, number>, number]>([
new Map<string, number>(),
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<string, number>(), 0])
})
export const itemSelectionSetActionAtom = atom(
null,
(get, set, arg: Record<string, number | undefined>) => {
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<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
})
// 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'

91
src/state/auth.atoms.ts Normal file
View File

@ -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<TricksterCharacter | undefined>(
'lto_state.selected_character',
undefined,
)

View File

@ -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<TricksterCharacter | undefined>(undefined)
export const currentFilter = atom<undefined>(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<string, TricksterItem>()
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<InventoryFilter>(
'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<string, number>, number]>([
new Map<string, number>(),
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<string, number>(), 0])
})
export const itemSelectionSetActionAtom = atom(
null,
(get, set, arg: Record<string, number | undefined>) => {
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<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
})

View File

@ -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<string, TricksterInventory>
chars: Map<string, TricksterCharacter>
accs: Map<string, TricksterAccount>
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<string, TricksterInventory>,
chars: new Map() as Map<string, TricksterCharacter>,
accs: new Map() as Map<string, TricksterAccount>,
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<typeof useStoreRef>