forked from a/lifeto-shop
noot
This commit is contained in:
parent
4d10b45b1e
commit
21b6041941
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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">
|
||||
<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)
|
||||
})
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle server maintenance (503) state
|
||||
if (loginState?.code === 503) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<form
|
||||
action={() => {
|
||||
login(username, password)
|
||||
.catch(e => {
|
||||
setLoginError(e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
<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="button"
|
||||
onClick={() => {
|
||||
setIsLoading(true)
|
||||
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>
|
||||
<input
|
||||
onChange={e => {
|
||||
setUsername(e.target.value)
|
||||
}}
|
||||
type="email"
|
||||
value={username}
|
||||
placeholder="username"
|
||||
className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500"
|
||||
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
|
||||
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"
|
||||
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"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}),
|
||||
],
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
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