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">
<LoginWidget/>
</div>
<div className="flex flex-row mx-auto p-4 gap-8 w-full h-full">
<div className="flex flex-col">
<LoginWidget/>
<CharacterRoulette/>
</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 {
@ -136,24 +135,24 @@ export const CharacterRoulette = ()=>{
return <PleaseLogin/>
}
const searchResults = fuse.search(search || "!-----", {
limit: 20,
}).map((x)=>{
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} />
</div>
})
limit: 20,
}).map((x)=>{
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} />
</div>
})
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,13 +1,22 @@
@import 'tailwindcss';
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
html {
cursor: url(/public/cursor.png), auto !important;
}
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@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
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,

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>
<QueryClientProvider client={queryClient}>
<AppContext>
<App />
</AppContext>
</QueryClientProvider>
<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"