This commit is contained in:
a 2025-05-25 00:17:41 -05:00
parent 79f90a7478
commit 3d30ab085f
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
23 changed files with 861 additions and 219 deletions

View File

@ -12,6 +12,7 @@
"@handsontable/react": "^15.3.0",
"@mantine/hooks": "^8.0.0",
"@tanstack/react-query": "^5.76.0",
"@tanstack/react-table": "^8.21.3",
"@types/qs": "^6.9.18",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
@ -19,6 +20,7 @@
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.4.1",
"arktype": "^2.1.20",
"axios": "^1.9.0",
"eslint": "^9.26.0",
"eslint-config-react-app": "^7.0.1",
@ -28,15 +30,19 @@
"fuse.js": "^7.1.0",
"handsontable": "^15.3.0",
"jotai": "^2.12.4",
"jotai-optics": "^0.4.0",
"jotai-tanstack-query": "^0.9.0",
"loglevel": "^1.9.2",
"optics-ts": "^2.4.1",
"pinia": "^3.0.2",
"prettier": "^3.5.3",
"qs": "^6.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-select": "^5.10.1",
"react-spinners": "^0.17.0",
"superjson": "^2.2.2",
"typescript-cookie": "^1.0.6",
"use-local-storage": "^3.0.0",
"usehooks-ts": "^3.1.1",
@ -44,6 +50,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.6",
"@types/node": "^22.15.18",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.6",
"typescript": "^5.8.3",

