forked from a/lifeto-shop
noot
This commit is contained in:
parent
4d10b45b1e
commit
21b6041941
@ -7,7 +7,7 @@ export const App: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-row mx-auto p-4 gap-8 w-full h-full">
|
<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 />
|
<LoginWidget />
|
||||||
<CharacterRoulette />
|
<CharacterRoulette />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { useMemo, useState } from 'react'
|
|||||||
import { TricksterCharacter } from '../lib/trickster'
|
import { TricksterCharacter } from '../lib/trickster'
|
||||||
import { charactersAtom, selectedCharacterAtom } from '../state/atoms'
|
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 [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
const { refs, floatingStyles, context } = useFloating({
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
@ -56,7 +56,7 @@ export const CharacterCard = ({ character }: { character: TricksterCharacter })
|
|||||||
ref={refs.setReference}
|
ref={refs.setReference}
|
||||||
{...getReferenceProps()}
|
{...getReferenceProps()}
|
||||||
className={`
|
className={`
|
||||||
flex flex-col border border-black
|
flex flex-col ${noTopBorder ? 'border-l border-r border-b' : 'border'} border-black
|
||||||
hover:cursor-pointer
|
hover:cursor-pointer
|
||||||
hover:bg-blue-100
|
hover:bg-blue-100
|
||||||
p-2 ${character.path === selectedCharacter?.path ? `bg-blue-200 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
|
<img
|
||||||
className="h-16"
|
className="h-16"
|
||||||
src={`https://knowledge.lifeto.co/animations/character/chr${(
|
src={`https://knowledge.lifeto.co/animations/character/chr${(
|
||||||
character.current_job -
|
character.current_type || character.base_job
|
||||||
character.base_job -
|
|
||||||
1
|
|
||||||
)
|
)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(3, '0')}_13.png`}
|
.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 = () => {
|
export const CharacterRoulette = () => {
|
||||||
const [{ data: rawCharacters }] = useAtom(charactersAtom)
|
const [{ data: rawCharacters }] = useAtom(charactersAtom)
|
||||||
|
|
||||||
@ -136,8 +126,10 @@ export const CharacterRoulette = () => {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}, [rawCharacters])
|
}, [rawCharacters])
|
||||||
|
|
||||||
|
// Return nothing when no characters
|
||||||
if (!characters || characters.length === 0) {
|
if (!characters || characters.length === 0) {
|
||||||
return <PleaseLogin />
|
return null
|
||||||
}
|
}
|
||||||
const searchResults = fuse
|
const searchResults = fuse
|
||||||
.search(search || '!-----', {
|
.search(search || '!-----', {
|
||||||
@ -147,7 +139,7 @@ export const CharacterRoulette = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col" key={`${x.item.character.account_id}`}>
|
<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.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>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,81 +7,126 @@ import { loginStatusAtom } from '../state/atoms'
|
|||||||
export const LoginWidget = () => {
|
export const LoginWidget = () => {
|
||||||
const [username, setUsername] = useLocalStorage('input_username', '', { syncData: false })
|
const [username, setUsername] = useLocalStorage('input_username', '', { syncData: false })
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const [{ data: loginState, refetch: refetchLoginState }] = useAtom(loginStatusAtom)
|
const [{ data: loginState, refetch: refetchLoginState }] = useAtom(loginStatusAtom)
|
||||||
|
|
||||||
const [loginError, setLoginError] = useState('')
|
const [loginError, setLoginError] = useState('')
|
||||||
|
|
||||||
|
// Handle logged in state
|
||||||
if (loginState?.logged_in) {
|
if (loginState?.logged_in) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-row justify-between items-center px-4 py-2 bg-green-50 border border-green-200 rounded">
|
||||||
<div className="flex flex-row justify-between px-2">
|
<div className="flex items-center gap-2">
|
||||||
<div>{loginState.community_name}</div>
|
<span className="text-green-600">●</span>
|
||||||
<div className="flex flex-row gap-2">
|
<span className="font-medium">{loginState.community_name}</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setIsLoading(true)
|
||||||
logout().finally(() => {
|
logout().finally(() => {
|
||||||
refetchLoginState()
|
refetchLoginState()
|
||||||
|
setIsLoading(false)
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}}
|
}}
|
||||||
className="text-blue-400 text-xs hover:cursor-pointer hover:text-blue-600"
|
disabled={isLoading}
|
||||||
|
className="text-blue-600 text-sm hover:text-blue-800 hover:underline disabled:opacity-50"
|
||||||
>
|
>
|
||||||
logout
|
{isLoading ? 'Logging out...' : 'Logout'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle server maintenance (503) state
|
||||||
|
if (loginState?.code === 503) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-2 p-4 bg-yellow-50 border border-yellow-200 rounded">
|
||||||
<div className="flex flex-col">
|
<div className="flex items-center gap-2 justify-center">
|
||||||
<form
|
<span className="text-yellow-600">⚠️</span>
|
||||||
action={() => {
|
<span className="font-medium text-yellow-800">Server Maintenance</span>
|
||||||
login(username, password)
|
</div>
|
||||||
.catch(e => {
|
<p className="text-sm text-yellow-700 ml-4">
|
||||||
setLoginError(e.message)
|
The server is currently unavailable.{' '}
|
||||||
})
|
<button
|
||||||
.finally(() => {
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoading(true)
|
||||||
refetchLoginState()
|
refetchLoginState()
|
||||||
refetchLoginState()
|
// Add a small delay to show loading state
|
||||||
})
|
setTimeout(() => setIsLoading(false), 500)
|
||||||
}}
|
}}
|
||||||
className="flex flex-col gap-1 p-2 justify-left"
|
disabled={isLoading}
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loginError ? <div className="text-red-500 text-xs">{loginError}</div> : null}
|
{isLoading ? 'Checking...' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
</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>
|
<div>
|
||||||
<input
|
<input
|
||||||
onChange={e => {
|
type="email"
|
||||||
setUsername(e.target.value)
|
|
||||||
}}
|
|
||||||
value={username}
|
value={username}
|
||||||
placeholder="username"
|
onChange={e => setUsername(e.target.value)}
|
||||||
className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
onChange={e => {
|
|
||||||
setPassword(e.target.value)
|
|
||||||
}}
|
|
||||||
value={password}
|
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="password"
|
value={password}
|
||||||
className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500"
|
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="border-b border-gray-600 px-2 py-1 hover:text-gray-600 hover:cursor-pointer"
|
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"
|
||||||
>
|
>
|
||||||
login
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,6 +106,7 @@ export class LTOApiv0 implements LTOApi {
|
|||||||
class: -8,
|
class: -8,
|
||||||
base_job: -8,
|
base_job: -8,
|
||||||
current_job: -8,
|
current_job: -8,
|
||||||
|
current_type: -8,
|
||||||
},
|
},
|
||||||
...Object.values(x.characters).map((z: any) => {
|
...Object.values(x.characters).map((z: any) => {
|
||||||
return {
|
return {
|
||||||
@ -117,6 +118,7 @@ export class LTOApiv0 implements LTOApi {
|
|||||||
class: z.class,
|
class: z.class,
|
||||||
base_job: z.base_job,
|
base_job: z.base_job,
|
||||||
current_job: z.current_job,
|
current_job: z.current_job,
|
||||||
|
current_type: z.current_type,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export interface TricksterCharacter extends Identifier {
|
|||||||
class: number
|
class: number
|
||||||
base_job: number
|
base_job: number
|
||||||
current_job: number
|
current_job: number
|
||||||
|
current_type: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TricksterInventory extends Identifier {
|
export interface TricksterInventory extends Identifier {
|
||||||
|
|||||||
@ -1,511 +1,44 @@
|
|||||||
import { AxiosError } from 'axios'
|
// Re-export all atoms from the separate files for backward compatibility
|
||||||
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'
|
|
||||||
|
|
||||||
export const LTOApi = new LTOApiv0(new TokenSession())
|
// Auth-related atoms
|
||||||
|
export {
|
||||||
|
LTOApi,
|
||||||
|
loginStatusAtom,
|
||||||
|
charactersAtom,
|
||||||
|
selectedCharacterAtom,
|
||||||
|
} from './auth.atoms'
|
||||||
|
|
||||||
export const loginStatusAtom = atomWithQuery(_get => {
|
// Inventory-related atoms
|
||||||
return {
|
export {
|
||||||
queryKey: ['login_status'],
|
selectedTargetInventoryAtom,
|
||||||
enabled: true,
|
currentFilter,
|
||||||
placeholderData: {
|
currentCharacterInventoryAtom,
|
||||||
logged_in: false,
|
inventoryDisplaySettingsAtoms,
|
||||||
community_name: '...',
|
currentCharacterItemsAtom,
|
||||||
},
|
type InventoryFilter,
|
||||||
queryFn: async () => {
|
inventoryFilterAtom,
|
||||||
return LoginHelper.info()
|
preferenceInventorySearch,
|
||||||
.then(info => {
|
preferenceInventoryTab,
|
||||||
return {
|
preferenceInventorySort,
|
||||||
logged_in: true,
|
preferenceInventorySortReverse,
|
||||||
community_name: info.community_name,
|
setInventoryFilterTabActionAtom,
|
||||||
}
|
inventoryPageRangeAtom,
|
||||||
})
|
nextInventoryPageActionAtom,
|
||||||
.catch(e => {
|
currentItemSelectionAtom,
|
||||||
if (e instanceof AxiosError) {
|
currentInventorySearchQueryAtom,
|
||||||
return {
|
filteredCharacterItemsAtom,
|
||||||
logged_in: false,
|
inventoryItemsCurrentPageAtom,
|
||||||
community_name: '...',
|
rowSelectionLastActionAtom,
|
||||||
}
|
mouseDragSelectionStateAtom,
|
||||||
}
|
clearItemSelectionActionAtom,
|
||||||
throw e
|
itemSelectionSetActionAtom,
|
||||||
})
|
itemSelectionSelectAllFilterActionAtom,
|
||||||
},
|
itemSelectionSelectAllPageActionAtom,
|
||||||
}
|
paginateInventoryActionAtom,
|
||||||
})
|
type MoveItemsResult,
|
||||||
|
type MoveConfirmationState,
|
||||||
export const charactersAtom = atomWithQuery(get => {
|
moveConfirmationAtom,
|
||||||
const { data: loginStatus } = get(loginStatusAtom)
|
openMoveConfirmationAtom,
|
||||||
return {
|
closeMoveConfirmationAtom,
|
||||||
queryKey: ['characters', loginStatus?.community_name || '...'],
|
moveSelectedItemsAtom,
|
||||||
enabled: !!loginStatus?.logged_in,
|
} from './inventory.atoms'
|
||||||
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
|
|
||||||
})
|
|
||||||
91
src/state/auth.atoms.ts
Normal file
91
src/state/auth.atoms.ts
Normal 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,
|
||||||
|
)
|
||||||
428
src/state/inventory.atoms.ts
Normal file
428
src/state/inventory.atoms.ts
Normal 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
|
||||||
|
})
|
||||||
@ -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>
|
|
||||||
Loading…
Reference in New Issue
Block a user