BIN
public/cursor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,21 +1,17 @@
import { FC } from "react";
import { LoginWidget } from "./components/login";
import { CharacterRoulette } from "./components/characters";
import { Inventory } from "./components/inventory";
import { Inventory } from "./components/inventory/index";
export const App: FC = () => {
return (
<>
<div className="flex flex-col mx-auto p-4 w-full">
<div className="flex flex-row max-w-6xl">
<div className="flex flex-row justify-end w-full">
<div className="flex flex-row mx-auto p-4 gap-8 w-full h-full">
<div className="flex flex-col">
<LoginWidget/>
</div>
</div>
<div>
<CharacterRoulette/>
</div>
<div className="overflow-hidden">
<div className="flex-1">
<Inventory/>
</div>
</div>

View File

@ -1,7 +1,6 @@
import { TricksterCharacter } from "../lib/trickster"
import { useSessionContext } from "../context/SessionContext"
import Fuse from 'fuse.js'
import { useAtom, useSetAtom } from "jotai"
import { useAtom } from "jotai"
import { charactersAtom, selectedCharacterAtom } from "../state/atoms"
import { useMemo, useState } from "react";
import {
@ -146,14 +145,14 @@ export const CharacterRoulette = ()=>{
return <>
<div className="flex flex-col gap-1">
<input
className="border border-black-1 bg-gray-100 placeholder-gray-600 p-1 max-w-[200px]"
className="border border-black-1 bg-gray-100 placeholder-gray-600 p-1 max-w-[180px]"
placeholder="search character..."
value={search}
onChange={(e)=>{
setSearch(e.target.value)
}}
></input>
<div className="flex flex-row overflow-x-scroll gap-1 h-full min-h-36">
<div className="flex flex-row flex-wrap overflow-x-scroll gap-1 h-full min-h-36 max-w-48">
{searchResults ? searchResults : <>
</>}
</div>

View File

@ -0,0 +1,149 @@
import { clearItemSelectionActionAtom, currentCharacterItemsAtom, filteredCharacterItemsAtom, inventoryFilterAtom, inventoryItemsCurrentPageAtom, inventoryPageRangeAtom, itemSelectionSelectAllFilterActionAtom, itemSelectionSelectAllPageActionAtom, paginateInventoryActionAtom, preferenceInventorySearch, selectedCharacterAtom, setInventoryFilterTabActionAtom} from "@/state/atoms";
import {useAtom, useAtomValue, useSetAtom } from "jotai";
import { InventoryTargetSelector } from './movetarget';
import { InventoryTable } from './table';
import { FaArrowLeft, FaArrowRight } from "react-icons/fa";
const sections = [
{ name: 'all', value: '' },
{ name: 'consume', value: '1' },
{ name: 'equip', value: '2' },
{ name: 'drill', value: '3' },
{ name: 'pet', value: '4' },
{ name: 'etc', value: '5' },
]
const cardSections = [
{ name: 'skill', value: '10' },
{ name: 'char', value: '11' },
{ name: 'mon', value: '12' },
{ name: 'fortune', value: '13' },
{ name: 'secret', value: '14' },
{ name: 'arcana', value: '15' },
]
const InventoryTabs = ()=> {
const inventoryFilter= useAtomValue(inventoryFilterAtom)
const setInventoryFilterTab = useSetAtom(setInventoryFilterTabActionAtom)
const inventoryRange = useAtomValue(inventoryPageRangeAtom)
const items = useAtomValue(filteredCharacterItemsAtom)
console.log("items", items)
const sharedStyle = "hover:cursor-pointer hover:bg-gray-200 px-2 pr-4 border border-gray-200"
const selectedStyle = "bg-gray-200 border-b-2 border-black-1"
return <div className="flex flex-row gap-1 justify-between">
<div className="flex flex-col gap-1">
<div className="flex flex-row gap-1">
{sections.map(x=>{
return <div
onClick={()=>{
setInventoryFilterTab(x.value)
}}
key={x.name}
className={`${sharedStyle}
${inventoryFilter.tab === x.value ? selectedStyle : ""}`}
>{x.name}</div>
})}
</div>
<div className="flex flex-row gap-1">
{cardSections.map(x=>{
return <div
onClick={()=>{
setInventoryFilterTab(x.value)
}}
key={x.name}
className={`${sharedStyle}
${inventoryFilter.tab === x.value ? selectedStyle : ""}`}
>{x.name}</div>
})}
</div>
</div>
<div className="flex flex-row gap-1 items-center px-1 bg-yellow-100">
<div className="whitespace-pre select-none">{inventoryRange.start}..{inventoryRange.end}/{items.length} </div>
</div>
</div>
}
export const Inventory = () => {
const selectedCharacter = useAtomValue(selectedCharacterAtom)
const clearItemSelection = useSetAtom(clearItemSelectionActionAtom)
const addPageItemSelection = useSetAtom(itemSelectionSelectAllPageActionAtom)
const addFilterItemSelection = useSetAtom(itemSelectionSelectAllFilterActionAtom)
const [search, setSearch] = useAtom(preferenceInventorySearch)
const paginateInventory = useSetAtom(paginateInventoryActionAtom)
if(!selectedCharacter){
return <div>
select a character
</div>
}
return <div className={`flex flex-col h-full w-full`}>
<div className="flex flex-col py-2 flex-0 justify-between h-full">
<div className="flex flex-row justify-between">
<div className="flex flex-row gap-2">
<div className="whitespace-pre bg-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
onClick={()=>{
addPageItemSelection()
}}
>select filtered</div>
<div className="whitespace-pre bg-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
onClick={()=>{
addFilterItemSelection()
}}
>select page</div>
<div className="whitespace-pre bg-blue-200 px-2 py-1 rounded-xl hover:cursor-pointer hover:bg-blue-300"
onClick={()=>{
clearItemSelection()
}}
>clear </div>
</div>
<div className="flex flex-row">
<InventoryTargetSelector/>
<div
onClick={(e)=>{
// sendOrders()
}}
className="hover:cursor-pointer whitespace-preborder border-black-1 bg-orange-200 hover:bg-orange-300 px-2 py-1">Move Selected</div>
</div>
</div>
<div className="flex flex-row gap-2 justify-between">
<div className="flex flex-row gap-0 items-center">
<input
type="text"
value={search}
className="border border-black-1 px-2 py-1"
placeholder="search..."
onChange={(e)=>{
setSearch(e.target.value)
}}
/>
<div
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 py-1 h-full flex items-center"
onClick={()=>{
paginateInventory(-1)
}}
><FaArrowLeft/></div>
<div
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 py-1 h-full flex items-center"
onClick={()=>{
paginateInventory(1)
}}
><FaArrowRight/></div>
</div>
</div>
</div>
<InventoryTabs />
<div className="flex flex-col flex-1 h-full border border-black-2">
<InventoryTable />
</div>
</div>
}

View File

@ -1,39 +1,20 @@
import { TricksterCharacter } from "../lib/trickster"
import { useSessionContext } from "../context/SessionContext"
import 'handsontable/dist/handsontable.full.min.css';
import { registerAllModules } from 'handsontable/registry';
import { HotTable, HotTableClass } from '@handsontable/react';
import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState} from "react";
import { InventoryTable } from "../lib/table";
import { DotLoader } from "react-spinners";
import { useResizeObserver } from "@mantine/hooks";
import { Columns } from "../lib/columns";
import { OrderDetails, OrderSender } from "../lib/lifeto/order_manager";
import log from "loglevel";
import { charactersAtom, currentCharacterInventoryAtom, currentCharacterItemsAtom, LTOApi, selectedTargetInventoryAtom } from "../state/atoms";
import Select from 'react-select';
import { forwardRef, useId, useMemo, useRef, useState} from "react";
import { useAtom, useAtomValue } from "jotai";
import { autoUpdate, flip, FloatingFocusManager, FloatingPortal, size, useDismiss, useFloating, useInteractions, useListNavigation, useRole } from "@floating-ui/react";
import Fuse from "fuse.js";
import { charactersAtom, selectedTargetInventoryAtom } from "@/state/atoms";
registerAllModules();
type Size = {
width?: number
height?: number
}
interface InventoryItemProps {
interface AccountInventorySelectorItemProps {
children: React.ReactNode;
active: boolean;
}
const InventoryItem = forwardRef<
const AccountInventorySelectorItem = forwardRef<
HTMLDivElement,
InventoryItemProps & React.HTMLProps<HTMLDivElement>
AccountInventorySelectorItemProps & React.HTMLProps<HTMLDivElement>
>(({ children, active, ...rest }, ref) => {
const id = useId();
return (
@ -54,8 +35,7 @@ const InventoryItem = forwardRef<
</div>
);
});
const InventoryTargetSelector = () => {
export const InventoryTargetSelector = () => {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [activeIndex, setActiveIndex] = useState<number | null>(null);
@ -133,6 +113,9 @@ const InventoryTargetSelector = () => {
value: selectedTargetInventory !== undefined ? selectedTargetInventory.name : inputValue,
placeholder: "Target Inventory",
"aria-autocomplete": "list",
onFocus() {
setOpen(true);
},
onKeyDown(event) {
if (
event.key === "Enter" &&
@ -166,7 +149,7 @@ const InventoryTargetSelector = () => {
})}
>
{items.map((item, index) => (
<InventoryItem
<AccountInventorySelectorItem
{...getItemProps({
key: item.path,
ref(node) {
@ -182,7 +165,7 @@ const InventoryTargetSelector = () => {
active={activeIndex === index}
>
{item.name}
</InventoryItem>
</AccountInventorySelectorItem>
))}
</div>
</FloatingFocusManager>
@ -191,76 +174,3 @@ const InventoryTargetSelector = () => {
</>
);
}
export const Inventory = () => {
const {activeTable, columns, tags, orders} = useSessionContext()
const [ref, {height}] = useResizeObserver({})
const {data:character, isLoading, isFetching } = useAtomValue(currentCharacterInventoryAtom)
//const sendOrders = useCallback(()=>{
// if(!hotTableComponent.current?.hotInstance){
// return
// }
// const hott = hotTableComponent.current?.hotInstance
// const headers = hott.getColHeader()
// const dat = hott.getData()
// const idxNumber = headers.indexOf(Columns.MoveCount.displayName)
// const idxTarget = headers.indexOf(Columns.Move.displayName)
// const origin = activeTable
// const pending:OrderDetails[] = [];
// for(const row of dat) {
// try{
// const nm = Number(row[idxNumber].replace("x",""))
// const target = (row[idxTarget] as string).replaceAll("-","").trim()
// if(!isNaN(nm) && nm > 0 && target.length > 0){
// const info:OrderDetails = {
// item_uid: row[0].toString(),
// count: nm,
// origin_path: activeTable,
// target_path: target,
// }
// pending.push(info)
// }
// }catch(e){
// }
// }
// log.debug("OrderDetails", pending)
// const chars = new Map<string,TricksterCharacter>()
// const manager = new OrderSender(orders, chars)
// for(const d of pending){
// const order = manager.send(d)
// //order.tick(api)
// }
//}, [orders])
const Loading = ()=>{
return <div role="status" className="flex align-center justify-center">
<div className="justify-center py-4">
<DotLoader color="#dddddd"/>
</div>
</div>
}
const items = useAtomValue(currentCharacterItemsAtom)
return <div ref={ref} className={``}>
<div className="flex flex-row py-2 justify-end">
<InventoryTargetSelector/>
<div
onClick={(e)=>{
// sendOrders()
}}
className="
hover:cursor-pointer
border border-black-1
bg-green-200
px-2 py-1
">Move Selected</div>
</div>
{(isLoading || isFetching) ? <Loading/> : <>
<div>
total: {items.size}
</div>
</> }
</div>
}

View File

@ -0,0 +1,90 @@
import { StatsColumns } from "@/lib/columns"
import { ItemWithSelection } from "@/lib/table/defs"
import { InventoryColumns } from "@/lib/table/tanstack"
import { inventoryItemsCurrentPageAtom, preferenceInventoryTab } from "@/state/atoms"
import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { atom, useAtom, useAtomValue } from "jotai"
import { useMemo } from "react"
const columnVisibilityAtom = atom((get)=>{
const itemTab = get(preferenceInventoryTab)
if(!["2","4"].includes(itemTab)) {
return Object.fromEntries([
...StatsColumns.map(x=>["stats."+x,false]),
["slots",false]
])
}
return {
}
})
export const InventoryTable = () => {
const items = useAtomValue(inventoryItemsCurrentPageAtom)
const columns = useMemo(()=>{
return [
...Object.values(InventoryColumns)
]
}, [])
const columnVisibility = useAtomValue(columnVisibilityAtom)
console.log(columnVisibility)
const table = useReactTable<ItemWithSelection>({
getRowId: row =>row.item.unique_id.toString(),
data: items,
state: {
columnVisibility,
},
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div className="overflow-y-auto h-full mb-32">
<table
onContextMenu={(e)=>{
e.preventDefault()
return
}}
className="border-spacing-x-2 border-separate">
<thead className="sticky top-0 z-10 select-none bg-white">
{table.getHeaderGroups().map(headerGroup => (
<tr
className=""
key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
className="text-left"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-200">
{table.getRowModel().rows.map(row => (
<tr
key={row.id}
className={""}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -1,5 +1,14 @@
@import 'tailwindcss';
html {
cursor: url(/public/cursor.png), auto !important;
}
@theme {
--cursor-default: url(/public/cursor.png), auto !important;
--cursor-pointer: url(/public/cursor.png), pointer !important;
--cursor-text: url(/public/cursor.png), pointer !important;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still

View File

@ -4,16 +4,20 @@ import { App } from "./App";
import AppContext from "./context/AppContext";
import "./lib/superjson";
import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "jotai";
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("app") as HTMLElement).render(
<React.StrictMode>
<Provider>
<QueryClientProvider client={queryClient}>
<AppContext>
<App />
</AppContext>
</QueryClientProvider>
</Provider>
</React.StrictMode>,
);

View File

@ -21,7 +21,7 @@ export const EquipmentColumns = [
] as const
export const StatsColumns = [
"AP","GunAP","AC","DX","MP","MA","MD","WT","DA","LK","HP","DP","HV",
"HV","AC","LK","WT","HP","MA","DP","DX","MP","AP","MD","DA","GunAP"
] as const

View File

@ -73,6 +73,7 @@ export class LTOApiv0 implements LTOApi {
galders,
items: new Map((Object.entries(o.items) as any).map(([k, v]: [string, TricksterItem]):[string, TricksterItem]=>{
v.unique_id = Number(k)
v.id = k
return [k, v]
})),
}

View File

@ -60,7 +60,7 @@ export const StoreAccounts = {
}
export const StoreJsonable = {
Murder: <T>(s:T):string=>JSON.stringify(Object.entries(s)),
Murder: <T extends object>(s:T):string=>JSON.stringify(Object.entries(s)),
Revive: <T>(s:string):T=>JSON.parse(s),
}

1
src/lib/superjson.ts Normal file
View File

@ -0,0 +1 @@
import SuperJSON from "superjson";

View File

@ -1,7 +1,5 @@
import { TricksterInventory } from "./trickster"
import {ColumnInfo, ColumnName, Columns, ColumnSorter, LazyColumn} from "./columns"
import { PredefinedMenuItemKey } from "handsontable/plugins/contextMenu"
import Handsontable from "handsontable"
import { HotTableProps } from "@handsontable/react"
@ -95,25 +93,8 @@ export class InventoryTable {
getTableColumnNames(): string[] {
return this.o.columns.map(x=>x.displayName)
}
getTableColumnSettings(): Handsontable.ColumnSettings[] {
return this.o.columns.map(x=>{
let out:Handsontable.ColumnSettings = {
renderer: x.renderer ? x.renderer : "text",
filters: true,
dropdownMenu: x.filtering ? DefaultDropdownItems() : false,
readOnly: x.writable ? false : true,
selectionMode: (x.writable ? "multiple" : 'single') as any,
}
if(x.options) {
out.type = 'dropdown'
if(typeof x.options == "function") {
out.source = x.options(this.o.accounts)
}else {
out.source = x.options
}
}
return out
})
getTableColumnSettings(){
}
getTableRows():any[][] {
return Object.values(this.inv.items)
@ -162,47 +143,3 @@ export interface TableRecipe {
settings: HotTableProps
}
export const DefaultDropdownItems = ():PredefinedMenuItemKey[]=>['filter_by_condition' , 'filter_operators' ,'filter_by_condition2' , 'filter_by_value' , 'filter_action_bar']
export const DefaultSettings = ():HotTableProps=>{
return {
trimDropdown: true,
filters: true,
manualRowMove: false,
manualColumnMove: false,
allowInsertRow: false,
allowInsertColumn: false,
allowRemoveRow: false,
allowRemoveColumn: false,
allowHtml: true,
disableVisualSelection: false,
columnSorting: {
indicator: true,
headerAction: true,
},
hiddenColumns: {
columns: [0],
},
// renderAllRows: true,
viewportColumnRenderingOffset: 3,
viewportRowRenderingOffset: 10,
// dropdownMenu: DefaultDropdownItems(),
afterGetColHeader: (col, th) => {
if(!th.innerHTML.toLowerCase().includes("name")) {
const ct = th.querySelector('.changeType')
if(ct){
ct.parentElement!.removeChild(ct)
}
}
},
beforeOnCellMouseDown(event:any, coords) {
// Deselect the column after clicking on input.
if (coords.row === -1 && event.target.nodeName === 'INPUT') {
event.stopImmediatePropagation();
}
},
className: 'htLeft',
contextMenu: false,
readOnlyCellClassName: "",
licenseKey:"non-commercial-and-evaluation",
}
}

11
src/lib/table/defs.ts Normal file
View File

@ -0,0 +1,11 @@
import { TricksterItem } from "../trickster";
export interface ItemSelectionStatus {
selected: boolean;
amount?: number;
}
export interface ItemWithSelection {
item: TricksterItem
status?: ItemSelectionStatus;
}

138
src/lib/table/tanstack.tsx Normal file
View File

@ -0,0 +1,138 @@
import { createColumnHelper } from '@tanstack/react-table';
import { ItemWithSelection } from './defs';
import { useAtomValue, useSetAtom } from 'jotai';
import { currentItemSelectionAtom, itemSelectionSetActionAtom } from '@/state/atoms';
import { useMemo } from 'react';
import { StatsColumns } from '../columns';
const ch = createColumnHelper<ItemWithSelection>();
const columns = {
icon: ch.display({
id: 'icon',
header: function Component(col) {
return <div className="flex flex-row justify-center"></div>
},
cell: function Component({ row }){
const setItemSelection= useSetAtom(itemSelectionSetActionAtom);
const c = useAtomValue(currentItemSelectionAtom);
const selected = useMemo(()=> {
return c[0].has(row.original.item.id);
}, [c])
return <div
className={`no-select flex flex-row ${ row.original.status?.selected ? "animate-pulse" : ""}`}
onClick={(e)=>{
setItemSelection({
[row.original.item.id]: selected ? undefined : row.original.item.item_count,
})
}}
>
<div className="flex flex-row w-6 h-6 justify-center">
<img src={row.original.item.item_image || ""} alt="icon" className="select-none object-contain select-none"/>
</div>
</div>
},
}),
count: ch.display({
id: 'count',
header: function Component(col){
return <div className="flex flex-row justify-center">#</div>
},
cell: function Component({ row }){
const c = useAtomValue(currentItemSelectionAtom);
const setItemSelection= useSetAtom(itemSelectionSetActionAtom);
const currentValue = useMemo(()=> {
const got = c[0].get(row.original.item.id);
if(got !== undefined) {
return got.toString();
}
return ""
}, [c])
const itemCount = row.original.item.item_count
return <div
className={`flex flex-row select-none ${ row.original.status?.selected ? "bg-gray-200" : ""}`}
>
<input
className="w-10 text-center "
value={currentValue}
onChange={(e)=>{
if(e.target.value === ""){
setItemSelection({[row.original.item.id]: undefined});
return
}
if(e.target.value === "-"){
setItemSelection({
[row.original.item.id]: itemCount,
})
}
let parsedInt = parseInt(e.target.value);
if (isNaN(parsedInt)) {
return;
}
if(parsedInt > itemCount){
parsedInt = itemCount;
}
setItemSelection({
[row.original.item.id]: parsedInt,
})
}}
placeholder={itemCount.toString()} />
</div>
},
}),
name: ch.display({
id: 'name',
header: (col)=> {
return <div
className="flex flex-row text-sm"
>name</div>
},
cell: function Component({ row }){
return <div className="flex flex-row whitespace-pre">
<span>{row.original.item.item_name}</span>
</div>
},
}),
slots: ch.display({
id: 'slots',
header: (col)=>{
return <div
className="flex flex-row text-sm"
>slots</div>
},
cell: function Component({ row }){
return <div className="flex flex-row justify-center">
<span>{row.original.item.item_slots}</span>
</div>
},
}),
stats: ch.group({
id: 'stats',
header: (col)=>{
return <div
className="flex flex-row text-sm"
>stats</div>
},
columns: [
...StatsColumns.map((c)=>{
return ch.display({
id: 'stats.'+c,
header: (col)=>{
return <div
className="flex flex-row text-sm justify-center"
>{c}</div>
},
cell: function Component({ row }){
const stats = row.original.item.stats
const stat = stats ? stats[c] : ""
return <div className={`flex flex-row justify-start ${stat ? "border" : ""}`}>
<span>{stat}</span>
</div>
},
})
})
]
}),
} as const;
export const InventoryColumns = columns;

View File

@ -1,10 +1,13 @@
export interface TricksterItem {
id: string;
unique_id: number;
item_name: string;
item_count: number;
item_comment: string;
item_use: string;
item_slots?: number;
item_tab: number
item_type: number,
item_min_level?: number;
is_equip?: boolean;
is_drill?: boolean;

View File

@ -1,12 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia';
import log from 'loglevel';
log.setLevel("debug")
const pinia = createPinia()
createApp(App).
use(pinia).
mount('#app')

View File

@ -2,9 +2,13 @@ import { AxiosError } from 'axios';
import { LTOApiv0 } from '../lib/lifeto'
import { LoginHelper, TokenSession } from '../lib/session'
import { atomWithQuery } from 'jotai-tanstack-query'
import {atomFamily, atomWithRefresh, atomWithStorage} from "jotai/utils";
import {atomWithStorage} from "jotai/utils";
import { atom } from 'jotai';
import { TricksterCharacter, TricksterInventory, TricksterItem } from '../lib/trickster';
import {focusAtom} from "jotai-optics";
import { createSuperjsonStorage, superJsonStorage } from './storage';
import { ItemWithSelection } from '@/lib/table/defs';
import Fuse from 'fuse.js';
export const LTOApi = new LTOApiv0(new TokenSession())
@ -77,10 +81,9 @@ export const charactersAtom = atomWithQuery((get) => {
}
})
export const selectedCharacterAtom = atom<TricksterCharacter | undefined>(undefined)
export const selectedCharacterAtom = atomWithStorage<TricksterCharacter | undefined>("lto_state.selected_character", undefined)
export const selectedTargetInventoryAtom = atom<TricksterCharacter | undefined>(undefined)
export const pageSize = atomWithStorage("preference.page_size", 250)
export const currentFilter = atom<undefined>(undefined)
@ -96,9 +99,221 @@ export const currentCharacterInventoryAtom = atomWithQuery((get) => {
}
})
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)
return inventory?.items || new Map<string, TricksterItem>()
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,
}
})
})
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, searcher } = 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 = 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 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) {
set(inventoryPageRangeAtom, {
start: filteredItems.length - pageSize,
end: filteredItems.length,
})
return
}
}
const delta = pages * pageSize
let newStart = inventoryRange.start + delta
let newEnd = inventoryRange.end + delta
if(newEnd > filteredItems.length) {
newEnd = filteredItems.length
}
if(newEnd - newStart != pageSize) {
newStart = newEnd - pageSize
}
set(inventoryPageRangeAtom, {
start: newStart,
end: newEnd,
})
})

116
src/state/storage.ts Normal file
View File

@ -0,0 +1,116 @@
import { AsyncStorage, AsyncStringStorage, SyncStorage, SyncStringStorage } from "jotai/vanilla/utils/atomWithStorage"
import superjson from 'superjson'
const isPromiseLike = (x: unknown): x is PromiseLike<unknown> =>
typeof (x as any)?.then === 'function'
type Unsubscribe = () => void
type Subscribe<Value> = (
key: string,
callback: (value: Value) => void,
initialValue: Value,
) => Unsubscribe | undefined
type StringSubscribe = (
key: string,
callback: (value: string | null) => void,
) => Unsubscribe | undefined
export function createSuperjsonStorage<Value>(): SyncStorage<Value>
export function createSuperjsonStorage<Value>(
getStringStorage: () =>
| AsyncStringStorage
| SyncStringStorage
| undefined = () => {
try {
return window.localStorage
} catch (e) {
if (import.meta.env?.MODE !== 'production') {
if (typeof window !== 'undefined') {
console.warn(e)
}
}
return undefined
}
},
): AsyncStorage<Value> | SyncStorage<Value> {
let lastStr: string | undefined
let lastValue: Value
const storage: AsyncStorage<Value> | SyncStorage<Value> = {
getItem: (key, initialValue) => {
const parse = (str: string | null) => {
str = str || ''
if (lastStr !== str) {
try {
lastValue = superjson.parse(str)
} catch {
return initialValue
}
lastStr = str
}
return lastValue
}
const str = getStringStorage()?.getItem(key) ?? null
if (isPromiseLike(str)) {
return str.then(parse) as never
}
return parse(str) as never
},
setItem: (key, newValue) =>
getStringStorage()?.setItem(
key,
superjson.stringify(newValue),
),
removeItem: (key) => getStringStorage()?.removeItem(key),
}
const createHandleSubscribe =
(subscriber: StringSubscribe): Subscribe<Value> =>
(key, callback, initialValue) =>
subscriber(key, (v) => {
let newValue: Value
try {
newValue = superjson.parse(v || '')
} catch {
newValue = initialValue
}
callback(newValue)
})
let subscriber: StringSubscribe | undefined
try {
subscriber = getStringStorage()?.subscribe
} catch {
// ignore
}
if (
!subscriber &&
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function' &&
window.Storage
) {
subscriber = (key, callback) => {
if (!(getStringStorage() instanceof window.Storage)) {
return () => {}
}
const storageEventCallback = (e: StorageEvent) => {
if (e.storageArea === getStringStorage() && e.key === key) {
callback(e.newValue)
}
}
window.addEventListener('storage', storageEventCallback)
return () => {
window.removeEventListener('storage', storageEventCallback)
}
}
}
if (subscriber) {
storage.subscribe = createHandleSubscribe(subscriber)
}
return storage
}
export const superJsonStorage = createSuperjsonStorage()

View File

@ -4,6 +4,9 @@
"target": "esnext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"paths": {
"@/*": ["./src/*"]
},
"types": [ "node" ],
"allowJs": true,
"skipLibCheck": true,

View File

@ -1,9 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
// with options

View File

@ -15,6 +15,18 @@
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
"@ark/schema@0.46.0":
version "0.46.0"
resolved "https://registry.yarnpkg.com/@ark/schema/-/schema-0.46.0.tgz#81a1a0dc1ff0f2faa098cba05de505a174bdc64e"
integrity sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==
dependencies:
"@ark/util" "0.46.0"
"@ark/util@0.46.0":
version "0.46.0"
resolved "https://registry.yarnpkg.com/@ark/util/-/util-0.46.0.tgz#aee240bdaf413793e5ca4c4e8e3707aa965f4be3"
integrity sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.24.7":
version "7.24.7"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465"
@ -1989,6 +2001,18 @@
dependencies:
"@tanstack/query-core" "5.76.0"
"@tanstack/react-table@^8.21.3":
version "8.21.3"
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b"
integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==
dependencies:
"@tanstack/table-core" "8.21.3"
"@tanstack/table-core@8.21.3":
version "8.21.3"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c"
integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==
"@tybys/wasm-util@^0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
@ -2044,6 +2068,13 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/node@^22.15.18":
version "22.15.18"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.18.tgz#2f8240f7e932f571c2d45f555ba0b6c3f7a75963"
integrity sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==
dependencies:
undici-types "~6.21.0"
"@types/parse-json@^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
@ -2355,6 +2386,14 @@ aria-query@~5.1.3:
dependencies:
deep-equal "^2.0.5"
arktype@^2.1.20:
version "2.1.20"
resolved "https://registry.yarnpkg.com/arktype/-/arktype-2.1.20.tgz#dd46726b0faf23c2753369876c77bb037e7089d9"
integrity sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==
dependencies:
"@ark/schema" "0.46.0"
"@ark/util" "0.46.0"
array-buffer-byte-length@^1.0.0, array-buffer-byte-length@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f"
@ -4554,6 +4593,11 @@ jiti@^2.4.2:
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
jotai-optics@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/jotai-optics/-/jotai-optics-0.4.0.tgz#aad207090a06390e61cc85324b623b2692e2e272"
integrity sha512-osbEt9AgS55hC4YTZDew2urXKZkaiLmLqkTS/wfW5/l0ib8bmmQ7kBXSFaosV6jDDWSp00IipITcJARFHdp42g==
jotai-tanstack-query@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/jotai-tanstack-query/-/jotai-tanstack-query-0.9.0.tgz#f3c4f96c2ec88f6fdd0b39a1d2eca6a10a8878d6"
@ -5065,6 +5109,11 @@ once@^1.4.0:
dependencies:
wrappy "1"
optics-ts@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/optics-ts/-/optics-ts-2.4.1.tgz#de94bda2b0ed7fde5b7631283031b9699459d40d"
integrity sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ==
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
@ -5274,6 +5323,11 @@ react-dom@^19.1.0:
dependencies:
scheduler "^0.26.0"
react-icons@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.5.0.tgz#8aa25d3543ff84231685d3331164c00299cdfaf2"
integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -6108,6 +6162,11 @@ unbox-primitive@^1.1.0:
has-symbols "^1.1.0"
which-boxed-primitive "^1.1.1"
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"