Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

66 changed files with 3173 additions and 14250 deletions

View File

@ -1,8 +0,0 @@
dist
dist/**
**/vendor/**
**/locales/**
generated.*
node_modules
*.min.js
*.min.css

View File

@ -1,26 +0,0 @@
# flyctl launch added from .gitignore
# Logs
**/logs
**/*.log
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*
**/lerna-debug.log*
**/node_modules
**/dist
**/dist-ssr
**/*.local
# Editor directories and files
**/.vscode/*
!**/.vscode/extensions.json
**/.idea
**/.DS_Store
**/*.suo
**/*.ntvs*
**/*.njsproj
**/*.sln
**/*.sw?
fly.toml

View File

@ -1,74 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Essential Commands
### Development
```bash
yarn dev # Start Vite development server on port 5173
yarn preview # Preview production build locally
```
### Build & Deploy
```bash
yarn build # Create production build with Vite
make build # Build Docker image (tuxpa.in/a/lto:v0.0.2)
make push # Push Docker image to registry
```
### Code Quality
```bash
yarn lint # Check code with Biome
yarn lint:fix # Auto-fix linting issues
yarn format # Format code with Biome
```
## Architecture Overview
This is a React-based inventory management system for the game "Trickster Online" via the lifeto.co platform.
### Key Technologies
- **React 19** with TypeScript
- **Vite** for bundling and dev server
- **Jotai** for atomic state management
- **TanStack Query** for server state and caching
- **Tailwind CSS** for styling
- **Axios** for HTTP requests
### Core Architecture
1. **State Management Pattern**:
- Jotai atoms in `src/state/atoms.ts` handle all application state
- Uses `atomWithQuery` for server data integration
- Persistent storage via `atomWithStorage` with superjson serialization
- Actions are implemented as write-only atoms (e.g., `doLoginAtom`, `orderManagerAtom`)
2. **API Integration**:
- All API calls go through `LTOApi` interface (`src/lib/lifeto/api.ts`)
- Token-based authentication via `TokenSession`
- Development: Vite proxy to `https://beta.lifeto.co`
- Production: Caddy reverse proxy configuration
3. **Component Structure**:
- Entry: `src/index.tsx``App.tsx`
- Main sections: Login, Character Selection, Inventory Management
- Components follow atomic design with clear separation of concerns
4. **Business Logic**:
- Domain models in `src/lib/trickster.ts` (Character, Item, Inventory)
- Order management via `OrderManager` class for item transfers
- Item filtering uses Fuse.js for fuzzy search
5. **Data Flow**:
```
User Action → Component → Jotai Action Atom → API Call →
Server Response → Query Cache → Atom Update → UI Re-render
```
### Development Notes
- The app uses a proxy setup to avoid CORS issues with the lifeto.co API
- All API responses are strongly typed with TypeScript interfaces
- State persistence allows users to maintain their session and preferences
- The inventory system supports multi-character management with bulk operations

View File

@ -1,31 +0,0 @@
{
admin off
log {
include http.log.access http.handlers.reverse_proxy
output stdout
format console
level debug
}
}
:{$PORT:8080} {
root * {$ROOT:./dist}
file_server
# Proxy requests with "/lifeto" prefix to the remote server
handle /lifeto/* {
uri strip_prefix /lifeto
reverse_proxy https://beta.lifeto.co {
header_up Host {upstream_hostport}
header_up X-Forwarded-For {remote_host}
header_up User-Agent "LifetoShop/1.0"
header_down -Connection
header_down -Keep-Alive
header_down -Proxy-Authenticate
header_down -Proxy-Authorization
header_down -Te
header_down -Trailers
header_down -Transfer-Encoding
header_down -Upgrade
}
}
}

View File

@ -1,13 +1,19 @@
FROM node:24-alpine as NODEBUILDER FROM golang:1.18.2-alpine as GOBUILDER
WORKDIR /wd WORKDIR /wd
# Copy only package files first for better caching COPY go.mod go.sum ./
COPY package.json yarn.lock ./ COPY app ./app
RUN corepack yarn install RUN go mod tidy
# Copy source code after dependencies are installed RUN go build -o app.exe ./app
COPY . .
RUN corepack yarn build
FROM caddy:2.10-alpine FROM node:18.1-alpine as NODEBUILDER
WORKDIR /wd WORKDIR /wd
COPY Caddyfile /etc/caddy/Caddyfile COPY . .
RUN npm install
RUN npx vite build
FROM alpine:3.16
WORKDIR /wd
COPY --from=GOBUILDER /wd/app.exe app.exe
COPY --from=NODEBUILDER /wd/dist dist COPY --from=NODEBUILDER /wd/dist dist
ENTRYPOINT [ "/wd/app.exe" ]

View File

@ -1,9 +1,6 @@
VERSION=v0.0.2
build: build:
docker build -t tuxpa.in/a/lto:${VERSION} . docker build -t cr.aaaaa.news/lto:latest .
push: push:
docker push tuxpa.in/a/lto:${VERSION} docker push cr.aaaaa.news/lto:latest

113
app/main.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"io"
"log"
"net"
"net/http"
"net/url"
"path"
"strings"
"gfx.cafe/open/gun"
)
var Config struct {
Port string
Root string
Remote string
}
func init() {
gun.Load(&Config)
if Config.Port == "" {
Config.Port = "8080"
}
if Config.Root == "" {
Config.Root = "dist"
}
if Config.Remote == "" {
Config.Remote = "https://beta.lifeto.co"
}
Config.Root = path.Clean(Config.Root)
}
var hopHeaders = []string{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te",
"Trailers",
"Transfer-Encoding",
"Upgrade",
}
func appendHostToXForwardHeader(header http.Header, host string) {
if prior, ok := header["X-Forwarded-For"]; ok {
host = strings.Join(prior, ", ") + ", " + host
}
header.Set("X-Forwarded-For", host)
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
func delHopHeaders(header http.Header) {
for _, h := range hopHeaders {
header.Del(h)
}
}
type Handler struct {
p ProxyHandler
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/lifeto") {
http.StripPrefix("/lifeto", &h.p).ServeHTTP(w, r)
return
}
h.handleSite(w, r)
}
type ProxyHandler struct {
c http.Client
}
func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.RequestURI = ""
if clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
appendHostToXForwardHeader(r.Header, clientIP)
}
r.URL, _ = url.Parse(Config.Remote + r.URL.Path)
r.Host = r.URL.Host
resp, err := h.c.Do(r)
if err != nil {
http.Error(w, "Server Error", http.StatusInternalServerError)
log.Println("ServeHTTP:", err)
return
}
defer resp.Body.Close()
delHopHeaders(resp.Header)
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (h *Handler) handleSite(w http.ResponseWriter, r *http.Request) {
http.FileServer(http.Dir(Config.Root)).ServeHTTP(w, r)
}
func main() {
log.Printf("starting with config: %+v", Config)
http.ListenAndServe(":"+Config.Port, &Handler{})
}

View File

@ -1,60 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"files": {
"ignoreUnknown": true,
"includes": ["src/**/*.{ts,tsx,js,jsx}"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noUselessElse": "error",
"useConst": "warn",
"useImportType": "off",
"useNodejsImportProtocol": "off"
},
"suspicious": {
"noConsole": "error",
"noRedeclare": "off",
"noDoubleEquals": "warn",
"noExplicitAny": "off"
},
"correctness": {
"noUndeclaredVariables": "off",
"useExhaustiveDependencies": "off",
"noUnusedImports": "warn"
},
"complexity": {
"noExtraBooleanCast": "warn",
"noBannedTypes": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
},
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "all",
"arrowParentheses": "asNeeded"
}
},
"json": {
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
}
}
}

View File

@ -1,22 +0,0 @@
# fly.toml app configuration file generated for lifeto on 2025-06-23T01:16:55-05:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'lifeto'
primary_region = 'ord'
[build]
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1

View File

@ -4,10 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lto inventory</title> <title>Vite App</title>
</head> </head>
<body style="overflow-y: hidden;" class="w-screen h-screen"> <body style="overflow-y: hidden;">
<div id="app" class="w-full h-full"></div> <div id="app"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@ -2,58 +2,29 @@
"name": "lifeto-shop", "name": "lifeto-shop",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview"
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
}, },
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.27.8", "@handsontable/vue3": "^12.0.1",
"@handsontable/react": "^15.3.0", "@types/qs": "^6.9.7",
"@mantine/hooks": "^8.0.0", "@types/uuid": "^8.3.4",
"@tailwindcss/vite": "^4.1.10", "@vueuse/core": "^8.7.5",
"@tanstack/react-query": "^5.76.0", "axios": "^0.27.2",
"@tanstack/react-table": "^8.21.3", "handsontable": "^12.0.1",
"@types/qs": "^6.9.18", "loglevel": "^1.8.0",
"@types/react": "^19.1.4", "pinia": "^2.0.14",
"@types/react-dom": "^19.1.5", "qs": "^6.10.5",
"@types/uuid": "^10.0.0", "typescript-cookie": "^1.0.4",
"@vitejs/plugin-react": "^4.4.1", "uuid": "^8.3.2",
"arktype": "^2.1.20", "vue": "^3.2.25"
"axios": "^1.9.0",
"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",
"uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.0.0", "@vitejs/plugin-vue": "^2.3.3",
"@tailwindcss/postcss": "^4.1.6", "typescript": "^4.5.4",
"@types/node": "^22.15.18", "vite": "^2.9.9",
"postcss": "^8.5.3", "vue-tsc": "^0.34.7"
"tailwindcss": "^4.1.6", }
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 B

View File

@ -1,44 +0,0 @@
import { FC } from 'react'
import { CharacterRoulette } from './components/characters'
import { Inventory } from './components/inventory/index'
import { LoginWidget } from './components/login'
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 max-w-64">
<LoginWidget />
<CharacterRoulette />
</div>
<div className="flex-1">
<Inventory />
</div>
</div>
</>
)
}
/*
<div className="flex flex-col p-4 h-full">
<div className="grid grid-cols-6 gap-x-4">
<div className="col-span-5 h-full">
<CharacterRoulette/>
</div>
<div className="col-span-1">
<div className="flex flex-col border border-gray-400">
<LoginWidget/>
</div>
</div>
</div>
<div className="grid grid-cols-6 h-full">
<div className="col-span-1">
</div>
<div className="col-span-5 h-full">
<div className="overflow-hidden h-5/6">
<Inventory/>
</div>
</div>
</div>
</div>
*/

106
src/App.vue Normal file
View File

@ -0,0 +1,106 @@
<script setup lang="ts">
import CharacterInventory from "./components/CharacterInventory.vue"
import Login from "./pages/login.vue"
import CharacterRoulette from "./components/CharacterRoulette.vue";
import Sidebar from "./components/Sidebar.vue";
import { loadStore } from "./state/state";
import OrderDisplay from "./components/OrderDisplay.vue";
loadStore()
</script>
<template>
<div class="parent">
<div class="splash">
<Login />
</div>
<div class="main">
<CharacterInventory />
</div>
<div class="sidebar">
<Sidebar/>
</div>
<div class="select">
<CharacterRoulette />
</div>
<div class="login">
<OrderDisplay/>
</div>
</div>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
height: 100vh;
}
.handsontable th {
border-right: 0px !important;
border-left: 0px !important;
border-top: 1px white !important;
border-bottom: 1px white !important;
line-height: 0 !important;
vertical-align: middle !important;
text-align: left !important;
min-width: 10px !important;
width: 20px !important;
}
.handsontable td {
border-right: 0px !important;
border-left: 0px !important;
border-top: 1px white !important;
border-bottom: 1px white !important;
background-color: #F7F7F7 !important;
vertical-align: middle !important;
text-align: left !important;
}
.handsontable tr {
border-radius: 10px !important;
}
.handsontable .changeType {
margin: 0px !important;
border:0px !important;
float: none !important;
}
</style>
<style>
.main {
overflow-x: scroll;
overflow-y: hidden;
}
.sidebar {
overflow-y: scroll;
}
.parent {
display: grid;
height: 100%;
grid-template-columns: 1fr 4fr 3fr;
grid-template-rows: 1fr repeat(2, 3fr) 1fr;
grid-column-gap: 0px;
grid-row-gap: 0px;
overflow: hidden;
display: scroll;
}
.splash {
grid-area: 1 / 1 / 2 / 2;
}
.main{
grid-area: 2 / 2 / 5 / 4;
}
.sidebar {
grid-area: 2 / 1 / 5 / 2;
}
.selection {
grid-area: 1 / 2 / 2 / 3;
}
.login {
grid-area: 1 / 3 / 2 / 4;
}
</style>

35
src/ColumnCheckbox.vue Normal file
View File

@ -0,0 +1,35 @@
<template>
<input type="checkbox" id={{props.colname}} v-model="checked" />
<label for={{props.colname}}>{{(props.label ? props.label : Columns[props.colname].displayName)}}</label>
</template>
<script lang="ts" setup>
const props = defineProps(["colname","label"])
const {columns} = useStoreRef()
const checked = ref(columns.value.has(props.colname))
watch(columns.value.dirty,()=>{
console.log("changed")
if(columns.value.has(props.colname)) {
checked.value = true
}else{
checked.value = false
}
},{deep:true})
watch(checked, ()=>{
if(checked.value === true) {
columns.value.add(props.colname)
return
}
if(checked.value === false) {
columns.value.delete(props.colname)
return
}
})
</script>
<script lang="ts">
import { defineProps, ref, watch } from 'vue';
import { useStoreRef } from '../state/state';
import { ColumnName, Columns } from '../lib/columns';
</script>

View File

@ -0,0 +1,100 @@
<template>
<div
:class="'cc_parent cc_' + job"
v-on:click="selectCharacter()"
>
<div class="cc_div1">
<span v-html="job" />
</div>
<div class="cc_div2">name: <br/> items: </div>
<div class="cc_div3"> {{name}} <br> {{items}} </div>
<div class="cc_div4">
{{galders.toLocaleString()}}g
</div>
<div class="cc_div5">
</div>
<div class="cc_div6">
{{activeTable == props.character ? "**" :""}}
</div>
<div class="cc_div7">
{{currentChar?.path.split("/")[0]}}
</div>
</div>
</template>
<script lang="ts" setup>
const session = storage.GetSession()
const api:LTOApi = getLTOState(LTOApiv0, session, useStoreRef())
const props = defineProps(['character'])
const name = ref("")
const job = ref("")
const items = ref(0)
const galders = ref(0)
const {invs, activeTable, chars} = useStoreRef()
watch(invs.value,()=>{
const currentInv = invs.value.get(props.character)
if(currentInv){
if(currentInv.galders){
galders.value = currentInv.galders
}
items.value = Object.values(currentInv.items).length
}
},{deep:true})
const currentChar = chars.value.get(props.character)
if(currentChar){
name.value = currentChar.name!
job.value = JobNumberToString(currentChar.current_job)
}
const selectCharacter = () => {
activeTable.value = props.character
api.GetInventory(props.character)
}
</script>
<script lang="ts">
import { defineProps, ref, watch} from 'vue';
import { getLTOState, LTOApi, LTOApiv0 } from '../lib/lifeto';
import { JobNumberToString } from '../lib/trickster';
import { storage } from '../session_storage';
import { useStoreRef } from '../state/state';
</script>
<style>
.cc_parent {
border: 1px black solid;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-rows: repeat(7, 1fr);
grid-column-gap: 0px;
grid-row-gap: 0px;
margin-right: 5px;
margin-left: 5px;
width: 165px;
font-size: 14px;
}
.cc_div1 { grid-area: 1 / 1 / 5 / 6; }
.cc_div2 {
text-align: left;
grid-area: 5 / 2 / 6 / 3; }
.cc_div3 {
text-align: right;
grid-area: 5 / 4 / 6 / 5; }
.cc_div4 {
text-align: right;
grid-area: 6 / 4 / 7 / 5; }
.cc_div5 { grid-area: 1 / 1 / 8 / 6; }
.cc_div6 { grid-area: 7 / 1 / 8 / 3; }
.cc_div7 {
font-size: 12px;
text-align:right;
padding-right: 8px;
grid-area: 7 / 4 / 8 / 6;
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<button
type="button"
id="logoutButton"
v-on:click="send_orders()"
>ayy lmao button</button>
<HotTable
ref="hotTableComponent"
:settings="DefaultSettings()"
></HotTable>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { HotTable, HotColumn } from '@handsontable/vue3';
const storeRefs = useStoreRef()
const {invs, activeTable, columns, tags, dirty, chars, currentSearch, orders} = storeRefs
const hotTableComponent = ref<any>(null)
const hott = ():Handsontable =>{
return hotTableComponent.value.hotInstance as any
}
const session = storage.GetSession()
const api:LTOApi = getLTOState(LTOApiv0, session, useStoreRef())
const manager = new OrderSender(storeRefs)
const updateTable = ():TableRecipe | undefined => {
if (invs.value.has(activeTable.value)) {
const chardat = invs.value.get(activeTable.value)
if (chardat) {
const it = new InventoryTable(chardat, {
columns: columns.value,
tags: tags.value,
accounts: Array.from(chars.value.keys()),
} as InventoryTableOptions)
const build = it.BuildTable()
hott().updateSettings(build.settings)
return build
}
}
return undefined
}
watch(currentSearch, ()=>{
filterTable()
})
const send_orders = () => {
if(hott()) {
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: origin.value,
target_path: target,
}
pending.push(info)
}
}catch(e){
}
}
log.debug("OrderDetails", pending)
for(const d of pending){
const order = manager.send(d)
order.tick(storeRefs, api)
}
saveStore();
}
}
onMounted(()=>{
window.setInterval(tick_orders, 1000)
})
const tick_orders = () => {
if(orders && storeRefs && api){
orders.value.tick(storeRefs, api)
}
}
const filterTable = () => {
if(hott()){
const fp = hott().getPlugin('filters')
fp.removeConditions(2)
fp.addCondition(2,'contains', [currentSearch.value])
fp.filter()
}
}
// register Handsontable's modules
registerAllModules();
watch([columns.value.dirty, tags.value.dirty, activeTable, dirty], () => {
log.debug(`${dirty.value} rendering inventory`, activeTable.value)
let u = updateTable()
saveStore()
})
</script>
<script lang="ts">
import { defineComponent, computed, PropType, defineProps, defineEmits, watch} from 'vue';
import { registerAllModules } from 'handsontable/registry';
import { DefaultSettings, InventoryTable, InventoryTableOptions, TableRecipe } from '../lib/table';
import { Columns, ColumnByNames, ColumnInfo } from '../lib/columns';
import { TricksterItem} from '../lib/trickster';
import Handsontable from 'handsontable';
import { useStoreRef, saveStore } from '../state/state';
import { storage } from '../session_storage';
import { getLTOState, LTOApi, LTOApiv0 } from '../lib/lifeto';
import log, { info } from 'loglevel';
import { OrderDetails, OrderSender } from '../lib/lifeto/order_manager';
</script>
<style src="handsontable/dist/handsontable.full.css">
</style>

View File

@ -0,0 +1,60 @@
<template>
<div id="character_roulette">
<div class="single_character_card" v-for="v in characters">
<CharacterCard :character="v" />
</div>
</div>
</template>
<script lang="ts" setup>
import CharacterCard from './CharacterCard.vue';
const {accs, chars, invs, activeTable } = useStoreRef()
const characters = ref([] as string[])
watch(chars, () => {
characters.value = [...new Set([...characters.value, ...invs.value.keys()])]
}, { deep: true })
const session = storage.GetSession()
const api:LTOApi = getLTOState(LTOApiv0, session, useStoreRef())
api.GetAccounts().then(xs => {
xs.forEach(x => {
characters.value.push(...x.characters.map(x=>x.path))
accs.value.set(x.name, x)
})
characters.value = [...new Set([...characters.value])]
saveStore();
})
onMounted(()=>{
let val = invs.value.get(activeTable.value)
if(!val || Object.values(val.items).length == 0) {
api.GetInventory(activeTable.value)
}
})
</script>
<script lang="ts">
import { ref, watch, onMounted } from 'vue';
import { getLTOState, LTOApi, LTOApiv0 } from '../lib/lifeto';
import { storage } from '../session_storage';
import { saveStore, useStoreRef } from '../state/state';
</script>
<style>
#character_roulette {
display: flex;
flex-direction: row;
justify-content: normal;
align-items: center;
overflow-x: scroll;
width: 1000px;
}
.single_character_card {
display: flex;
}
</style>

View File

@ -0,0 +1,34 @@
<template>
<input type="checkbox" id={{props.colname}} v-model="checked" />
<label for={{props.colname}}>{{(props.label ? props.label : Columns[props.colname].displayName)}}</label>
</template>
<script lang="ts" setup>
const props = defineProps(["colname","label"])
const {columns} = useStoreRef()
const checked = ref(columns.value.has(props.colname))
watch(columns.value.dirty,()=>{
if(columns.value.has(props.colname)) {
checked.value = true
}else{
checked.value = false
}
},{deep:true})
watch(checked, ()=>{
if(checked.value === true) {
columns.value.add(props.colname)
return
}
if(checked.value === false) {
columns.value.delete(props.colname)
return
}
})
</script>
<script lang="ts">
import { defineProps, ref, watch } from 'vue';
import { useStoreRef } from '../state/state';
import { ColumnName, Columns } from '../lib/columns';
</script>

View File

@ -0,0 +1,122 @@
<template>
<input
type="checkbox"
class="css-checkbox"
:id="'toggle-'+props.header"
v-model="show"
/>
<label :for="'toggle-'+props.header" class="css-label">
<span class="fa fa-plus">+</span>
<span class="fa fa-minus">-</span>
</label>
<label :for="'checkbox-'+props.header">{{props.header}}</label>
<input type="checkbox" :id="'checkbox-'+props.header" v-model="checked" />
<br>
<div
class="checkbox_parent"
:style="'grid-template-rows: repeat('+Math.ceil(props.columns.length/2+2)+', 1fr);'"
v-if="show"
>
<div
v-for="(item, index) in props.columns"
class="checkbox_child"
>
<ColumnCheckbox :colname="item"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import ColumnCheckbox from './ColumnCheckbox.vue';
const props = defineProps<{
header: string
columns: ColumnName[]
default?: boolean
}>()
const {columns} = useStoreRef()
const checked = ref(props.default)
const show = ref(true)
watch(show,()=>{
})
watch(checked,()=>{
if(checked.value === true) {
props.columns.forEach(x=>columns.value.add(x))
return
}
if(checked.value === false) {
props.columns.forEach(x=>columns.value.delete(x))
return
}
})
</script>
<script lang="ts">
import { defineProps, ref, watch } from 'vue';
import { useStoreRef } from '../state/state';
import { ColumnName } from '../lib/columns';
</script>
<style>
.checkbox_parent {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 0px;
grid-row-gap: 0px;
}
.checkbox_child {
align-content: left;
justify-self: left;
}
.css-label {
cursor: pointer;
}
.css-checkbox {
display: none;
}
.fa {
color: white;
border-radius: 4px;
padding-top: 0px;
padding-right: 4px;
padding-left: 4px;
padding-bottom: 0px;
}
.fa-plus {
background-color: #E85764;
}
.fa-minus {
background-color: #3AC5C9;
display: none;
}
.css-checkbox:checked + .css-label .fa-minus {
display: inline;
}
.css-checkbox:checked + .css-label .fa-plus {
display: none;
}
</style>

View File

@ -0,0 +1,35 @@
<template>
<input type="checkbox" id={{props.colname}} v-model="checked" />
<label for={{props.colname}}>{{(props.label ? props.label : Columns[props.colname].displayName)}}</label>
</template>
<script lang="ts" setup>
const props = defineProps(["colname","label"])
const {tags} = useStoreRef()
const checked = ref(tags.value.has(props.colname))
watch(tags.value.dirty,()=>{
console.log("changed")
if(tags.value.has(props.colname)) {
checked.value = true
}else{
checked.value = false
}
},{deep:true})
watch(checked, ()=>{
if(checked.value === true) {
tags.value.add(props.colname)
return
}
if(checked.value === false) {
tags.value.delete(props.colname)
return
}
})
</script>
<script lang="ts">
import { defineProps, ref, watch } from 'vue';
import { useStoreRef } from '../state/state';
import { ColumnName, Columns } from '../lib/columns';
</script>

View File

@ -0,0 +1,123 @@
<template>
<input
type="checkbox"
class="css-checkbox"
:id="'toggle-'+props.header"
v-model="show"
/>
<label :for="'toggle-'+props.header" class="css-label">
<span class="fa fa-plus">+</span>
<span class="fa fa-minus">-</span>
</label>
<label :for="'checkbox-'+props.header">{{props.header}}</label>
<input type="checkbox" :id="'checkbox-'+props.header" v-model="checked" />
<br>
<div
class="checkbox_parent"
:style="'grid-template-rows: repeat('+Math.ceil(props.columns.length/2+2)+', 1fr);'"
v-if="show"
>
<div
v-for="(item, index) in props.columns"
class="checkbox_child"
>
<FilterCheckbox :colname="item"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import FilterCheckbox from './FilterCheckbox.vue';
const props = defineProps<{
header: string
columns: ColumnName[]
}>()
const {tags} = useStoreRef()
const checked = ref(false)
const show = ref(true)
watch(show,()=>{
})
watch(checked,()=>{
if(checked.value === true) {
props.columns.forEach(x=>tags.value.add(x))
return
}
if(checked.value === false) {
props.columns.forEach(x=>tags.value.delete(x))
return
}
})
</script>
<script lang="ts">
import { defineProps, ref, watch } from 'vue';
import { useStoreRef } from '../state/state';
import { ColumnName } from '../lib/columns';
</script>
<style>
.checkbox_parent {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 0px;
grid-row-gap: 0px;
}
.checkbox_child {
align-content: left;
justify-self: left;
}
.css-label {
cursor: pointer;
}
.css-checkbox {
display: none;
}
.fa {
color: white;
border-radius: 4px;
padding-top: 0px;
padding-right: 4px;
padding-left: 4px;
padding-bottom: 0px;
}
.fa-plus {
background-color: #E85764;
}
.fa-minus {
background-color: #3AC5C9;
display: none;
}
.css-checkbox:checked + .css-label .fa-minus {
display: inline;
}
.css-checkbox:checked + .css-label .fa-plus {
display: none;
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<div id="order-display">
<div id="order-titlebar"></div>
<table>
<tr
v-for="v in orders.orders"
:key="dirty"
>
<td>{{v.action_id}}</td>
<td>[{{v.progress()[0]}} / {{v.progress()[1]}}]</td>
<td>{{v.order_type}}</td>
<td>{{v.state}}</td>
<td>{{(((new Date()).getTime() - new Date(v.created).getTime())/(60 *1000)).toFixed(0)}} min ago</td>
<td>
<button
type="button"
id="logoutButton"
v-on:click="tick_order(v.action_id)"
>tick</button>
</td>
</tr>
</table>
</div>
</template>
<script lang="ts" setup>
const storeRefs = useStoreRef()
const {orders, dirty} = storeRefs;
const session = storage.GetSession()
const api:LTOApi = getLTOState(LTOApiv0, session, useStoreRef())
const tick_order = (action_id:string)=> {
const deet = orders.value.orders[action_id]
console.log(deet)
if(deet){
deet.tick(storeRefs, api)
}else {
console.log(`tried to send ${action_id} but undefined`)
}
}
onMounted(()=>{
dragElement(document.getElementById("order-display"));
function dragElement(elmnt:any) {
var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
document.getElementById("order-titlebar")!.onmousedown = dragMouseDown;
function dragMouseDown(e:any) {
e = e || window.event;
e.preventDefault();
// get the mouse cursor position at startup:
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
// call a function whenever the cursor moves:
document.onmousemove = elementDrag;
}
function elementDrag(e:any) {
e = e || window.event;
e.preventDefault();
// calculate the new cursor position:
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
// set the element's new position:
elmnt.style.top = (elmnt.offsetTop - pos2) + "px";
elmnt.style.left = (elmnt.offsetLeft - pos1) + "px";
}
function closeDragElement() {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
}
})
</script>
<script lang="ts">
import { onMounted, watch } from 'vue';
import { getLTOState, LTOApi, LTOApiv0 } from '../lib/lifeto';
import { storage } from '../session_storage';
import { useStoreRef } from '../state/state';
</script>
<style>
#order-display{
position: absolute;
z-index: 9;
background-color: #f1f1f1;
border: 1px solid #d3d3d3;
text-align: center;
width: 300px;
}
#order-titlebar {
padding: 10px;
cursor: move;
z-index: 10;
background-color: #2196F3;
color: #fff;
}
</style>

View File

@ -0,0 +1,34 @@
<template>
search:
<div class="filter_field">
<input
type="text"
id="searchbox"
v-model="currentSearch"
/>
</div>
<br>
<FilterCheckboxGroup :header="'tags:'" :columns="[...TagColumns]" />
<br>
Columns:
<br>
<ColumnCheckboxGroup :header="'action:'" :columns="[...MoveColumns]" :default="true" />
<ColumnCheckboxGroup :header="'details:'" :columns="[...DetailsColumns]" :default="true"/>
<ColumnCheckboxGroup :header="'equipment:'" :columns="[...EquipmentColumns]" />
<ColumnCheckboxGroup :header="'stats:'" :columns="[...StatsColumns]" />
<ColumnCheckboxGroup :header="'debug:'" :columns="[...DebugColumns]" />
</template>
<script lang="ts" setup>
import ColumnCheckboxGroup from './ColumnCheckboxGroup.vue';
import FilterCheckboxGroup from './FilterCheckboxGroup.vue';
import { DebugColumns, StatsColumns, MoveColumns, TagColumns, EquipmentColumns, DetailsColumns } from '../lib/columns';
const { currentSearch} = useStoreRef()
</script>
<script lang="ts">
import { useStoreRef } from '../state/state';
</script>

View File

@ -1,260 +0,0 @@
import {
autoUpdate,
FloatingPortal,
flip,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useRole,
} from '@floating-ui/react'
import Fuse from 'fuse.js'
import { useAtom, useAtomValue } from 'jotai'
import { useMemo, useState } from 'react'
import { TricksterCharacter } from '../lib/trickster'
import { charactersAtom, selectedCharacterAtom } from '../state/atoms'
export const CharacterCard = ({ character, noTopBorder = false }: { character: TricksterCharacter; noTopBorder?: boolean }) => {
const [isOpen, setIsOpen] = useState(false)
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
// Make sure the tooltip stays on the screen
whileElementsMounted: autoUpdate,
middleware: [
offset(5),
flip({
fallbackAxisSideDirection: 'start',
}),
shift(),
],
})
// Event listeners to change the open state
const hover = useHover(context, { move: false })
const focus = useFocus(context)
const dismiss = useDismiss(context)
// Role props for screen readers
const role = useRole(context, { role: 'tooltip' })
// Merge all the interactions into prop getters
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role])
const [selectedCharacter, setSelectedCharacter] = useAtom(selectedCharacterAtom)
const isBank = character.base_job === -8
return (
<>
<button
type="button"
onClick={() => {
setSelectedCharacter(character)
}}
ref={refs.setReference}
{...getReferenceProps()}
className={`
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` : ''}`}
>
<div className="flex flex-col justify-between h-full">
<div className="flex flex-col gap-1 items-center">
<div className="flex flex-row justify-center">
{isBank ? (
<img
className="h-8"
src="https://beta.lifeto.co/item_img/gel.nri.003.000.png"
alt="Gel character"
/>
) : (
<img
className="h-16 bg-gray-200"
src=""
alt={`Character ${character.name}`}
/>
)}
</div>
<div className="text-xs font-semibold text-center">
{isBank ? 'Bank' : character.name}
</div>
<FloatingPortal>
{isOpen && (
<div
className="Tooltip"
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<div className="flex flex-col gap-1 bg-white">
{character.base_job === -8 ? 'bank' : character.name}
</div>
</div>
)}
</FloatingPortal>
</div>
</div>
</button>
</>
)
}
const SelectedCharacterDisplay = () => {
const selectedCharacter = useAtomValue(selectedCharacterAtom)
const [{ data: rawCharacters }] = useAtom(charactersAtom)
if (!selectedCharacter) {
return (
<div className="flex flex-col items-center gap-2 p-4 border border-gray-300 rounded bg-gray-50 h-[140px] justify-center">
<div className="text-sm text-gray-400">No character selected</div>
</div>
)
}
if (selectedCharacter.base_job === -8) {
// Find the character associated with this bank
const characterPair = rawCharacters?.find(pair => pair.bank.id === selectedCharacter.id)
const associatedCharacter = characterPair?.character
return (
<div className="flex flex-col items-center gap-2 p-4 border border-gray-300 rounded bg-gray-50 h-[140px] justify-center">
<div className="text-sm font-bold">Bank for: {associatedCharacter?.name || 'Unknown'}</div>
<div className="flex items-center gap-4">
<img
className="h-24"
src="https://knowledge.lifeto.co/animations/npc/npc041_4.png"
alt="Bank NPC"
/>
{associatedCharacter && (
<img
className="h-24"
src={`https://knowledge.lifeto.co/animations/character/chr${(
associatedCharacter.current_type || associatedCharacter.base_job
)
.toString()
.padStart(3, '0')}_13.png`}
alt={`${associatedCharacter.name} walking`}
/>
)}
</div>
</div>
)
}
return (
<div className="flex flex-col items-center gap-2 p-4 border border-gray-300 rounded bg-gray-50 h-[140px] justify-center">
<div className="text-sm font-bold">Selected: {selectedCharacter.name}</div>
<img
className="h-24"
src={`https://knowledge.lifeto.co/animations/character/chr${(
selectedCharacter.current_type || selectedCharacter.base_job
)
.toString()
.padStart(3, '0')}_13.png`}
alt={`${selectedCharacter.name} walking`}
/>
</div>
)
}
const CharacterRow = ({ characterPair }: { characterPair: { bank: TricksterCharacter; character: TricksterCharacter } }) => {
const [selectedCharacter, setSelectedCharacter] = useAtom(selectedCharacterAtom)
return (
<div className="flex flex-row">
<button
type="button"
onClick={() => setSelectedCharacter(characterPair.bank)}
className={`flex items-center justify-center px-1 py-0.5 border border-black hover:bg-blue-100 ${
characterPair.bank.path === selectedCharacter?.path ? 'bg-blue-200' : ''
}`}
>
<img
src="https://beta.lifeto.co/item_img/gel.nri.003.000.png"
alt="Bank"
className="h-6"
/>
</button>
<button
type="button"
onClick={() => setSelectedCharacter(characterPair.character)}
className={`flex items-center justify-start px-2 py-1 border-l-0 border-t border-r border-b border-black hover:bg-blue-100 flex-1 ${
characterPair.character.path === selectedCharacter?.path ? 'bg-blue-200' : ''
}`}
>
<img
src={`https://beta.lifeto.co/img/job/${characterPair.character.current_type - 1}.png`}
alt="Class icon"
className="h-4 w-4 mr-2"
/>
{characterPair.character.name}
</button>
</div>
)
}
export const CharacterRoulette = () => {
const [{ data: rawCharacters }] = useAtom(charactersAtom)
const [search, setSearch] = useState('')
const { characters, fuse } = useMemo(() => {
if (!rawCharacters) {
return {
characters: [],
fuse: new Fuse([], {}),
}
}
// transform characters into pairs between the bank and not bank
return {
characters: rawCharacters,
fuse: new Fuse(rawCharacters, {
findAllMatches: true,
threshold: 0.8,
useExtendedSearch: true,
keys: ['character.name'],
}),
}
}, [rawCharacters])
// Return nothing when no characters
if (!characters || characters.length === 0) {
return null
}
const searchResults = fuse
.search(search || '!-----', {
limit: 20,
})
.map(x => {
return (
<CharacterRow key={`${x.item.character.account_id}`} characterPair={x.item} />
)
})
return (
<>
<div className="flex flex-col gap-4">
<SelectedCharacterDisplay />
<div className="flex flex-col gap-2">
<input
className="border border-black-1 bg-gray-100 placeholder-gray-600 p-1"
placeholder="search character..."
value={search}
onChange={e => {
setSearch(e.target.value)
}}
/>
<div className="flex flex-col">
{searchResults.length > 0 ? searchResults : (
search ? <div className="text-sm text-gray-500 p-2">No characters matched search</div> : null
)}
</div>
</div>
</div>
</>
)
}

View File

@ -1,207 +0,0 @@
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import {
FloatingPortal,
autoUpdate,
flip,
offset,
shift,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react'
import { inventoryFilterAtom, setInventoryFilterTabActionAtom } from '@/state/atoms'
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' },
]
export const InventoryFilters = () => {
const inventoryFilter = useAtomValue(inventoryFilterAtom)
const setInventoryFilterTab = useSetAtom(setInventoryFilterTabActionAtom)
const [isCardDropdownOpen, setIsCardDropdownOpen] = useState(false)
const sharedStyle = 'hover:cursor-pointer hover:bg-gray-200 px-2 pr-4 border-t border-l border-r border-gray-200'
const selectedStyle = 'bg-gray-200 border-b-2 border-black-1'
const { refs, floatingStyles, context } = useFloating({
open: isCardDropdownOpen,
onOpenChange: setIsCardDropdownOpen,
middleware: [offset(5), flip(), shift()],
whileElementsMounted: autoUpdate,
placement: 'bottom-start',
})
const hover = useHover(context, {
delay: { open: 100, close: 300 },
})
const { getReferenceProps, getFloatingProps } = useInteractions([hover])
// Check if any card section is selected
const isCardSectionSelected = cardSections.some(x => x.value === inventoryFilter.tab)
return (
<div className="flex flex-row gap-1">
{sections.map(x => {
return (
<button
type="button"
onClick={() => {
setInventoryFilterTab(x.value)
}}
key={x.name}
className={`${sharedStyle} ${inventoryFilter.tab === x.value ? selectedStyle : ''}`}
>
<div className="flex items-center gap-1">
{x.value === '' && (
<img
src="https://beta.lifeto.co/item_img/gel.nri.003.000.png"
alt="All"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '1' && (
<img
src="https://beta.lifeto.co/item_img/itm000.nri.00c.000.png"
alt="Consume"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '2' && (
<img
src="https://beta.lifeto.co/item_img/itm_cm_wp_106.nri.000.000.png"
alt="Equip"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '3' && (
<img
src="https://beta.lifeto.co/item_img/dri001.nri.000.000.png"
alt="Drill"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '4' && (
<img
src="https://beta.lifeto.co/item_img/pet_inv001.nri.015.000.png"
alt="Pet"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '5' && (
<img
src="https://beta.lifeto.co/item_img/itm_cm_ear_020.nri.001.000.png"
alt="Etc"
className="w-4 h-4 object-contain"
/>
)}
{x.name}
</div>
</button>
)
})}
<div className="relative">
<button
ref={refs.setReference}
type="button"
className={`${sharedStyle} ${isCardSectionSelected ? selectedStyle : ''}`}
{...getReferenceProps()}
>
<div className="flex items-center gap-1">
<img
src="https://beta.lifeto.co/item_img/card_com_001.nri.000.000.png"
alt="Card"
className="w-4 h-4 object-contain"
/>
card
</div>
</button>
{isCardDropdownOpen && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
className="bg-white border border-gray-300 shadow-lg rounded-md py-1 z-50"
{...getFloatingProps()}
>
{cardSections.map(x => (
<button
key={x.name}
type="button"
onClick={() => {
setInventoryFilterTab(x.value)
setIsCardDropdownOpen(false)
}}
className={`block w-full text-left px-4 py-2 hover:bg-gray-100 ${
inventoryFilter.tab === x.value ? 'bg-gray-200 font-semibold' : ''
}`}
>
<div className="flex items-center gap-2">
{x.value === '10' && (
<img
src="https://beta.lifeto.co/item_img/card_skill_c_202.nri.000.000.png"
alt="Skill"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '11' && (
<img
src="https://beta.lifeto.co/item_img/cardch001.nri.006.000.png"
alt="Character"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '12' && (
<img
src="https://beta.lifeto.co/item_img/cardmo001.nri.019.000.png"
alt="Monster"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '13' && (
<img
src="https://beta.lifeto.co/item_img/card_ftn_001.nri.000.000.png"
alt="Fortune"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '14' && (
<img
src="https://beta.lifeto.co/item_img/card_scr_001.nri.000.000.png"
alt="Secret"
className="w-4 h-4 object-contain"
/>
)}
{x.value === '15' && (
<img
src="https://beta.lifeto.co/item_img/card_ear_002.nri.001.000.png"
alt="Arcana"
className="w-4 h-4 object-contain"
/>
)}
{x.name}
</div>
</button>
))}
</div>
</FloatingPortal>
)}
</div>
</div>
)
}

View File

@ -1,182 +0,0 @@
import {
FloatingFocusManager,
FloatingOverlay,
FloatingPortal,
useClick,
useDismiss,
useFloating,
useInteractions,
useRole,
} from '@floating-ui/react'
import { useAtom, useSetAtom } from 'jotai'
import { useState } from 'react'
import {
clearItemSelectionActionAtom,
closeMoveConfirmationAtom,
type MoveItemsResult,
moveConfirmationAtom,
moveSelectedItemsAtom,
} from '@/state/atoms'
export function MoveConfirmationPopup() {
const [confirmationState] = useAtom(moveConfirmationAtom)
const closeConfirmation = useSetAtom(closeMoveConfirmationAtom)
const moveItems = useSetAtom(moveSelectedItemsAtom)
const clearSelection = useSetAtom(clearItemSelectionActionAtom)
const [isMoving, setIsMoving] = useState(false)
const [moveResult, setMoveResult] = useState<MoveItemsResult | null>(null)
const { refs, context } = useFloating({
open: confirmationState.isOpen,
onOpenChange: open => {
if (!open && !isMoving) {
closeConfirmation()
setMoveResult(null)
}
},
})
const click = useClick(context)
const dismiss = useDismiss(context, {
outsidePressEvent: 'mousedown',
escapeKey: !isMoving,
})
const role = useRole(context)
const { getFloatingProps } = useInteractions([click, dismiss, role])
if (!confirmationState.isOpen) return null
const { selectedItems, sourceCharacter, targetCharacter } = confirmationState
const handleConfirm = async () => {
setIsMoving(true)
try {
const result = await moveItems()
setMoveResult(result)
if (result.failedCount === 0) {
clearSelection()
setTimeout(() => {
closeConfirmation()
setMoveResult(null)
}, 1500)
}
} catch (_error) {
// Error handled in UI
} finally {
setIsMoving(false)
}
}
const handleCancel = () => {
if (!isMoving) {
closeConfirmation()
}
}
const renderItemPreview = () => {
const itemsArray = Array.from(selectedItems.values())
const totalUniqueItems = itemsArray.length
const totalQuantity = itemsArray.reduce((sum, { count }) => sum + count, 0)
if (totalUniqueItems > 5) {
return (
<div className="text-center py-4">
<p className="text-lg font-semibold">Moving {totalUniqueItems} different items</p>
<p className="text-sm text-gray-600">Total quantity: {totalQuantity.toLocaleString()}</p>
</div>
)
}
return (
<div className="space-y-2 max-h-60 overflow-y-auto">
{itemsArray.map(({ item, count }) => (
<div
key={item.id}
className="flex items-center justify-between px-2 py-1 hover:bg-gray-50 rounded"
>
<div className="flex items-center gap-2">
<img
src={item.item_image || ''}
alt={item.item_name}
className="w-6 h-6 object-contain"
/>
<span className="text-sm">{item.item_name}</span>
</div>
<span className="text-sm font-medium text-gray-600">×{count.toLocaleString()}</span>
</div>
))}
</div>
)
}
return (
<FloatingPortal>
<FloatingOverlay className="grid place-items-center bg-black/50 z-50" lockScroll>
<FloatingFocusManager context={context} initialFocus={-1}>
<div
ref={refs.setFloating}
className="bg-white rounded-lg shadow-xl border border-gray-200 p-6 max-w-md w-full mx-4"
{...getFloatingProps()}
>
<h2 className="text-xl font-bold mb-4">Confirm Item Movement</h2>
<div className="mb-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">From:</span>
<span className="font-medium">{sourceCharacter?.name || 'Unknown'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">To:</span>
<span className="font-medium">{targetCharacter?.name || 'Unknown'}</span>
</div>
</div>
<div className="border-t border-b border-gray-200 py-4 mb-4">{renderItemPreview()}</div>
{moveResult && (
<div
className={`mb-4 p-3 rounded ${moveResult.failedCount > 0 ? 'bg-yellow-50' : 'bg-green-50'}`}
>
<p className="text-sm font-medium">
{moveResult.failedCount === 0
? `Successfully moved ${moveResult.successCount} items!`
: `Moved ${moveResult.successCount} of ${moveResult.totalItems} items`}
</p>
{moveResult.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{moveResult.errors.slice(0, 3).map(error => (
<p key={error.itemId}>{error.error}</p>
))}
{moveResult.errors.length > 3 && (
<p>...and {moveResult.errors.length - 3} more errors</p>
)}
</div>
)}
</div>
)}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={handleCancel}
disabled={isMoving}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={isMoving || moveResult !== null}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isMoving ? 'Moving...' : 'Confirm Move'}
</button>
</div>
</div>
</FloatingFocusManager>
</FloatingOverlay>
</FloatingPortal>
)
}

View File

@ -1,188 +0,0 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useEffect } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import {
clearItemSelectionActionAtom,
currentCharacterInventoryAtom,
filteredCharacterItemsAtom,
inventoryPageRangeAtom,
itemSelectionSelectAllFilterActionAtom,
itemSelectionSelectAllPageActionAtom,
moveSelectedItemsAtom,
openMoveConfirmationAtom,
paginateInventoryActionAtom,
preferenceInventorySearch,
selectedCharacterAtom,
} from '@/state/atoms'
import { MoveConfirmationPopup } from './MoveConfirmationPopup'
import { InventoryTargetSelector } from './movetarget'
import { InventoryTable } from './table'
import { InventoryFilters } from './InventoryFilters'
const InventoryRangeDisplay = () => {
const inventoryRange = useAtomValue(inventoryPageRangeAtom)
const items = useAtomValue(filteredCharacterItemsAtom)
return (
<div className="flex items-center px-2 bg-yellow-100 border border-black-1 whitespace-pre select-none">
{inventoryRange.start}..{inventoryRange.end}/{items.length}
</div>
)
}
export const Inventory = () => {
const selectedCharacter = useAtomValue(selectedCharacterAtom)
const clearItemSelection = useSetAtom(clearItemSelectionActionAtom)
const { refetch: refetchInventory } = useAtomValue(currentCharacterInventoryAtom)
const addPageItemSelection = useSetAtom(itemSelectionSelectAllPageActionAtom)
const addFilterItemSelection = useSetAtom(itemSelectionSelectAllFilterActionAtom)
const [search, setSearch] = useAtom(preferenceInventorySearch)
const paginateInventory = useSetAtom(paginateInventoryActionAtom)
const openMoveConfirmation = useSetAtom(openMoveConfirmationAtom)
const moveSelectedItems = useSetAtom(moveSelectedItemsAtom)
// Add keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't paginate if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
paginateInventory(-1)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
paginateInventory(1)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [paginateInventory])
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-col gap-2">
<div className="flex flex-row gap-0 items-center justify-between">
<div className="flex flex-row gap-0 items-stretch">
<button
type="button"
className="hover:cursor-pointer border border-black-1 bg-blue-200 hover:bg-blue-300 px-2 py-1"
onClick={() => {
if (selectedCharacter) {
refetchInventory()
}
}}
title="Refresh inventory"
>
</button>
<input
type="text"
value={search}
className="border border-black-1 px-2 py-1"
placeholder="search..."
onChange={e => {
setSearch(e.target.value)
}}
/>
<button
type="button"
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 flex items-center justify-center"
onClick={() => {
paginateInventory(-1)
}}
aria-label="Previous page"
title="Previous page (← arrow key)"
>
<FaArrowLeft />
</button>
<button
type="button"
className="hover:cursor-pointer border border-black-1 bg-green-200 hover:bg-green-300 px-2 flex items-center justify-center"
onClick={() => {
paginateInventory(1)
}}
aria-label="Next page"
title="Next page (→ arrow key)"
>
<FaArrowRight />
</button>
<InventoryRangeDisplay />
</div>
<div className="flex flex-row gap-0">
<InventoryTargetSelector />
<button
type="button"
onClick={async e => {
if (e.shiftKey) {
// Shift+click: skip confirmation
const result = await moveSelectedItems()
if (result.successCount > 0) {
clearItemSelection()
}
} else {
// Normal click: show confirmation
openMoveConfirmation()
}
}}
className="hover:cursor-pointer whitespace-preborder border-black-1 bg-orange-200 hover:bg-orange-300 px-2 py-1"
title="Click to move with confirmation, Shift+Click to move immediately"
>
Move Selected
</button>
</div>
</div>
<div className="flex flex-row gap-0">
<button
type="button"
className="whitespace-pre bg-purple-200 px-2 py-1 hover:cursor-pointer hover:bg-purple-300 border border-black-1"
onClick={() => {
addFilterItemSelection()
}}
>
select filtered
</button>
<button
type="button"
className="whitespace-pre bg-cyan-200 px-2 py-1 hover:cursor-pointer hover:bg-cyan-300 border border-black-1"
onClick={() => {
addPageItemSelection()
}}
>
select page
</button>
<button
type="button"
className="whitespace-pre bg-red-200 px-2 py-1 hover:cursor-pointer hover:bg-red-300 border border-black-1"
onClick={() => {
clearItemSelection()
}}
>
clear
</button>
</div>
</div>
</div>
<div className="flex flex-row justify-between items-center">
<InventoryFilters />
<InventoryRangeDisplay />
</div>
<div className="flex flex-col flex-1 h-full border border-black-2">
<InventoryTable />
</div>
<MoveConfirmationPopup />
</div>
)
}

View File

@ -1,235 +0,0 @@
import {
autoUpdate,
FloatingFocusManager,
FloatingPortal,
flip,
size,
useDismiss,
useFloating,
useInteractions,
useListNavigation,
useRole,
} from '@floating-ui/react'
import Fuse from 'fuse.js'
import { useAtom, useAtomValue } from 'jotai'
import { forwardRef, useId, useMemo, useRef, useState } from 'react'
import { charactersAtom, selectedCharacterAtom, selectedTargetInventoryAtom } from '@/state/atoms'
interface AccountInventorySelectorItemProps {
children: React.ReactNode
active: boolean
}
const AccountInventorySelectorItem = forwardRef<
HTMLDivElement,
AccountInventorySelectorItemProps & React.HTMLProps<HTMLDivElement>
>(({ children, active, ...rest }, ref) => {
const id = useId()
const isDisabled = rest['aria-disabled']
return (
<div
ref={ref}
// biome-ignore lint/a11y/useSemanticElements: Custom autocomplete component needs role="option"
role="option"
id={id}
aria-selected={active}
aria-disabled={isDisabled}
tabIndex={-1}
{...rest}
style={{
background: active && !isDisabled ? 'lightblue' : 'none',
padding: 4,
cursor: isDisabled ? 'not-allowed' : 'default',
opacity: isDisabled ? 0.5 : 1,
...rest.style,
}}
>
{children}
</div>
)
})
export const InventoryTargetSelector = () => {
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const listRef = useRef<Array<HTMLElement | null>>([])
const { refs, floatingStyles, context } = useFloating<HTMLInputElement>({
whileElementsMounted: autoUpdate,
open,
onOpenChange: setOpen,
middleware: [
flip({ padding: 10 }),
size({
apply({ rects, availableHeight, elements }) {
Object.assign(elements.floating.style, {
width: `${Math.max(rects.reference.width * 2, 400)}px`,
maxHeight: `${availableHeight}px`,
})
},
padding: 10,
}),
],
})
const role = useRole(context, { role: 'listbox' })
const dismiss = useDismiss(context)
const listNav = useListNavigation(context, {
listRef,
activeIndex,
onNavigate: setActiveIndex,
virtual: true,
loop: true,
})
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
role,
dismiss,
listNav,
])
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value
setInputValue(value)
setSelectedTargetInventory(undefined)
if (value) {
setOpen(true)
setActiveIndex(0)
} else {
setOpen(false)
}
}
const { data: subaccounts } = useAtomValue(charactersAtom)
const selectedCharacter = useAtomValue(selectedCharacterAtom)
const [selectedTargetInventory, setSelectedTargetInventory] = useAtom(selectedTargetInventoryAtom)
const searcher = useMemo(() => {
const allInventories = subaccounts?.flatMap(x => [x.bank, x.character]) || []
// Don't filter out current character, we'll disable it in the UI
return new Fuse(allInventories, {
keys: ['path', 'name'],
findAllMatches: true,
threshold: 0.8,
useExtendedSearch: true,
})
}, [subaccounts])
const items = inputValue
? searcher.search(inputValue, { limit: 10 }).map(x => x.item)
: subaccounts?.flatMap(x => [x.bank, x.character]).slice(0, 10) || []
return (
<>
<input
className={`border border-black-1 placeholder-gray-600 ${
selectedTargetInventory ? 'bg-green-100' : inputValue ? 'bg-yellow-200' : 'bg-gray-300'
}`}
{...getReferenceProps({
ref: refs.setReference,
onChange,
value:
selectedTargetInventory !== undefined
? !selectedTargetInventory.path.includes('/')
? `[Bank] ${selectedTargetInventory.account_name}`
: selectedTargetInventory.name
: inputValue,
placeholder: 'Target Inventory',
'aria-autocomplete': 'list',
onFocus() {
setOpen(true)
},
onKeyDown(event) {
if (event.key === 'Enter' && activeIndex != null && items[activeIndex]) {
setSelectedTargetInventory(items[activeIndex])
setInputValue('')
setActiveIndex(null)
setOpen(false)
}
},
})}
/>
{open && (
<FloatingPortal>
<FloatingFocusManager context={context} initialFocus={-1} visuallyHiddenDismiss>
<div
{...getFloatingProps({
ref: refs.setFloating,
style: {
...floatingStyles,
background: '#eee',
color: 'black',
overflowY: 'auto',
},
})}
>
<div style={{ display: 'flex', flexDirection: 'row', gap: '10px', padding: '5px' }}>
<div style={{ flex: 1 }}>
{items
.filter(item => item.path.includes('/'))
.map(item => {
const actualIndex = items.indexOf(item)
const isDisabled = item.path === selectedCharacter?.path
return (
<AccountInventorySelectorItem
key={item.path}
{...getItemProps({
ref(node) {
listRef.current[actualIndex] = node
},
onClick() {
if (!isDisabled) {
setInputValue('')
setSelectedTargetInventory(item)
setOpen(false)
refs.domReference.current?.focus()
}
},
})}
active={activeIndex === actualIndex}
aria-disabled={isDisabled}
>
{item.name}
</AccountInventorySelectorItem>
)
})}
</div>
<div style={{ flex: 1 }}>
{items
.filter(item => !item.path.includes('/'))
.map(item => {
const actualIndex = items.indexOf(item)
const isDisabled = item.path === selectedCharacter?.path
return (
<AccountInventorySelectorItem
key={item.path}
{...getItemProps({
ref(node) {
listRef.current[actualIndex] = node
},
onClick() {
if (!isDisabled) {
setInputValue('')
setSelectedTargetInventory(item)
setOpen(false)
refs.domReference.current?.focus()
}
},
})}
active={activeIndex === actualIndex}
aria-disabled={isDisabled}
>
[Bank] {item.account_name}
</AccountInventorySelectorItem>
)
})}
</div>
</div>
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</>
)
}

View File

@ -1,86 +0,0 @@
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { atom, useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useMemo } from 'react'
import { StatsColumns } from '@/lib/columns'
import { ItemWithSelection } from '@/lib/table/defs'
import { InventoryColumns } from '@/lib/table/tanstack'
import {
inventoryItemsCurrentPageAtom,
mouseDragSelectionStateAtom,
preferenceInventoryTab,
} from '@/state/atoms'
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 setDragState = useSetAtom(mouseDragSelectionStateAtom)
const columns = useMemo(() => {
return [...Object.values(InventoryColumns)]
}, [])
const columnVisibility = useAtomValue(columnVisibilityAtom)
const table = useReactTable<ItemWithSelection>({
getRowId: row => row.item.unique_id.toString(),
data: items,
state: {
columnVisibility,
},
columns,
getCoreRowModel: getCoreRowModel(),
})
// Handle global mouse up to end drag selection
useEffect(() => {
const handleMouseUp = () => {
setDragState(prev => ({ ...prev, isDragging: false }))
}
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mouseup', handleMouseUp)
}
}, [setDragState])
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,132 +0,0 @@
import { useAtom } from 'jotai'
import { useState } from 'react'
import useLocalStorage from 'use-local-storage'
import { login, logout } from '../lib/session'
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 items-center px-4 py-2 bg-green-50 border border-green-200 rounded">
<div className="flex items-center gap-2">
<span className="text-green-600"></span>
<span className="font-medium">{loginState.community_name}</span>
</div>
<button
type="button"
onClick={() => {
setIsLoading(true)
logout().finally(() => {
refetchLoginState()
setIsLoading(false)
})
}}
disabled={isLoading}
className="text-blue-600 text-sm hover:text-blue-800 hover:underline disabled:opacity-50"
>
{isLoading ? 'Logging out...' : 'Logout'}
</button>
</div>
)
}
// Handle server maintenance (503) state
if (loginState?.code === 503) {
return (
<div className="flex flex-col gap-2 p-4 bg-yellow-50 border border-yellow-200 rounded">
<div className="flex items-center gap-2 justify-center">
<span className="text-yellow-600"></span>
<span className="font-medium text-yellow-800">Server Maintenance</span>
</div>
<p className="text-sm text-yellow-700 ml-4">
The server is currently unavailable.{' '}
<button
type="button"
onClick={() => {
setIsLoading(true)
refetchLoginState()
// Add a small delay to show loading state
setTimeout(() => setIsLoading(false), 500)
}}
disabled={isLoading}
className="text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50"
>
{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
type="email"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Email"
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
required
/>
</div>
<div>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
required
/>
</div>
<button
type="submit"
disabled={isLoading || !username || !password}
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
)
}

View File

@ -1,28 +0,0 @@
@import "tailwindcss";
html {
cursor: url(/assets/cursor.png), auto !important;
}
@theme {
--cursor-default: url(/assets/cursor.png), auto !important;
--cursor-pointer: url(/assets/cursor.png), pointer !important;
--cursor-text: url(/assets/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,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

View File

@ -1,20 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
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}>
<App />
</QueryClientProvider>
</Provider>
</React.StrictMode>,
)

View File

@ -1,33 +1,38 @@
import { TricksterItem } from '../trickster' import Handsontable from "handsontable"
import numbro from 'numbro';
import { textRenderer } from "handsontable/renderers"
import { TricksterItem } from "../trickster"
import Core from "handsontable/core";
export const BasicColumns = ['uid', 'Image', 'Name', 'Count'] as const export const BasicColumns = [
"uid","Image","Name","Count",
export const DetailsColumns = ['Desc', 'Use'] as const
export const MoveColumns = ['MoveCount', 'Move'] as const
export const TagColumns = ['All', 'Equip', 'Drill', 'Card', 'Quest', 'Consume', 'Compound'] as const
export const EquipmentColumns = ['MinLvl', 'Slots', 'RefineNumber', 'RefineState'] as const
export const StatsColumns = [
'HV',
'AC',
'LK',
'WT',
'HP',
'MA',
'DP',
'DX',
'MP',
'AP',
'MD',
'DA',
'GunAP',
] as const ] as const
export const DebugColumns = [] export const DetailsColumns = [
export const HackColumns = [] as const "Desc","Use",
] as const
export const MoveColumns = [
"MoveCount","Move",
] as const
export const TagColumns = [
"All","Equip","Drill","Card","Quest","Consume", "Compound"
] as const
export const EquipmentColumns = [
"MinLvl","Slots","RefineNumber","RefineState",
] as const
export const StatsColumns = [
"AP","GunAP","AC","DX","MP","MA","MD","WT","DA","LK","HP","DP","HV",
] as const
export const DebugColumns = [
]
export const HackColumns = [
] as const
export const ColumnNames = [ export const ColumnNames = [
...BasicColumns, ...BasicColumns,
@ -39,22 +44,22 @@ export const ColumnNames = [
...HackColumns, ...HackColumns,
] as const ] as const
export type ColumnName = (typeof ColumnNames)[number] export type ColumnName = typeof ColumnNames[number]
const c = (a:ColumnName | ColumnInfo):ColumnName => { const c = (a:ColumnName | ColumnInfo):ColumnName => {
switch(typeof a) { switch(typeof a) {
case 'string': case "string":
return a return a
case 'object': case "object":
return a.name return a.name
} }
} }
export const LazyColumn = c export const LazyColumn = c;
export const ColumnSorter = (a:ColumnName | ColumnInfo, b: ColumnName | ColumnInfo):number => { export const ColumnSorter = (a:ColumnName | ColumnInfo, b: ColumnName | ColumnInfo):number => {
const n1 = ColumnNames.indexOf(c(a)) let n1 = ColumnNames.indexOf(c(a))
const n2 = ColumnNames.indexOf(c(b)) let n2 = ColumnNames.indexOf(c(b))
if (n1 === n2) { if(n1 == n2) {
return 0 return 0
} }
return n1 > n2 ? 1 : -1 return n1 > n2 ? 1 : -1
@ -68,5 +73,6 @@ export interface ColumnInfo {
renderer?:any renderer?:any
filtering?:boolean filtering?:boolean
writable?:boolean writable?:boolean
getter(item: TricksterItem): string | number getter(item:TricksterItem):(string | number)
} }

View File

@ -1,9 +1,9 @@
import Handsontable from 'handsontable' import Handsontable from "handsontable"
import Core from 'handsontable/core' import Core from "handsontable/core"
import { textRenderer } from 'handsontable/renderers' import { textRenderer } from "handsontable/renderers"
import numbro from 'numbro' import numbro from "numbro"
import { TricksterItem } from '../trickster' import { TricksterItem } from "../trickster"
import { ColumnInfo, ColumnName } from './column' import {ColumnName, ColumnInfo} from "./column"
export const ColumnByNames = (...n:ColumnName[]) => { export const ColumnByNames = (...n:ColumnName[]) => {
return n.map(ColumnByName) return n.map(ColumnByName)
@ -14,465 +14,416 @@ export const ColumnByName = (n: ColumnName) => {
class Image implements ColumnInfo { class Image implements ColumnInfo {
name:ColumnName = 'Image' name:ColumnName = 'Image'
displayName = ' ' displayName = " "
renderer = coverRenderer renderer = coverRenderer
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number) {
return item.item_image ? item.item_image : '' return item.image ? item.image : ""
} }
} }
function coverRenderer( function coverRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any,
_row: any,
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
if (stringifiedValue.startsWith('http')) { if (stringifiedValue.startsWith('http')) {
const img: any = document.createElement('IMG') const img:any = document.createElement('IMG');
img.src = value img.src = value;
Handsontable.dom.addEvent(img, 'mousedown', event =>{ Handsontable.dom.addEvent(img, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(img) td.appendChild(img);
} }
} }
class Name implements ColumnInfo { class Name implements ColumnInfo {
name: ColumnName = 'Name' name:ColumnName = "Name"
displayName = 'Name' displayName = "Name"
filtering = true filtering = true
renderer = nameRenderer renderer = nameRenderer
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_name return item.item_name
} }
} }
function nameRenderer( function nameRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any, let showText = stringifiedValue;
_row: any, const div= document.createElement('div');
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
const showText = stringifiedValue
const div = document.createElement('div')
div.innerHTML = showText div.innerHTML = showText
div.title = showText div.title = showText
div.style.maxWidth = '20ch' div.style.maxWidth = "20ch"
div.style.textOverflow = 'ellipsis' div.style.textOverflow = "ellipsis"
div.style.overflow = 'hidden' div.style.overflow= "hidden"
div.style.whiteSpace = 'nowrap' div.style.whiteSpace= "nowrap"
Handsontable.dom.addEvent(div, 'mousedown', event =>{ Handsontable.dom.addEvent(div, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(div) td.appendChild(div);
td.classList.add('htLeft') td.classList.add("htLeft")
} }
class Count implements ColumnInfo { class Count implements ColumnInfo {
name: ColumnName = 'Count' name:ColumnName = "Count"
displayName = 'Count' displayName = "Count"
renderer = 'numeric' renderer = "numeric"
filtering = true filtering = true
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_count return item.item_count
} }
} }
const spacer = '-----------' const spacer = "-----------"
const getMoveTargets = (invs: string[]): string[] => {
const out: string[] = []
out.push(spacer)
for (const k of invs) {
out.push(k)
}
out.push('')
out.push('')
out.push('TRASH')
return out
}
class Move implements ColumnInfo { class Move implements ColumnInfo {
name: ColumnName = 'Move' name:ColumnName = "Move"
displayName = 'Target' displayName = "Target"
writable = true writable = true
options = getMoveTargets options = getMoveTargets
getter(_item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return spacer return spacer
} }
} }
const getMoveTargets = (invs: string[]):string[] => {
let out:string[] = [];
out.push(spacer)
for(const k of invs){
out.push(k)
}
out.push("")
out.push("")
out.push("TRASH")
return out
}
class MoveCount implements ColumnInfo { class MoveCount implements ColumnInfo {
name: ColumnName = 'MoveCount' name:ColumnName = "MoveCount"
displayName = 'Move #' displayName = "Move #"
renderer = moveCountRenderer renderer = moveCountRenderer
writable = true writable = true
getter(_item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return '' return ""
} }
} }
function moveCountRenderer( function moveCountRenderer(instance:Core, td:any, row:number, col:number, prop:any, value:any, cellProperties:any) {
instance: Core, let newValue = value;
td: any,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any,
) {
let newValue = value
if (Handsontable.helper.isNumeric(newValue)) { if (Handsontable.helper.isNumeric(newValue)) {
const numericFormat = cellProperties.numericFormat const numericFormat = cellProperties.numericFormat;
const cellCulture = numericFormat?.culture || '-' const cellCulture = numericFormat && numericFormat.culture || '-';
const cellFormatPattern = numericFormat?.pattern const cellFormatPattern = numericFormat && numericFormat.pattern;
const className = cellProperties.className || '' const className = cellProperties.className || '';
const classArr = className.length ? className.split(' ') : [] const classArr = className.length ? className.split(' ') : [];
if (typeof cellCulture !== 'undefined' && !numbro.languages()[cellCulture]) { if (typeof cellCulture !== 'undefined' && !numbro.languages()[cellCulture]) {
const shortTag: any = cellCulture.replace('-', '') const shortTag:any = cellCulture.replace('-', '');
const langData = (numbro as any)[shortTag] const langData = (numbro as any)[shortTag];
if (langData) { if (langData) {
numbro.registerLanguage(langData) numbro.registerLanguage(langData);
} }
} }
const totalCount = Number(instance.getCell(row,col-1)?.innerHTML) const totalCount = Number(instance.getCell(row,col-1)?.innerHTML)
numbro.setLanguage(cellCulture) numbro.setLanguage(cellCulture);
const num = numbro(newValue) const num = numbro(newValue)
if(totalCount < num.value()) { if(totalCount < num.value()) {
const newNum = numbro(totalCount) const newNum = numbro(totalCount)
newValue = newNum.format(cellFormatPattern || '0') newValue = newNum.format(cellFormatPattern || '0');
}else { }else {
newValue = num.format(cellFormatPattern || '0') newValue = num.format(cellFormatPattern || '0');
} }
if ( if (classArr.indexOf('htLeft') < 0 && classArr.indexOf('htCenter') < 0 &&
classArr.indexOf('htLeft') < 0 && classArr.indexOf('htRight') < 0 && classArr.indexOf('htJustify') < 0) {
classArr.indexOf('htCenter') < 0 && classArr.push('htRight');
classArr.indexOf('htRight') < 0 &&
classArr.indexOf('htJustify') < 0
) {
classArr.push('htRight')
} }
if (classArr.indexOf('htNumeric') < 0) { if (classArr.indexOf('htNumeric') < 0) {
classArr.push('htNumeric') classArr.push('htNumeric');
} }
cellProperties.className = classArr.join(' ') cellProperties.className = classArr.join(' ');
td.dir = 'ltr' td.dir = 'ltr';
newValue = `${newValue}x` newValue = newValue + "x"
}else { }else {
newValue = '' newValue = ""
} }
textRenderer(instance, td, row, col, prop, newValue, cellProperties) textRenderer(instance, td, row, col, prop, newValue, cellProperties);
} }
class Equip implements ColumnInfo { class Equip implements ColumnInfo {
name: ColumnName = 'Equip' name:ColumnName = "Equip"
displayName = 'equip' displayName = "equip"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.is_equip ? 1 : 0 return item.is_equip ? 1 : 0
} }
} }
class Drill implements ColumnInfo { class Drill implements ColumnInfo {
name: ColumnName = 'Drill' name:ColumnName = "Drill"
displayName = 'drill' displayName = "drill"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.is_drill ? 1 : 0 return item.is_drill ? 1 : 0
} }
} }
class All implements ColumnInfo { class All implements ColumnInfo {
name: ColumnName = 'All' name:ColumnName = "All"
displayName = 'swap' displayName = "swap"
getter(_: TricksterItem): string | number { getter(_:TricksterItem):(string|number){
return -10000 return -10000
} }
} }
class uid implements ColumnInfo { class uid implements ColumnInfo {
name: ColumnName = 'uid' name:ColumnName = "uid"
displayName = 'id' displayName = "id"
renderer = invisibleRenderer renderer = invisibleRenderer
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.unique_id return item.unique_id
} }
} }
function invisibleRenderer( function invisibleRenderer(instance:Core, td:any, row:number, col:number, prop:any, value:any, cellProperties:any) {
_instance: Core, Handsontable.dom.empty(td);
td: any,
_row: number,
_col: number,
_prop: any,
_value: any,
_cellProperties: any,
) {
Handsontable.dom.empty(td)
} }
class Card implements ColumnInfo { class Card implements ColumnInfo {
name: ColumnName = 'Card' name:ColumnName = "Card"
displayName = 'card' displayName = "card"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return cardFilter(item) ? 1 : 0 return cardFilter(item) ? 1 : 0
} }
} }
const cardFilter= (item:TricksterItem): boolean => { const cardFilter= (item:TricksterItem): boolean => {
return item.item_name.endsWith(' Card') || item.item_name.startsWith('Star Card') return (item.item_name.endsWith(" Card") || item.item_name.startsWith("Star Card"))
} }
class Compound implements ColumnInfo { class Compound implements ColumnInfo {
name: ColumnName = 'Compound' name:ColumnName = "Compound"
displayName = 'comp' displayName = "comp"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return compFilter(item) ? 1 : 0 return compFilter(item) ? 1 : 0
} }
} }
const compFilter= (item:TricksterItem): boolean => { const compFilter= (item:TricksterItem): boolean => {
return item.item_comment.toLowerCase().includes('compound item') return (item.item_desc.toLowerCase().includes("compound item"))
} }
class Quest implements ColumnInfo { class Quest implements ColumnInfo {
name: ColumnName = 'Quest' name:ColumnName = "Quest"
displayName = 'quest' displayName = "quest"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return questFilter(item) ? 1 : 0 return questFilter(item) ? 1 : 0
} }
} }
const questFilter = (_item: TricksterItem): boolean => { const questFilter= (item:TricksterItem): boolean => {
return false return false
} }
class Consume implements ColumnInfo { class Consume implements ColumnInfo {
name: ColumnName = 'Consume' name:ColumnName = "Consume"
displayName = 'eat' displayName = "eat"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return consumeFilter(item) ? 1 : 0 return consumeFilter(item) ? 1 : 0
} }
} }
const consumeFilter= (item:TricksterItem): boolean => { const consumeFilter= (item:TricksterItem): boolean => {
const tl = item.item_use.toLowerCase() const tl = item.item_use.toLowerCase()
return tl.includes('recover') || tl.includes('restores') return tl.includes("recover") || tl.includes("restores")
} }
class AP implements ColumnInfo { class AP implements ColumnInfo {
name: ColumnName = 'AP' name:ColumnName = "AP"
displayName = 'AP' displayName = "AP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.AP : '' return item.stats ? item.stats["AP"] : ""
} }
} }
class GunAP implements ColumnInfo { class GunAP implements ColumnInfo {
name: ColumnName = 'GunAP' name:ColumnName = "GunAP"
displayName = 'Gun AP' displayName = "Gun AP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats['Gun AP'] : '' return item.stats ? item.stats["Gun AP"] : ""
} }
} }
class AC implements ColumnInfo { class AC implements ColumnInfo {
name: ColumnName = 'AC' name:ColumnName = "AC"
displayName = 'AC' displayName = "AC"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.AC : '' return item.stats ? item.stats["AC"] : ""
} }
} }
class DX implements ColumnInfo { class DX implements ColumnInfo {
name: ColumnName = 'DX' name:ColumnName = "DX"
displayName = 'DX' displayName = "DX"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.DX : '' return item.stats ? item.stats["DX"] : ""
} }
} }
class MP implements ColumnInfo { class MP implements ColumnInfo {
name: ColumnName = 'MP' name:ColumnName = "MP"
displayName = 'MP' displayName = "MP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.MP : '' return item.stats ? item.stats["MP"] : ""
} }
} }
class MA implements ColumnInfo { class MA implements ColumnInfo {
name: ColumnName = 'MA' name:ColumnName = "MA"
displayName = 'MA' displayName = "MA"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.MA : '' return item.stats ? item.stats["MA"] : ""
} }
} }
class MD implements ColumnInfo { class MD implements ColumnInfo {
name: ColumnName = 'MD' name:ColumnName = "MD"
displayName = 'MD' displayName = "MD"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.MD : '' return item.stats ? item.stats["MD"] : ""
} }
} }
class WT implements ColumnInfo { class WT implements ColumnInfo {
name: ColumnName = 'WT' name:ColumnName = "WT"
displayName = 'WT' displayName = "WT"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.WT : '' return item.stats ? item.stats["WT"] : ""
} }
} }
class DA implements ColumnInfo { class DA implements ColumnInfo {
name: ColumnName = 'DA' name:ColumnName = "DA"
displayName = 'DA' displayName = "DA"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.DA : '' return item.stats ? item.stats["DA"] : ""
} }
} }
class LK implements ColumnInfo { class LK implements ColumnInfo {
name: ColumnName = 'LK' name:ColumnName = "LK"
displayName = 'LK' displayName = "LK"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.LK : '' return item.stats ? item.stats["LK"] : ""
} }
} }
class HP implements ColumnInfo { class HP implements ColumnInfo {
name: ColumnName = 'HP' name:ColumnName = "HP"
displayName = 'HP' displayName = "HP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.HP : '' return item.stats ? item.stats["HP"] : ""
} }
} }
class DP implements ColumnInfo { class DP implements ColumnInfo {
name: ColumnName = 'DP' name:ColumnName = "DP"
displayName = 'DP' displayName = "DP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.DP : '' return item.stats ? item.stats["DP"] : ""
} }
} }
class HV implements ColumnInfo { class HV implements ColumnInfo {
name: ColumnName = 'HV' name:ColumnName = "HV"
displayName = 'HV' displayName = "HV"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.HV : '' return item.stats ? item.stats["HV"] : ""
} }
} }
class MinLvl implements ColumnInfo { class MinLvl implements ColumnInfo {
name: ColumnName = 'MinLvl' name:ColumnName = "MinLvl"
displayName = 'lvl' displayName = "lvl"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
//TODO: //TODO:
return item.item_min_level ? item.item_min_level : '' return item.item_min_level? item.item_min_level:""
} }
} }
class Slots implements ColumnInfo { class Slots implements ColumnInfo {
name: ColumnName = 'Slots' name:ColumnName = "Slots"
displayName = 'slots' displayName = "slots"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
//TODO: //TODO:
return item.item_slots ? item.item_slots : '' return item.item_slots ? item.item_slots : ""
} }
} }
class RefineNumber implements ColumnInfo { class RefineNumber implements ColumnInfo {
name: ColumnName = 'RefineNumber' name:ColumnName = "RefineNumber"
displayName = 'refine' displayName = "refine"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.refine_level ? item.refine_level : 0 return item.refine_level ? item.refine_level : 0
} }
} }
class RefineState implements ColumnInfo { class RefineState implements ColumnInfo {
name: ColumnName = 'RefineState' name:ColumnName = "RefineState"
displayName = 'bork' displayName = "bork"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.refine_state ? item.refine_state : 0 return item.refine_state ? item.refine_state : 0
} }
} }
class Desc implements ColumnInfo { class Desc implements ColumnInfo {
name: ColumnName = 'Desc' name:ColumnName = "Desc"
displayName = 'desc' displayName = "desc"
renderer = descRenderer renderer = descRenderer
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_comment return item.item_desc
} }
} }
function descRenderer( function descRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any, let showText = stringifiedValue;
_row: any, const div= document.createElement('div');
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
const showText = stringifiedValue
const div = document.createElement('div')
div.innerHTML = showText div.innerHTML = showText
div.title = showText div.title = showText
div.style.maxWidth = '30ch' div.style.maxWidth = "30ch"
div.style.textOverflow = 'ellipsis' div.style.textOverflow = "ellipsis"
div.style.overflow = 'hidden' div.style.overflow= "hidden"
div.style.whiteSpace = 'nowrap' div.style.whiteSpace= "nowrap"
Handsontable.dom.addEvent(div, 'mousedown', event =>{ Handsontable.dom.addEvent(div, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(div) td.appendChild(div);
td.classList.add('htLeft') td.classList.add("htLeft")
} }
class Use implements ColumnInfo { class Use implements ColumnInfo {
name: ColumnName = 'Use' name:ColumnName = "Use"
displayName = 'use' displayName = "use"
renderer = useRenderer renderer= useRenderer;
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_use return item.item_use
} }
} }
function useRenderer( function useRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any, let showText = stringifiedValue;
_row: any, const div= document.createElement('div');
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
const showText = stringifiedValue
const div = document.createElement('div')
div.title = showText div.title = showText
div.innerHTML = showText div.innerHTML = showText
div.style.maxWidth = '30ch' div.style.maxWidth = "30ch"
div.style.textOverflow = 'ellipsis' div.style.textOverflow = "ellipsis"
div.style.overflow = 'hidden' div.style.overflow= "hidden"
div.style.whiteSpace = 'nowrap' div.style.whiteSpace= "nowrap"
Handsontable.dom.addEvent(div, 'mousedown', event =>{ Handsontable.dom.addEvent(div, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(div) td.appendChild(div);
td.classList.add('htLeft') td.classList.add("htLeft")
} }
export const Columns:{[Property in ColumnName]:ColumnInfo}= { export const Columns:{[Property in ColumnName]:ColumnInfo}= {
@ -509,3 +460,5 @@ export const Columns: { [Property in ColumnName]: ColumnInfo } = {
Compound: new Compound(), Compound: new Compound(),
uid: new uid(), uid: new uid(),
} }

View File

@ -1,2 +1,3 @@
export * from './column' export * from "./column"
export * from './column_impl' export * from "./column_impl"

View File

@ -1,13 +1,10 @@
import { TricksterAccount, TricksterInventory } from '../trickster' import { trace } from "loglevel"
import { TricksterAccount, TricksterInventory } from "../trickster"
import { v4 as uuidv4 } from 'uuid';
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
export const BankEndpoints = [ export const BankEndpoints = ["internal-xfer-item", "bank-item", "sell-item","buy-from-order","cancel-order"] as const
'internal-xfer-item', export type BankEndpoint = typeof BankEndpoints[number]
'bank-item',
'sell-item',
'buy-from-order',
'cancel-order',
] as const
export type BankEndpoint = (typeof BankEndpoints)[number]
export interface LTOApi { export interface LTOApi {
GetInventory:(path:string)=>Promise<TricksterInventory> GetInventory:(path:string)=>Promise<TricksterInventory>

View File

@ -1,3 +1,3 @@
export * from './api' export * from "./lifeto"
export * from './lifeto' export * from "./api"
export * from './stateful' export * from "./stateful"

View File

@ -1,121 +0,0 @@
import { LTOApi } from './api'
export interface InternalXferParams {
itemUid: string | 'galders'
count: number
targetCharId: string
}
export interface BankItemParams {
itemUid: string | 'galders'
count: number
targetAccount: string
}
export interface MoveResult {
success: boolean
error?: string
data?: any
}
export class ItemMover {
constructor(private api: LTOApi) {}
/**
* Transfer items between characters
* Uses internal-xfer-item API
*/
async internalXfer(params: InternalXferParams): Promise<MoveResult> {
try {
const request = {
item_uid: params.itemUid,
qty: params.count.toString(),
new_char: params.targetCharId,
}
const response = await this.api.BankAction<any, any>('internal-xfer-item', request)
if (response.status !== 'success') {
return {
success: false,
error: response.message || 'Failed to transfer item',
}
}
return {
success: true,
data: response.data,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error in internalXfer',
}
}
}
/**
* Move items to bank
* Uses bank-item API
*/
async bankItem(params: BankItemParams): Promise<MoveResult> {
try {
const request = {
item_uid: params.itemUid,
qty: params.count.toString(),
account: params.targetAccount,
}
const response = await this.api.BankAction<any, any>('bank-item', request)
if (response.status !== 'success') {
return {
success: false,
error: response.message || 'Failed to bank item',
}
}
return {
success: true,
data: response.data,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error in bankItem',
}
}
}
/**
* High-level function that determines whether to use bankItem or internalXfer
* based on whether targetAccountId is provided (bank) or targetCharId (character)
*/
async moveItem(
itemUid: string | 'galders',
count: number,
targetCharId?: string,
targetAccountId?: string,
): Promise<MoveResult> {
if (targetAccountId) {
// Use bank-item when moving to bank (targetAccountId is provided)
return this.bankItem({
itemUid,
count,
targetAccount: targetAccountId,
})
}
if (targetCharId) {
// Use internal-xfer when moving between characters
return this.internalXfer({
itemUid,
count,
targetCharId,
})
}
return {
success: false,
error: 'Either targetCharId or targetAccountId must be provided',
}
}
}

View File

@ -1,25 +1,25 @@
import { AxiosResponse, Method } from 'axios' import { Axios, AxiosResponse, Method } from "axios"
import log from 'loglevel' import log, { debug } from "loglevel"
import { bank_endpoint, EndpointCreator, market_endpoint, Session } from '../session' import { bank_endpoint, EndpointCreator, market_endpoint, Session } from "../session"
import { TricksterAccount, TricksterInventory, TricksterItem } from '../trickster' import { dummyChar, TricksterAccount, TricksterInventory, TricksterItem, TricksterWallet } from "../trickster"
import { BankEndpoint, LTOApi } from './api' import { BankEndpoint, LTOApi } from "./api"
export const pathIsBank = (path:string):boolean => { export const pathIsBank = (path:string):boolean => {
if (path.includes('/')) { if(path.includes("/")) {
return false return false
} }
return true return true
} }
export const splitPath = (path:string):[string,string]=>{ export const splitPath = (path:string):[string,string]=>{
const spl = path.split('/') const spl = path.split("/")
switch(spl.length) { switch(spl.length) {
case 1: case 1:
return [spl[0], ''] return [spl[0], ""]
case 2: case 2:
return [spl[0],spl[1]] return [spl[0],spl[1]]
} }
return ['', ''] return ["",""]
} }
export class LTOApiv0 implements LTOApi { export class LTOApiv0 implements LTOApi {
s: Session s: Session
@ -28,110 +28,81 @@ export class LTOApiv0 implements LTOApi {
} }
BankAction = async <T,D>(e: BankEndpoint, t: T):Promise<D> => { BankAction = async <T,D>(e: BankEndpoint, t: T):Promise<D> => {
let VERB: Method | 'POSTFORM' = 'POST' let VERB:Method | "POSTFORM" = "POST"
let endpoint:EndpointCreator = bank_endpoint let endpoint:EndpointCreator = bank_endpoint
switch(e){ switch(e){
case 'buy-from-order': case "buy-from-order":
case 'cancel-order': case "cancel-order":
endpoint = market_endpoint endpoint = market_endpoint
break case "sell-item":
case 'sell-item': VERB = "POSTFORM"
//case 'internal-xfer-item':
VERB = 'POSTFORM'
break
default: default:
break
} }
return this.s.request(VERB as any, e, t, endpoint).then(x => { return this.s.request(VERB as any,e,t,endpoint).then((x)=>{
return x.data return x.data
}) })
} }
GetInventory = async (char_path: string):Promise<TricksterInventory> =>{ GetInventory = async (char_path: string):Promise<TricksterInventory> =>{
if (char_path.startsWith(':')) { if(char_path.startsWith(":")) {
char_path = char_path.replace(':', '') char_path = char_path.replace(":","")
} }
const type = char_path.includes('/') ? 'char' : 'account' return this.s.request("GET", `item-manager/items/account/${char_path}`,undefined).then((ans:AxiosResponse)=>{
return this.s
.request('GET', `v3/item-manager/items/${type}/${char_path}`, undefined)
.then((ans: AxiosResponse) => {
const o = ans.data const o = ans.data
log.debug('GetInventory', o) log.debug("GetInventory", o)
let name = 'bank' let name = "bank"
let id = 0 let id = 0
let galders = 0 let galders = 0
if(pathIsBank(char_path)){ if(pathIsBank(char_path)){
const [char, val] = Object.entries(o.characters)[0] as [string, any] let [char, val] = Object.entries(o.characters)[0] as [string,any]
name = val.name name = val.name
id = Number(char) id = Number(char)
galders = 0 galders = 0
}else { }else {
const [char, val] = Object.entries(o.characters)[0] as [string, any] let [char, val] = Object.entries(o.characters)[0] as [string,any]
name = val.name name = val.name
id = Number(char) id = Number(char)
galders = val.galders galders = val.galders
} }
const out: TricksterInventory = { let out = {
account_name: o.account.account_gid,
account_id: o.account.account_code,
name, name,
id, id,
path: char_path, path: char_path,
galders, galders,
items: new Map( items: Object.fromEntries((Object.entries(o.items) as any).map(([k, v]: [string, TricksterItem]):[string, TricksterItem]=>{
(Object.entries(o.items) as any).map(
([k, v]: [string, TricksterItem]): [string, TricksterItem] => {
v.unique_id = Number(k) v.unique_id = Number(k)
v.id = k
return [k, v] return [k, v]
}, })),
), } as TricksterInventory
),
}
return out return out
}) })
} }
GetAccounts = async ():Promise<TricksterAccount[]> => { GetAccounts = async ():Promise<TricksterAccount[]> => {
return this.s.request('GET', 'characters/list', undefined).then((ans: AxiosResponse) => { return this.s.request("GET", "characters/list",undefined).then((ans:AxiosResponse)=>{
log.debug('GetAccounts', ans.data) log.debug("GetAccounts", ans.data)
return ans.data.map((x: any): TricksterAccount => { return ans.data.map((x:any)=>{
return { return {
name: x.name, name: x.name,
characters: [ characters: [{id: x.id,account_id:x.id, path:x.name, name: x.name+'/bank', class:-8, base_job: -8, current_job: -8},...Object.values(x.characters).map((z:any)=>{
{
account_name: x.name,
id: x.id,
account_id: x.id,
path: x.name,
name: `${x.name}/bank`,
class: -8,
base_job: -8,
current_job: -8,
current_type: -8,
},
...Object.values(x.characters).map((z: any) => {
return { return {
account_name: x.name,
account_id: x.id, account_id: x.id,
id: z.id, id: z.id,
name: z.name, name: z.name,
path: `${x.name}/${z.name}`, path: x.name+"/"+z.name,
class: z.class, class: z.class,
base_job: z.base_job, base_job: z.base_job,
current_job: z.current_job, current_job: z.current_job,
current_type: z.current_type,
}
}),
],
} }
})],
} as TricksterAccount
}) })
}) })
} }
GetLoggedin = async ():Promise<boolean> => { GetLoggedin = async ():Promise<boolean> => {
return this.s.request('POST', 'accounts/list', undefined).then((ans: AxiosResponse) => { return this.s.request("POST", "accounts/list",undefined).then((ans:AxiosResponse)=>{
if (ans.status === 401) { if(ans.status == 401) {
return false return false
} }
if (ans.status === 200) { if(ans.status == 200) {
return true return true
} }
return false return false

View File

@ -1,14 +1,14 @@
import { debug } from 'loglevel' import { LTOApi } from "./api"
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid';
import { RefStore } from '../../state/state' import { RefStore } from "../../state/state";
import { LTOApi } from './api' import { debug } from "loglevel";
export const TxnStates = ['PENDING', 'INFLIGHT', 'WORKING', 'ERROR', 'SUCCESS'] as const export const TxnStates = ["PENDING","INFLIGHT","WORKING","ERROR","SUCCESS"] as const
export type TxnState = (typeof TxnStates)[number] export type TxnState = typeof TxnStates[number]
export interface TxnDetails { export interface TxnDetails {
item_uid: string | 'galders' item_uid: string | "galders"
count:number count:number
origin:string origin:string
target:string target:string
@ -26,16 +26,17 @@ export interface Envelope<REQ, RESP> {
state: TxnState state: TxnState
} }
export abstract class Order { export abstract class Order {
action_id: string action_id: string
details?:TxnDetails details?:TxnDetails
created:Date created:Date
state: TxnState state: TxnState
constructor(details?:TxnDetails) { constructor(details?:TxnDetails) {
this.state = 'PENDING' this.state = "PENDING"
this.details = details this.details = details
this.created = new Date() this.created = new Date()
this.action_id = uuidv4() this.action_id = uuidv4();
} }
mark(t:TxnState) { mark(t:TxnState) {
@ -73,7 +74,7 @@ export abstract class BasicOrder extends Order {
return this.state return this.state
} }
error():string { error():string {
return this.err ? this.err : '' return this.err ? this.err : ""
} }
parse(i:any):BasicOrder { parse(i:any):BasicOrder {
this.stage = i.stage this.stage = i.stage
@ -84,27 +85,20 @@ export abstract class BasicOrder extends Order {
} }
/// start user defined /// start user defined
export const OrderTypes = [ export const OrderTypes = ["InvalidOrder","BankItem","InternalXfer", "PrivateMarket","MarketMove", "MarketMoveToChar"]
'InvalidOrder', export type OrderType = typeof OrderTypes[number]
'BankItem',
'InternalXfer',
'PrivateMarket',
'MarketMove',
'MarketMoveToChar',
]
export type OrderType = (typeof OrderTypes)[number]
export class InvalidOrder extends Order{ export class InvalidOrder extends Order{
order_type = 'InvalidOrder' order_type = "InvalidOrder"
msg:string msg:string
constructor(msg: string){ constructor(msg: string){
super(undefined) super(undefined)
this.msg = msg this.msg = msg
this.mark('ERROR') this.mark("ERROR")
} }
status():string { status():string {
return 'ERROR' return "ERROR"
} }
progress():[number, number] { progress():[number, number] {
return [0,0] return [0,0]
@ -112,7 +106,7 @@ export class InvalidOrder extends Order {
error(): string { error(): string {
return this.msg return this.msg
} }
async tick(_r: RefStore, _api: LTOApi): Promise<void> { async tick(r:RefStore, api:LTOApi):Promise<void> {
return return
} }
parse(i:any):InvalidOrder { parse(i:any):InvalidOrder {
@ -123,11 +117,12 @@ export class InvalidOrder extends Order {
} }
export interface BasicResponse { export interface BasicResponse {
status: string status: number
data: any data: any
message?: string msg?: string
} }
export interface InternalXferRequest { export interface InternalXferRequest {
item_uid:string item_uid:string
qty:string qty:string
@ -138,7 +133,7 @@ export interface InternalXferRequest {
export interface InternalXferResponse extends BasicResponse {} export interface InternalXferResponse extends BasicResponse {}
export class InternalXfer extends BasicOrder{ export class InternalXfer extends BasicOrder{
order_type = 'InternalXfer' order_type = "InternalXfer"
originalRequest:InternalXferRequest originalRequest:InternalXferRequest
originalResponse?:InternalXferResponse originalResponse?:InternalXferResponse
@ -152,36 +147,27 @@ export class InternalXfer extends BasicOrder {
} }
} }
async tick(r:RefStore, api:LTOApi):Promise<void> { async tick(r:RefStore, api:LTOApi):Promise<void> {
if (this.state !== 'PENDING') { if(this.state !== "PENDING") {
return return
} }
this.mark('WORKING') this.mark("WORKING")
return api return api.BankAction<InternalXferRequest, InternalXferResponse>("internal-xfer-item",this.originalRequest)
.BankAction<InternalXferRequest, InternalXferResponse>(
'internal-xfer-item',
this.originalRequest,
)
.then((x:InternalXferResponse)=>{ .then((x:InternalXferResponse)=>{
if (x.status === 'success') { if(x.status == 200){
this.originalResponse = x this.originalResponse = x
this.stage = 1 this.stage = 1
this.mark('SUCCESS') this.mark("SUCCESS")
if (this.details?.origin_path && this.details?.item_uid && this.details?.count) { const origin_item = r.invs.value.get(this.details?.origin_path!)!.items[this.details?.item_uid!]!
const inventory = r.invs.value.get(this.details.origin_path) origin_item.item_count = origin_item.item_count - this.details?.count!
const origin_item = inventory?.items[this.details.item_uid]
if (origin_item) {
origin_item.item_count = origin_item.item_count - this.details.count
}
}
}else{ }else{
throw x.message throw x.msg
} }
}) })
.catch(e => { .catch((e)=>{
debug('InternalXfer', e) debug("InternalXfer",e)
this.stage = 1 this.stage = 1
this.err = e this.err = e
this.mark('ERROR') this.mark("ERROR")
}) })
} }
parse(i:any):InternalXfer { parse(i:any):InternalXfer {
@ -201,7 +187,7 @@ export interface BankItemRequest {
export interface BankItemResponse extends BasicResponse {} export interface BankItemResponse extends BasicResponse {}
export class BankItem extends BasicOrder{ export class BankItem extends BasicOrder{
order_type = 'BankItem' order_type = "BankItem";
originalRequest:BankItemRequest originalRequest:BankItemRequest
originalResponse?:BankItemResponse originalResponse?:BankItemResponse
@ -214,33 +200,27 @@ export class BankItem extends BasicOrder {
} }
} }
async tick(r:RefStore, api:LTOApi):Promise<void> { async tick(r:RefStore, api:LTOApi):Promise<void> {
if (this.state !== 'PENDING') { if(this.state !== "PENDING" ){
return return
} }
this.mark('WORKING') this.mark("WORKING")
return api return api.BankAction<BankItemRequest, BankItemResponse>("bank-item",this.originalRequest)
.BankAction<BankItemRequest, BankItemResponse>('bank-item', this.originalRequest) .then((x)=>{
.then(x => { debug("BankItem",x)
debug('BankItem', x) if(x.status == 200){
if (x.status === 'success') {
this.stage = 1 this.stage = 1
this.originalResponse = x this.originalResponse = x
this.mark('SUCCESS') this.mark("SUCCESS")
if (this.details?.origin_path && this.details?.item_uid && this.details?.count) { const origin_item = r.invs.value.get(this.details?.origin_path!)!.items[this.details?.item_uid!]!
const inventory = r.invs.value.get(this.details.origin_path) origin_item.item_count = origin_item.item_count - this.details?.count!
const origin_item = inventory?.items[this.details.item_uid]
if (origin_item) {
origin_item.item_count = origin_item.item_count - this.details.count
}
}
}else { }else {
throw x.message ? x.message : 'unknown error' throw x.msg ? x.msg : "unknown error"
} }
}) })
.catch(e => { .catch((e)=>{
this.stage = 1 this.stage = 1
this.err = e this.err = e
this.mark('ERROR') this.mark("ERROR")
}) })
} }
@ -252,6 +232,7 @@ export class BankItem extends BasicOrder {
} }
} }
export interface PrivateMarketRequest { export interface PrivateMarketRequest {
item_uid:string item_uid:string
qty:string qty:string
@ -264,7 +245,7 @@ export interface PrivateMarketRequest {
export interface PrivateMarketResponse extends BasicResponse {} export interface PrivateMarketResponse extends BasicResponse {}
export class PrivateMarket extends BasicOrder{ export class PrivateMarket extends BasicOrder{
order_type = 'PrivateMarket' order_type = "PrivateMarket";
originalRequest:PrivateMarketRequest originalRequest:PrivateMarketRequest
originalResponse?:PrivateMarketResponse originalResponse?:PrivateMarketResponse
@ -279,40 +260,37 @@ export class PrivateMarket extends BasicOrder {
qty: details.count.toString(), qty: details.count.toString(),
account: details.origin_account, account: details.origin_account,
private: 1, private: 1,
currency: '0', currency: "0",
price: 1, price: 1,
} }
} }
async tick(r:RefStore, api:LTOApi):Promise<void> { async tick(r:RefStore, api:LTOApi):Promise<void> {
if (this.state !== 'PENDING') { if(this.state !== "PENDING" ){
return return
} }
this.mark('WORKING') this.mark("WORKING")
return api return api.BankAction<PrivateMarketRequest, PrivateMarketResponse>("sell-item",this.originalRequest)
.BankAction<PrivateMarketRequest, PrivateMarketResponse>('sell-item', this.originalRequest) .then((x)=>{
.then(x => { debug("PrivateMarket",x)
debug('PrivateMarket', x) if(x.status == 200){
if (x.status === 'success') {
this.stage = 1 this.stage = 1
this.originalResponse = x this.originalResponse = x
this.mark('SUCCESS') this.mark("SUCCESS")
this.listingId = x.data.listing_id this.listingId = x.data["listing_id"]
this.listingHash = x.data.hash this.listingHash = x.data["hash"]
if (this.details?.origin_path && this.details?.item_uid && this.details?.count) { try{
const inventory = r.invs.value.get(this.details.origin_path) const origin_item = r.invs.value.get(this.details?.origin_path!)!.items[this.details?.item_uid!]!
const origin_item = inventory?.items[this.details.item_uid] origin_item.item_count = origin_item.item_count - this.details?.count!
if (origin_item) { }catch(e){
origin_item.item_count = origin_item.item_count - this.details.count
}
} }
}else { }else {
throw x.message ? x.message : 'unknown error' throw x.msg ? x.msg : "unknown error"
} }
}) })
.catch(e => { .catch((e)=>{
this.stage = 1 this.stage = 1
this.err = e this.err = e
this.mark('ERROR') this.mark("ERROR")
}) })
} }
@ -326,6 +304,7 @@ export class PrivateMarket extends BasicOrder {
} }
} }
export interface MarketMoveRequest { export interface MarketMoveRequest {
listing_id?: string listing_id?: string
qty:string qty:string
@ -337,8 +316,9 @@ export interface MarketMoveResponse extends BasicResponse {
item_uid: string item_uid: string
} }
export class MarketMove extends PrivateMarket { export class MarketMove extends PrivateMarket {
order_type = 'MarketMove' order_type = "MarketMove";
moveRequest:MarketMoveRequest moveRequest:MarketMoveRequest
moveResponse?:MarketMoveResponse moveResponse?:MarketMoveResponse
@ -351,52 +331,51 @@ export class MarketMove extends PrivateMarket {
constructor(details:TxnDetails) { constructor(details:TxnDetails) {
super(details) super(details)
this.moveStage = 0 this.moveStage = 0
this.moveState = 'PENDING' this.moveState = "PENDING"
this.newUid = '' this.newUid = ""
this.moveRequest = { this.moveRequest = {
qty: details.count.toString(), qty: details.count.toString(),
account: details.target_account, account: details.target_account,
character: details.target_path.includes('/') ? details.target : '0', character: (details.target_path.includes("/")) ? details.target : "0" ,
listing_id: '', // not initially populated listing_id: "", // not initially populated
} }
} }
async tick(r:RefStore, api:LTOApi):Promise<void> { async tick(r:RefStore, api:LTOApi):Promise<void> {
try { try {
await super.tick(r, api) await super.tick(r, api)
} catch (_e) { }catch(e){
return return
} }
switch(super.status()) { switch(super.status()) {
case 'SUCCESS': case "SUCCESS":
break break;
case 'ERROR': case "ERROR":
this.moveState = 'ERROR' this.moveState = "ERROR"
return return
default: default:
return return
} }
if (this.moveState !== 'PENDING') { if(this.moveState !== "PENDING" ){
return return
} }
this.moveRequest.listing_id = `${this.listingId}-${this.listingHash}` this.moveRequest.listing_id = `${this.listingId}-${this.listingHash}`
this.moveState = 'WORKING' this.moveState = "WORKING"
return api return api.BankAction<MarketMoveRequest, MarketMoveResponse>("buy-from-order",this.moveRequest)
.BankAction<MarketMoveRequest, MarketMoveResponse>('buy-from-order', this.moveRequest) .then((x)=>{
.then(x => { debug("MarketMove",x)
debug('MarketMove', x)
this.moveResponse = x this.moveResponse = x
if (x.status === 'success') { if(x.status == 200){
this.moveStage = 1 this.moveStage = 1
this.moveState = 'SUCCESS' this.moveState = "SUCCESS"
this.newUid = x.item_uid this.newUid = x.item_uid
}else { }else {
throw x ? x : 'unknown error' throw x ? x : "unknown error"
} }
}) })
.catch(e => { .catch((e)=>{
this.moveStage = 1 this.moveStage = 1
this.err = e this.err = e
this.moveState = 'ERROR' this.moveState = "ERROR"
}) })
} }
@ -418,7 +397,7 @@ export class MarketMove extends PrivateMarket {
} }
export class MarketMoveToChar extends MarketMove { export class MarketMoveToChar extends MarketMove {
order_type = 'MarketMoveToChar' order_type = "MarketMoveToChar";
charRequest:InternalXferRequest charRequest:InternalXferRequest
charResponse?:InternalXferResponse charResponse?:InternalXferResponse
@ -429,9 +408,9 @@ export class MarketMoveToChar extends MarketMove {
constructor(details:TxnDetails) { constructor(details:TxnDetails) {
super(details) super(details)
this.charStage = 0 this.charStage = 0
this.charState = 'PENDING' this.charState = "PENDING"
this.charRequest = { this.charRequest = {
item_uid: '', item_uid: "",
qty: details.count.toString(), qty: details.count.toString(),
new_char: details.target, new_char: details.target,
account: details.target_account, account: details.target_account,
@ -440,39 +419,38 @@ export class MarketMoveToChar extends MarketMove {
async tick(r:RefStore, api:LTOApi):Promise<void> { async tick(r:RefStore, api:LTOApi):Promise<void> {
try { try {
await super.tick(r, api) await super.tick(r, api)
} catch (_e) { }catch(e){
return return
} }
switch(super.status()) { switch(super.status()) {
case 'SUCCESS': case "SUCCESS":
break break;
case 'ERROR': case "ERROR":
this.charState = 'ERROR' this.charState = "ERROR"
return return
default: default:
return return
} }
if (this.charState !== 'PENDING') { if(this.charState !== "PENDING" ){
return return
} }
this.charState = 'WORKING' this.charState = "WORKING"
this.charRequest.item_uid = this.newUid this.charRequest.item_uid = this.newUid
return api return api.BankAction<InternalXferRequest, InternalXferResponse>("internal-xfer-item",this.charRequest)
.BankAction<InternalXferRequest, InternalXferResponse>('internal-xfer-item', this.charRequest) .then((x)=>{
.then(x => { debug("MarketMoveToChar",x)
debug('MarketMoveToChar', x)
this.charResponse = x this.charResponse = x
if (x.status === 'success') { if(x.status == 200){
this.charStage = 1 this.charStage = 1
this.charState = 'SUCCESS' this.charState = "SUCCESS"
}else { }else {
throw x ? x : 'unknown error' throw x ? x : "unknown error"
} }
}) })
.catch(e => { .catch((e)=>{
this.charStage = 1 this.charStage = 1
this.err = e this.err = e
this.charState = 'ERROR' this.charState = "ERROR"
}) })
} }

View File

@ -1,35 +1,28 @@
import { RefStore } from '../../state/state' import { RefStore } from "../../state/state";
import { Serializable } from '../storage' import { Serializable } from "../storage";
import { TricksterCharacter } from '../trickster' import { LTOApi } from "./api";
import { LTOApi } from './api' import { pathIsBank, splitPath } from "./lifeto";
import { pathIsBank, splitPath } from './lifeto' import { BankItem, InternalXfer, InvalidOrder, MarketMove, Order,MarketMoveToChar, TxnDetails } from "./order";
import {
BankItem,
InternalXfer,
InvalidOrder,
MarketMove,
MarketMoveToChar,
Order,
TxnDetails,
} from './order'
export interface OrderDetails { export interface OrderDetails {
item_uid: string | 'galders' item_uid: string | "galders"
count:number count:number
origin_path:string origin_path:string
target_path:string target_path:string
} }
const notSupported = new InvalidOrder('not supported yet') const notSupported = new InvalidOrder("not supported yet")
const notFound = new InvalidOrder('character not found') const notFound = new InvalidOrder("character not found")
export class OrderTracker implements Serializable<OrderTracker> { export class OrderTracker implements Serializable<OrderTracker> {
orders: {[key:string]:Order} = {} orders: {[key:string]:Order} = {}
async tick(r:RefStore, api:LTOApi):Promise<any> { async tick(r:RefStore, api:LTOApi):Promise<any> {
let hasDirty = false let hasDirty = false
console.log("ticking")
for(const [id, order] of Object.entries(this.orders)) { for(const [id, order] of Object.entries(this.orders)) {
if (order.status() === 'SUCCESS' || order.status() === 'ERROR') { if(order.status() == "SUCCESS" || order.status() == "ERROR") {
console.log("finished order", order)
hasDirty = true hasDirty = true
delete this.orders[id] delete this.orders[id]
} }
@ -42,36 +35,36 @@ export class OrderTracker implements Serializable<OrderTracker> {
} }
parse(s: any): OrderTracker { parse(s: any): OrderTracker {
if (s === undefined) { if(s == undefined) {
return new OrderTracker() return new OrderTracker()
} }
if (s.orders === undefined) { if(s.orders == undefined) {
return new OrderTracker() return new OrderTracker()
} }
this.orders = {} this.orders = {}
const raw: Order[] = Object.values(s.orders) const raw: Order[] = Object.values(s.orders)
for(const o of raw) { for(const o of raw) {
let newOrder: Order | undefined let newOrder:Order | undefined = undefined
console.log("loading", o)
if(o.details){ if(o.details){
if (o.status() === 'SUCCESS' || o.status() === 'ERROR') { if(o.status() == "SUCCESS" || o.status() == "ERROR") {
continue continue
} }
switch(o.order_type) { switch(o.order_type) {
case 'InternalXfer': case "InternalXfer":
newOrder = new InternalXfer(o.details).parse(o) newOrder = new InternalXfer(o.details).parse(o)
break break;
case 'BankItem': case "BankItem":
newOrder = new BankItem(o.details).parse(o) newOrder = new BankItem(o.details).parse(o)
break break;
case 'MarketMove': case "MarketMove":
newOrder = new MarketMove(o.details).parse(o) newOrder = new MarketMove(o.details).parse(o)
break case "MarketMoveToChar":
case 'MarketMoveToChar':
newOrder = new MarketMoveToChar(o.details).parse(o) newOrder = new MarketMoveToChar(o.details).parse(o)
break break;
case 'InvalidOrder': case "InvalidOrder":
newOrder = new InvalidOrder('').parse(o) newOrder = new InvalidOrder("").parse(o)
break break;
} }
if(newOrder) { if(newOrder) {
this.orders[newOrder.action_id] = newOrder this.orders[newOrder.action_id] = newOrder
@ -83,14 +76,14 @@ export class OrderTracker implements Serializable<OrderTracker> {
} }
export class OrderSender { export class OrderSender {
constructor( r: RefStore
private orders: OrderTracker, constructor(r:RefStore) {
private chars: Map<string, TricksterCharacter>, this.r = r
) {} }
send(o:OrderDetails):Order { send(o:OrderDetails):Order {
const formed = this.form(o) const formed = this.form(o)
this.orders.orders[formed.action_id] = formed this.r.orders.value.orders[formed.action_id] = formed
return formed return formed
} }
@ -114,8 +107,8 @@ export class OrderSender {
return notSupported return notSupported
} }
bank_to_bank(o:OrderDetails): Order{ bank_to_bank(o:OrderDetails): Order{
const origin = this.chars.get(o.origin_path) const origin = this.r.chars.value.get(o.origin_path)
const target = this.chars.get(o.target_path) const target = this.r.chars.value.get(o.target_path)
if(!(origin && target)) { if(!(origin && target)) {
return notFound return notFound
} }
@ -123,45 +116,41 @@ export class OrderSender {
} }
bank_to_user(o:OrderDetails): Order{ bank_to_user(o:OrderDetails): Order{
// get the uid of the bank // get the uid of the bank
const origin = this.chars.get(o.origin_path) const origin = this.r.chars.value.get(o.origin_path)
const target = this.chars.get(o.target_path) const target = this.r.chars.value.get(o.target_path)
if(!(origin && target)) { if(!(origin && target)) {
return notFound return notFound
} }
const [_account, _name] = splitPath(target.path) const [account, name] = splitPath(target.path)
/*if(account != origin.path) { if(account != origin.path) {
return new MarketMoveToChar(this.transformInternalOrder(o)) return new MarketMoveToChar(this.transformInternalOrder(o))
}*/ }
return new InternalXfer(this.transformInternalOrder(o)) return new InternalXfer(this.transformInternalOrder(o))
} }
user_to_bank(o:OrderDetails): Order{ user_to_bank(o:OrderDetails): Order{
const origin = this.chars.get(o.origin_path) const origin = this.r.chars.value.get(o.origin_path)
const target = this.chars.get(o.target_path) const target = this.r.chars.value.get(o.target_path)
if(!(origin && target)) { if(!(origin && target)) {
return notFound return notFound
} }
const [_account, _name] = splitPath(origin.path) const [account, name] = splitPath(origin.path)
/*if(account != target.path) { if(account != target.path) {
return new MarketMove(this.transformInternalOrder(o)) return new MarketMove(this.transformInternalOrder(o))
}*/ }
return new BankItem(this.transformInternalOrder(o)) return new BankItem(this.transformInternalOrder(o))
} }
user_to_user(o:OrderDetails): Order{ user_to_user(o:OrderDetails): Order{
const origin = this.chars.get(o.origin_path) const origin = this.r.chars.value.get(o.origin_path)
const target = this.chars.get(o.target_path) const target = this.r.chars.value.get(o.target_path)
if(!(origin && target)) { if(!(origin && target)) {
return notFound return notFound
} }
// return new MarketMoveToChar(this.transformInternalOrder(o)) return new MarketMoveToChar(this.transformInternalOrder(o))
return new InternalXfer(this.transformInternalOrder(o))
} }
private transformInternalOrder(o:OrderDetails):TxnDetails { private transformInternalOrder(o:OrderDetails):TxnDetails {
const origin = this.chars.get(o.origin_path) const origin = this.r.chars.value.get(o.origin_path)!
const target = this.chars.get(o.target_path) const target = this.r.chars.value.get(o.target_path)!
if (!origin || !target) {
throw new Error(`Character not found: origin=${o.origin_path}, target=${o.target_path}`)
}
return { return {
origin: origin.id.toString(), origin: origin.id.toString(),
target: target.id.toString(), target: target.id.toString(),
@ -174,3 +163,4 @@ export class OrderSender {
} }
} }
} }

View File

@ -1,17 +1,13 @@
import { RefStore } from '../../state/state' import { RefStore } from "../../state/state";
import { Session } from '../session' import { bank_endpoint, Session } from "../session";
import { TricksterAccount, TricksterInventory } from '../trickster' import { TricksterAccount, TricksterInventory } from "../trickster";
import { BankEndpoint, LTOApi } from './api' import { BankEndpoint, LTOApi } from "./api";
export interface SessionBinding { export interface SessionBinding {
new(s:Session):LTOApi new(s:Session):LTOApi
} }
export const getLTOState = <A extends LTOApi>( export const getLTOState = <A extends LTOApi>(c: new (s:Session) => A,s:Session, r:RefStore): LTOApi => {
c: new (s: Session) => A, return new StatefulLTOApi(new c(s),r);
s: Session,
r: RefStore,
): LTOApi => {
return new StatefulLTOApi(new c(s), r)
} }
export class StatefulLTOApi implements LTOApi { export class StatefulLTOApi implements LTOApi {
@ -26,22 +22,21 @@ export class StatefulLTOApi implements LTOApi {
} }
GetInventory = async (path:string):Promise<TricksterInventory>=>{ GetInventory = async (path:string):Promise<TricksterInventory>=>{
const inv = await this.u.GetInventory(path) const inv = await this.u.GetInventory(path)
const existingInv = this.r.invs.value.get(inv.path) if(this.r.invs.value.get(inv.path)){
if (existingInv) { this.r.invs.value.get(inv.path)!.items = inv.items
existingInv.items = inv.items
if (inv.galders) {
existingInv.galders = inv.galders
}
}else{ }else{
this.r.invs.value.set(inv.path,inv) this.r.invs.value.set(inv.path,inv)
} }
if(inv.galders) {
this.r.invs.value.get(inv.path)!.galders = inv.galders
}
this.r.dirty.value = this.r.dirty.value + 1 this.r.dirty.value = this.r.dirty.value + 1
return inv return inv
} }
GetAccounts = async ():Promise<TricksterAccount[]> => { GetAccounts = async ():Promise<TricksterAccount[]> => {
const xs = await this.u.GetAccounts() const xs = await this.u.GetAccounts()
xs.forEach(x => { xs.forEach((x)=>{
x.characters.forEach(ch => { x.characters.forEach((ch)=>{
this.r.chars.value.set(ch.path,ch) this.r.chars.value.set(ch.path,ch)
}) })
}) })
@ -51,3 +46,4 @@ export class StatefulLTOApi implements LTOApi {
return this.u.GetLoggedin() return this.u.GetLoggedin()
} }
} }

View File

@ -1,36 +1,37 @@
import Handsontable from 'handsontable' import Handsontable from "handsontable"
import Core from 'handsontable/core' import numbro from 'numbro';
import { textRenderer } from 'handsontable/renderers' import { textRenderer } from "handsontable/renderers"
import numbro from 'numbro' import { TricksterInventory, TricksterItem } from "./trickster"
import { TricksterItem } from './trickster' import Core from "handsontable/core";
import { RefStore } from "../state/state";
export const BasicColumns = ['Image', 'Name', 'Count'] as const
export const DetailsColumns = ['Desc', 'Use'] as const export const BasicColumns = [
"Image","Name","Count",
export const MoveColumns = ['MoveCount', 'Move'] as const
export const TagColumns = ['All', 'Equip', 'Drill', 'Card', 'Quest', 'Consume', 'Compound'] as const
export const EquipmentColumns = ['MinLvl', 'Slots', 'RefineNumber', 'RefineState'] as const
export const StatsColumns = [
'AP',
'GunAP',
'AC',
'DX',
'MP',
'MA',
'MD',
'WT',
'DA',
'LK',
'HP',
'DP',
'HV',
] as const ] as const
export const HackColumns = [] as const export const DetailsColumns = [
"Desc","Use",
] as const
export const MoveColumns = [
"MoveCount","Move",
] as const
export const TagColumns = [
"All","Equip","Drill","Card","Quest","Consume", "Compound"
] as const
export const EquipmentColumns = [
"MinLvl","Slots","RefineNumber","RefineState",
] as const
export const StatsColumns = [
"AP","GunAP","AC","DX","MP","MA","MD","WT","DA","LK","HP","DP","HV",
] as const
export const HackColumns = [
] as const
export const ColumnNames = [ export const ColumnNames = [
...BasicColumns, ...BasicColumns,
@ -42,22 +43,22 @@ export const ColumnNames = [
...HackColumns, ...HackColumns,
] as const ] as const
export type ColumnName = (typeof ColumnNames)[number] export type ColumnName = typeof ColumnNames[number]
const c = (a:ColumnName | ColumnInfo):ColumnName => { const c = (a:ColumnName | ColumnInfo):ColumnName => {
switch(typeof a) { switch(typeof a) {
case 'string': case "string":
return a return a
case 'object': case "object":
return a.name return a.name
} }
} }
export const LazyColumn = c export const LazyColumn = c;
export const ColumnSorter = (a:ColumnName | ColumnInfo, b: ColumnName | ColumnInfo):number => { export const ColumnSorter = (a:ColumnName | ColumnInfo, b: ColumnName | ColumnInfo):number => {
const n1 = ColumnNames.indexOf(c(a)) let n1 = ColumnNames.indexOf(c(a))
const n2 = ColumnNames.indexOf(c(b)) let n2 = ColumnNames.indexOf(c(b))
if (n1 === n2) { if(n1 == n2) {
return 0 return 0
} }
return n1 > n2 ? 1 : -1 return n1 > n2 ? 1 : -1
@ -71,448 +72,406 @@ export interface ColumnInfo {
renderer?:any renderer?:any
filtering?:boolean filtering?:boolean
writable?:boolean writable?:boolean
getter(item: TricksterItem): string | number getter(item:TricksterItem):(string | number)
} }
class Image implements ColumnInfo { class Image implements ColumnInfo {
name:ColumnName = 'Image' name:ColumnName = 'Image'
displayName = ' ' displayName = " "
renderer = coverRenderer renderer = coverRenderer
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number) {
return item.item_image ? item.item_image : '' return item.image ? item.image : ""
} }
} }
function coverRenderer( function coverRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any,
_row: any,
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
if (stringifiedValue.startsWith('http')) { if (stringifiedValue.startsWith('http')) {
const img: any = document.createElement('IMG') const img:any = document.createElement('IMG');
img.src = value img.src = value;
Handsontable.dom.addEvent(img, 'mousedown', event =>{ Handsontable.dom.addEvent(img, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(img) td.appendChild(img);
} else { } else {
} }
} }
class Name implements ColumnInfo { class Name implements ColumnInfo {
name: ColumnName = 'Name' name:ColumnName = "Name"
displayName = 'Name' displayName = "Name"
filtering = true filtering = true
renderer = nameRenderer renderer = nameRenderer
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_name return item.item_name
} }
} }
function nameRenderer( function nameRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any, let showText = stringifiedValue;
_row: any, const div= document.createElement('div');
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
const showText = stringifiedValue
const div = document.createElement('div')
div.innerHTML = showText div.innerHTML = showText
div.title = showText div.title = showText
div.style.maxWidth = '20ch' div.style.maxWidth = "20ch"
div.style.textOverflow = 'ellipsis' div.style.textOverflow = "ellipsis"
div.style.overflow = 'hidden' div.style.overflow= "hidden"
div.style.whiteSpace = 'nowrap' div.style.whiteSpace= "nowrap"
Handsontable.dom.addEvent(div, 'mousedown', event =>{ Handsontable.dom.addEvent(div, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(div) td.appendChild(div);
td.classList.add('htLeft') td.classList.add("htLeft")
} }
class Count implements ColumnInfo { class Count implements ColumnInfo {
name: ColumnName = 'Count' name:ColumnName = "Count"
displayName = 'Count' displayName = "Count"
renderer = 'numeric' renderer = "numeric"
filtering = true filtering = true
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_count return item.item_count
} }
} }
class Move implements ColumnInfo {
name:ColumnName = "Move"
displayName = "Target"
writable = true
options = getMoveTargets
getter(item:TricksterItem):(string|number){
return "---------------------------------------------"
}
}
const getMoveTargets = (invs: string[]):string[] => { const getMoveTargets = (invs: string[]):string[] => {
const out: string[] = [] let out:string[] = [];
for(const k of invs){ for(const k of invs){
out.push(k) out.push(k)
} }
out.push('') out.push("")
out.push('') out.push("")
out.push('!TRASH') out.push("!TRASH")
return out return out
} }
class Move implements ColumnInfo {
name: ColumnName = 'Move'
displayName = 'Target'
writable = true
options = getMoveTargets
getter(_item: TricksterItem): string | number {
return '---------------------------------------------'
}
}
class MoveCount implements ColumnInfo { class MoveCount implements ColumnInfo {
name: ColumnName = 'MoveCount' name:ColumnName = "MoveCount"
displayName = 'Move #' displayName = "Move #"
renderer = moveCountRenderer renderer = moveCountRenderer
writable = true writable = true
getter(_item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return '' return ""
} }
} }
function moveCountRenderer( function moveCountRenderer(instance:Core, td:any, row:number, col:number, prop:any, value:any, cellProperties:any) {
instance: Core, let newValue = value;
td: any,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any,
) {
let newValue = value
if (Handsontable.helper.isNumeric(newValue)) { if (Handsontable.helper.isNumeric(newValue)) {
const numericFormat = cellProperties.numericFormat const numericFormat = cellProperties.numericFormat;
const cellCulture = numericFormat?.culture || '-' const cellCulture = numericFormat && numericFormat.culture || '-';
const cellFormatPattern = numericFormat?.pattern const cellFormatPattern = numericFormat && numericFormat.pattern;
const className = cellProperties.className || '' const className = cellProperties.className || '';
const classArr = className.length ? className.split(' ') : [] const classArr = className.length ? className.split(' ') : [];
if (typeof cellCulture !== 'undefined' && !numbro.languages()[cellCulture]) { if (typeof cellCulture !== 'undefined' && !numbro.languages()[cellCulture]) {
const shortTag: any = cellCulture.replace('-', '') const shortTag:any = cellCulture.replace('-', '');
const langData = (numbro as any)[shortTag] const langData = (numbro as any)[shortTag];
if (langData) { if (langData) {
numbro.registerLanguage(langData) numbro.registerLanguage(langData);
} }
} }
const totalCount = Number(instance.getCell(row,col-1)?.innerHTML) const totalCount = Number(instance.getCell(row,col-1)?.innerHTML)
numbro.setLanguage(cellCulture) numbro.setLanguage(cellCulture);
const num = numbro(newValue) const num = numbro(newValue)
if(totalCount < num.value()) { if(totalCount < num.value()) {
const newNum = numbro(totalCount) const newNum = numbro(totalCount)
newValue = newNum.format(cellFormatPattern || '0') newValue = newNum.format(cellFormatPattern || '0');
}else { }else {
newValue = num.format(cellFormatPattern || '0') newValue = num.format(cellFormatPattern || '0');
} }
if ( if (classArr.indexOf('htLeft') < 0 && classArr.indexOf('htCenter') < 0 &&
classArr.indexOf('htLeft') < 0 && classArr.indexOf('htRight') < 0 && classArr.indexOf('htJustify') < 0) {
classArr.indexOf('htCenter') < 0 && classArr.push('htRight');
classArr.indexOf('htRight') < 0 &&
classArr.indexOf('htJustify') < 0
) {
classArr.push('htRight')
} }
if (classArr.indexOf('htNumeric') < 0) { if (classArr.indexOf('htNumeric') < 0) {
classArr.push('htNumeric') classArr.push('htNumeric');
} }
cellProperties.className = classArr.join(' ') cellProperties.className = classArr.join(' ');
td.dir = 'ltr' td.dir = 'ltr';
newValue = `${newValue}x` newValue = newValue + "x"
}else { }else {
newValue = '' newValue = ""
} }
textRenderer(instance, td, row, col, prop, newValue, cellProperties) textRenderer(instance, td, row, col, prop, newValue, cellProperties);
} }
class Equip implements ColumnInfo { class Equip implements ColumnInfo {
name: ColumnName = 'Equip' name:ColumnName = "Equip"
displayName = 'equip' displayName = "equip"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.is_equip ? 1 : 0 return item.is_equip ? 1 : 0
} }
} }
class Drill implements ColumnInfo { class Drill implements ColumnInfo {
name: ColumnName = 'Drill' name:ColumnName = "Drill"
displayName = 'drill' displayName = "drill"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.is_drill ? 1 : 0 return item.is_drill ? 1 : 0
} }
} }
class All implements ColumnInfo { class All implements ColumnInfo {
name: ColumnName = 'All' name:ColumnName = "All"
displayName = 'swap' displayName = "swap"
getter(_: TricksterItem): string | number { getter(_:TricksterItem):(string|number){
return -10000 return -10000
} }
} }
class Card implements ColumnInfo { class Card implements ColumnInfo {
name: ColumnName = 'Card' name:ColumnName = "Card"
displayName = 'card' displayName = "card"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return cardFilter(item) ? 1 : 0 return cardFilter(item) ? 1 : 0
} }
} }
const cardFilter= (item:TricksterItem): boolean => { const cardFilter= (item:TricksterItem): boolean => {
return item.item_name.endsWith(' Card') || item.item_name.startsWith('Star Card') return (item.item_name.endsWith(" Card") || item.item_name.startsWith("Star Card"))
} }
class Compound implements ColumnInfo { class Compound implements ColumnInfo {
name: ColumnName = 'Compound' name:ColumnName = "Compound"
displayName = 'comp' displayName = "comp"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return compFilter(item) ? 1 : 0 return compFilter(item) ? 1 : 0
} }
} }
const compFilter= (item:TricksterItem): boolean => { const compFilter= (item:TricksterItem): boolean => {
return item.item_comment.toLowerCase().includes('compound item') return (item.item_desc.toLowerCase().includes("compound item"))
} }
class Quest implements ColumnInfo { class Quest implements ColumnInfo {
name: ColumnName = 'Quest' name:ColumnName = "Quest"
displayName = 'quest' displayName = "quest"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return questFilter(item) ? 1 : 0 return questFilter(item) ? 1 : 0
} }
} }
const questFilter = (_item: TricksterItem): boolean => { const questFilter= (item:TricksterItem): boolean => {
return false return false
} }
class Consume implements ColumnInfo { class Consume implements ColumnInfo {
name: ColumnName = 'Consume' name:ColumnName = "Consume"
displayName = 'eat' displayName = "eat"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return consumeFilter(item) ? 1 : 0 return consumeFilter(item) ? 1 : 0
} }
} }
const consumeFilter= (item:TricksterItem): boolean => { const consumeFilter= (item:TricksterItem): boolean => {
const tl = item.item_use.toLowerCase() const tl = item.item_use.toLowerCase()
return tl.includes('recover') || tl.includes('restores') return tl.includes("recover") || tl.includes("restores")
} }
class AP implements ColumnInfo { class AP implements ColumnInfo {
name: ColumnName = 'AP' name:ColumnName = "AP"
displayName = 'AP' displayName = "AP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.AP : '' return item.stats ? item.stats["AP"] : ""
} }
} }
class GunAP implements ColumnInfo { class GunAP implements ColumnInfo {
name: ColumnName = 'GunAP' name:ColumnName = "GunAP"
displayName = 'Gun AP' displayName = "Gun AP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats['Gun AP'] : '' return item.stats ? item.stats["Gun AP"] : ""
} }
} }
class AC implements ColumnInfo { class AC implements ColumnInfo {
name: ColumnName = 'AC' name:ColumnName = "AC"
displayName = 'AC' displayName = "AC"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.AC : '' return item.stats ? item.stats["AC"] : ""
} }
} }
class DX implements ColumnInfo { class DX implements ColumnInfo {
name: ColumnName = 'DX' name:ColumnName = "DX"
displayName = 'DX' displayName = "DX"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.DX : '' return item.stats ? item.stats["DX"] : ""
} }
} }
class MP implements ColumnInfo { class MP implements ColumnInfo {
name: ColumnName = 'MP' name:ColumnName = "MP"
displayName = 'MP' displayName = "MP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.MP : '' return item.stats ? item.stats["MP"] : ""
} }
} }
class MA implements ColumnInfo { class MA implements ColumnInfo {
name: ColumnName = 'MA' name:ColumnName = "MA"
displayName = 'MA' displayName = "MA"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.MA : '' return item.stats ? item.stats["MA"] : ""
} }
} }
class MD implements ColumnInfo { class MD implements ColumnInfo {
name: ColumnName = 'MD' name:ColumnName = "MD"
displayName = 'MD' displayName = "MD"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.MD : '' return item.stats ? item.stats["MD"] : ""
} }
} }
class WT implements ColumnInfo { class WT implements ColumnInfo {
name: ColumnName = 'WT' name:ColumnName = "WT"
displayName = 'WT' displayName = "WT"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.WT : '' return item.stats ? item.stats["WT"] : ""
} }
} }
class DA implements ColumnInfo { class DA implements ColumnInfo {
name: ColumnName = 'DA' name:ColumnName = "DA"
displayName = 'DA' displayName = "DA"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.DA : '' return item.stats ? item.stats["DA"] : ""
} }
} }
class LK implements ColumnInfo { class LK implements ColumnInfo {
name: ColumnName = 'LK' name:ColumnName = "LK"
displayName = 'LK' displayName = "LK"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.LK : '' return item.stats ? item.stats["LK"] : ""
} }
} }
class HP implements ColumnInfo { class HP implements ColumnInfo {
name: ColumnName = 'HP' name:ColumnName = "HP"
displayName = 'HP' displayName = "HP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.HP : '' return item.stats ? item.stats["HP"] : ""
} }
} }
class DP implements ColumnInfo { class DP implements ColumnInfo {
name: ColumnName = 'DP' name:ColumnName = "DP"
displayName = 'DP' displayName = "DP"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.DP : '' return item.stats ? item.stats["DP"] : ""
} }
} }
class HV implements ColumnInfo { class HV implements ColumnInfo {
name: ColumnName = 'HV' name:ColumnName = "HV"
displayName = 'HV' displayName = "HV"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.stats ? item.stats.HV : '' return item.stats ? item.stats["HV"] : ""
} }
} }
class MinLvl implements ColumnInfo { class MinLvl implements ColumnInfo {
name: ColumnName = 'MinLvl' name:ColumnName = "MinLvl"
displayName = 'lvl' displayName = "lvl"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
//TODO: //TODO:
return item.item_min_level ? item.item_min_level : '' return item.item_min_level? item.item_min_level:""
} }
} }
class Slots implements ColumnInfo { class Slots implements ColumnInfo {
name: ColumnName = 'Slots' name:ColumnName = "Slots"
displayName = 'slots' displayName = "slots"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
//TODO: //TODO:
return item.item_slots ? item.item_slots : '' return item.item_slots ? item.item_slots : ""
} }
} }
class RefineNumber implements ColumnInfo { class RefineNumber implements ColumnInfo {
name: ColumnName = 'RefineNumber' name:ColumnName = "RefineNumber"
displayName = 'refine' displayName = "refine"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.refine_level ? item.refine_level : 0 return item.refine_level ? item.refine_level : 0
} }
} }
class RefineState implements ColumnInfo { class RefineState implements ColumnInfo {
name: ColumnName = 'RefineState' name:ColumnName = "RefineState"
displayName = 'bork' displayName = "bork"
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.refine_state ? item.refine_state : 0 return item.refine_state ? item.refine_state : 0
} }
} }
class Desc implements ColumnInfo { class Desc implements ColumnInfo {
name: ColumnName = 'Desc' name:ColumnName = "Desc"
displayName = 'desc' displayName = "desc"
renderer = descRenderer renderer = descRenderer
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_comment return item.item_desc
} }
} }
function descRenderer( function descRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any, let showText = stringifiedValue;
_row: any, const div= document.createElement('div');
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
const showText = stringifiedValue
const div = document.createElement('div')
div.innerHTML = showText div.innerHTML = showText
div.title = showText div.title = showText
div.style.maxWidth = '30ch' div.style.maxWidth = "30ch"
div.style.textOverflow = 'ellipsis' div.style.textOverflow = "ellipsis"
div.style.overflow = 'hidden' div.style.overflow= "hidden"
div.style.whiteSpace = 'nowrap' div.style.whiteSpace= "nowrap"
Handsontable.dom.addEvent(div, 'mousedown', event =>{ Handsontable.dom.addEvent(div, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(div) td.appendChild(div);
td.classList.add('htLeft') td.classList.add("htLeft")
} }
class Use implements ColumnInfo { class Use implements ColumnInfo {
name: ColumnName = 'Use' name:ColumnName = "Use"
displayName = 'use' displayName = "use"
renderer = useRenderer renderer= useRenderer;
getter(item: TricksterItem): string | number { getter(item:TricksterItem):(string|number){
return item.item_use return item.item_use
} }
} }
function useRenderer( function useRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) {
_instance: any, const stringifiedValue = Handsontable.helper.stringify(value);
td: any, let showText = stringifiedValue;
_row: any, const div= document.createElement('div');
_col: any,
_prop: any,
value: any,
_cellProperties: any,
) {
const stringifiedValue = Handsontable.helper.stringify(value)
const showText = stringifiedValue
const div = document.createElement('div')
div.title = showText div.title = showText
div.innerHTML = showText div.innerHTML = showText
div.style.maxWidth = '30ch' div.style.maxWidth = "30ch"
div.style.textOverflow = 'ellipsis' div.style.textOverflow = "ellipsis"
div.style.overflow = 'hidden' div.style.overflow= "hidden"
div.style.whiteSpace = 'nowrap' div.style.whiteSpace= "nowrap"
Handsontable.dom.addEvent(div, 'mousedown', event =>{ Handsontable.dom.addEvent(div, 'mousedown', event =>{
event?.preventDefault() event!.preventDefault();
}) });
Handsontable.dom.empty(td) Handsontable.dom.empty(td);
td.appendChild(div) td.appendChild(div);
td.classList.add('htLeft') td.classList.add("htLeft")
} }
export const ColumnByNames = (...n:ColumnName[]) => { export const ColumnByNames = (...n:ColumnName[]) => {
return n.map(ColumnByName) return n.map(ColumnByName)
@ -521,8 +480,8 @@ export const ColumnByNames = (...n: ColumnName[]) => {
export const ColumnByName = (n:ColumnName) => { export const ColumnByName = (n:ColumnName) => {
return Columns[n] return Columns[n]
} }
export const test = <T extends ColumnInfo>(n: new () => T): [string, T] => { export const test = <T extends ColumnInfo>(n:(new ()=>T)):[string,T] => {
const nn = new n() let nn = new n()
return [nn.name, nn] return [nn.name, nn]
} }

File diff suppressed because one or more lines are too long

View File

@ -1,128 +1,132 @@
import axios, { AxiosError, AxiosResponse, Method } from 'axios' import axios, { AxiosResponse, Method } from "axios";
import qs from 'qs' import qs from "qs";
import { TricksterAccountInfo } from './trickster' import { getCookie, removeCookie } from "typescript-cookie";
export const SITE_ROOT = '/lifeto/'
export const API_ROOT = 'api/lifeto/' export const SITE_ROOT = "/lifeto/"
export const BANK_ROOT = 'v3/item-manager/'
export const MARKET_ROOT = 'marketplace-api/' export const API_ROOT = "api/lifeto/"
export const BANK_ROOT = "item-manager-action/"
export const MARKET_ROOT = "marketplace-api/"
const raw_endpoint = (name: string): string => {
return SITE_ROOT + name
}
const login_endpoint = (name:string)=>{ const login_endpoint = (name:string)=>{
return `${SITE_ROOT + name}?canonical=1` return SITE_ROOT + name
} }
export const api_endpoint = (name:string):string =>{ export const api_endpoint = (name:string):string =>{
return SITE_ROOT+API_ROOT + name return SITE_ROOT+API_ROOT + name
} }
export const bank_endpoint = (name:string):string =>{ export const bank_endpoint = (name:string):string =>{
return SITE_ROOT + API_ROOT + BANK_ROOT + name return SITE_ROOT+BANK_ROOT + name
} }
export const market_endpoint = (name:string):string =>{ export const market_endpoint = (name:string):string =>{
return SITE_ROOT+MARKET_ROOT+ name return SITE_ROOT+MARKET_ROOT+ name
} }
export const EndpointCreators = [api_endpoint, bank_endpoint, market_endpoint] export const EndpointCreators = [
api_endpoint,
bank_endpoint,
market_endpoint,
]
export type EndpointCreator = (typeof EndpointCreators)[number] export type EndpointCreator = typeof EndpointCreators[number]
export interface Session { export interface Session {
user:string
xsrf:string
csrf:string
request:(verb:Method,url:string,data:any,c?:EndpointCreator)=>Promise<any> request:(verb:Method,url:string,data:any,c?:EndpointCreator)=>Promise<any>
} }
export const login = async (user: string, pass: string): Promise<TokenSession> => { export class LoginHelper {
return axios user:string
.get(login_endpoint('login'), { pass:string
withCredentials: false, csrf?:string
maxRedirects: 0, constructor(user:string, pass:string){
xsrfCookieName: 'XSRF-TOKEN', this.user = user;
}) this.pass = pass;
.then(async () => {
return axios.post(
login_endpoint('login'),
{
login: user,
password: pass,
redirectTo: 'lifeto',
},
{
withCredentials: false,
maxRedirects: 0,
xsrfCookieName: 'XSRF-TOKEN',
},
)
})
.then(async () => {
return new TokenSession()
})
.catch(e => {
if (e instanceof AxiosError) {
if (e.code === 'ERR_BAD_REQUEST') {
throw new Error('invalid username/password')
} }
throw new Error(e.message) login = async ():Promise<TokenSession> =>{
} return axios.get(login_endpoint("login"),{withCredentials:false})
throw e .then(async (x)=>{
return axios.post(login_endpoint("login"),{
login:this.user,
password:this.pass,
redirectTo:"lifeto"
},{withCredentials:false})
.then(async (x)=>{
await sleep(100)
let xsrf= getCookie("XSRF-TOKEN")
return new TokenSession(this.user,this.csrf!, xsrf!)
}) })
})
}
} }
export const getAccountInfo = async (): Promise<TricksterAccountInfo> => { export class LogoutHelper{
return axios constructor(){
.get(raw_endpoint('settings/info'), { withCredentials: false })
.then((ans: AxiosResponse) => {
return ans.data
})
} }
logout = async ():Promise<void> =>{
export const logout = async (): Promise<void> => { return axios.get(login_endpoint("logout"),{withCredentials:false}).catch((e)=>{})
return axios
.get(login_endpoint('logout'), { withCredentials: false })
.catch(() => {})
.then(() => {})
} }
}
// Keep LoginHelper for backwards compatibility const sleep = async(ms:number)=> {
export const LoginHelper = { return new Promise(resolve => setTimeout(resolve, ms))
login,
info: getAccountInfo,
logout,
} }
export class TokenSession implements Session { export class TokenSession implements Session {
request = async ( csrf:string
verb: string, xsrf:string
url: string, user:string
data: any, constructor(name:string, csrf:string, xsrf: string){
c: EndpointCreator = api_endpoint, this.user = name
): Promise<AxiosResponse> => { this.csrf = csrf
let promise: Promise<AxiosResponse> this.xsrf = xsrf;
}
request = async (verb:string,url:string,data:any, c:EndpointCreator = api_endpoint):Promise<AxiosResponse> => {
let promise
switch (verb.toLowerCase()){ switch (verb.toLowerCase()){
case 'post': case "post":
promise = axios.post(c(url),data,this.genHeaders()) promise = axios.post(c(url),data,this.genHeaders())
break break;
case 'postform': case "postform":
promise = axios.postForm(c(url),data) promise = axios.postForm(c(url),data)
break break;
case 'postraw': { case "postraw":
const querystring = qs.stringify(data) const querystring = qs.stringify(data)
promise = axios.post(c(url),querystring,this.genHeaders()) promise = axios.post(c(url),querystring,this.genHeaders())
break break;
} case "get":
default: default:
promise = axios.get(c(url),this.genHeaders()) promise = axios.get(c(url),this.genHeaders())
} }
return promise return promise.then(x=>{
if(x.data){
try{
this.xsrf = x.data.split("xsrf-token")[1].split('\">')[0].replace("\" content=\"",'')
}catch(e){
}
}
if(x.headers['set-cookie']){
const cookies = x.headers['set-cookie'].map((y)=>{
return y.split("=")[1].split(";")[0];
})
this.xsrf = cookies[0]
}
return x
})
} }
genHeaders = ()=>{ genHeaders = ()=>{
const out = { const out = {
headers:{ headers:{
Accept: 'application/json', Accept: "application/json",
'Update-Insecure-Requests': 1, "Update-Insecure-Requests": 1,
}, },
withCredentials: true, withCredentials:true
}
if(this.xsrf){
(out.headers as any)["X-XSRF-TOKEN"] = this.xsrf.replace("%3D","=")
} }
return out return out
} }

76
src/lib/storage.ts Normal file
View File

@ -0,0 +1,76 @@
//class helper {
// Revive<T>(t:string, _type:string):string {
// return t
// }
// Revive<T>(t:string, _type:string[]):string[]{
// return t.split(",")
// }
// Revive<T>(t:string, _type:number):number {
// return Number(t)
// }
// Revive<T>(t:string, _type:number[]):number[]{
// return t.split(",").map(Number)
// }
//}
import { ColumnSet } from "./table"
import { TricksterAccount, TricksterCharacter, TricksterInventory } from "./trickster"
export const ARRAY_SEPERATOR = ","
let as = ARRAY_SEPERATOR
export interface Reviver<T> {
Murder(t:T):string
Revive(s:string):T
}
export const StoreStr= {
Murder: (s:string):string=>s,
Revive: (s:string):string=>s
}
export const StoreNum = {
Murder: (s:number):string=>s.toString(),
Revive: (s:string):number=>Number(s)
}
export const StoreStrSet = {
Murder: (s:Set<string>):string=>Array.from(s).join(as),
Revive: (s:string):Set<string>=>new Set(s.split(as))
}
export const StoreColSet = {
Murder: (s:ColumnSet):string=>Array.from(s.s.values()).join(as),
Revive: (s:string):ColumnSet=>new ColumnSet(s.split(as) as any)
}
export const StoreChars = {
Murder: (s:Map<string,TricksterCharacter>):string=>{
let o = JSON.stringify(Array.from(s.entries()))
return o
},
Revive: (s:string):Map<string,TricksterCharacter>=>new Map(JSON.parse(s)),
}
export const StoreAccounts = {
Murder: (s:Map<string,TricksterAccount>):string=>{
let o = JSON.stringify(Array.from(s.entries()))
return o
},
Revive: (s:string):Map<string,TricksterAccount>=>new Map(JSON.parse(s)),
}
export const StoreJsonable = {
Murder: <T>(s:T):string=>JSON.stringify(Object.entries(s)),
Revive: <T>(s:string):T=>JSON.parse(s),
}
export interface Serializable<T> {
parse(s:any):T
}
export const StoreSerializable = <T extends Serializable<T>>(n:(new ()=>T))=>{
return {
Murder: (s:T):string=>JSON.stringify(s),
Revive: (s:string):T=>new n().parse(JSON.parse(s))
}
}

View File

View File

@ -1,6 +1,12 @@
import { HotTableProps } from '@handsontable/react' import { HotTableProps } from "@handsontable/vue3/types"
import { ColumnInfo, ColumnName, ColumnSorter, Columns, LazyColumn } from './columns' import { TricksterInventory } from "./trickster"
import { TricksterInventory } from './trickster' import {ColumnInfo, ColumnName, Columns, ColumnSorter, LazyColumn} from "./columns"
import { ColumnSettings } from "handsontable/settings"
import { PredefinedMenuItemKey } from "handsontable/plugins/contextMenu"
import { ref } from "vue"
import Handsontable from "handsontable"
export interface InventoryTableOptions { export interface InventoryTableOptions {
columns: ColumnSet columns: ColumnSet
@ -10,12 +16,12 @@ export interface InventoryTableOptions {
} }
export interface Mappable<T> { export interface Mappable<T> {
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[] map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
} }
export class ColumnSet implements Mappable<ColumnInfo> { export class ColumnSet implements Set<ColumnInfo>, Mappable<ColumnInfo>{
s: Set<ColumnName> = new Set() s: Set<ColumnName> = new Set()
size: number size: number;
dirty = 0 dirty = ref(0)
constructor(i?:Iterable<ColumnInfo | ColumnName>){ constructor(i?:Iterable<ColumnInfo | ColumnName>){
if(i){ if(i){
for (const a of i) { for (const a of i) {
@ -27,46 +33,33 @@ export class ColumnSet implements Mappable<ColumnInfo> {
this.size = 0 this.size = 0
this.mark() this.mark()
} }
map<U>( map<U>(callbackfn: (value: ColumnInfo, index: number, array: ColumnInfo[]) => U, thisArg?: any): U[] {
callbackfn: (value: ColumnInfo, index: number, array: ColumnInfo[]) => U,
thisArg?: any,
): U[] {
return Array.from(this.values()).map(callbackfn, thisArg) return Array.from(this.values()).map(callbackfn, thisArg)
} }
[Symbol.iterator](): IterableIterator<ColumnInfo>{ [Symbol.iterator](): IterableIterator<ColumnInfo>{
return this.values() return this.values()
} }
[Symbol.toStringTag] = 'ColumnSet' [Symbol.toStringTag] = "ColumnSet";
entries(): IterableIterator<[ColumnInfo, ColumnInfo]>{ entries(): IterableIterator<[ColumnInfo, ColumnInfo]>{
return Array.from(this.values()) return Array.from(this.values()).map((x):[ColumnInfo,ColumnInfo]=>{return [x,x]}).values()
.map((x): [ColumnInfo, ColumnInfo] => {
return [x, x]
})
.values()
} }
keys(): IterableIterator<ColumnInfo>{ keys(): IterableIterator<ColumnInfo>{
return this.values() return this.values()
} }
forEach( forEach(callbackfn: (value: ColumnInfo, value2: ColumnInfo, set: Set<ColumnInfo>) => void, thisArg?: any): void{
callbackfn: (value: ColumnInfo, value2: ColumnInfo, set: Set<ColumnInfo>) => void, Array.from(this.values()).forEach((v)=>{
thisArg?: any,
): void {
Array.from(this.values()).forEach(v => {
if(this.has(v)) { if(this.has(v)) {
callbackfn(v, v, new Set(this.values())) callbackfn(v, v, new Set(this.values()))
} }
}, thisArg) }, thisArg)
} }
values(): IterableIterator<ColumnInfo>{ values(): IterableIterator<ColumnInfo>{
return Array.from(this.s.values()) return Array.from(this.s.values()).sort(ColumnSorter).map((a, b)=>{
.sort(ColumnSorter)
.map((a, _b) => {
return Columns[a] return Columns[a]
}) }).values()
.values()
} }
mark() { mark() {
this.dirty = this.dirty + 1 this.dirty.value = this.dirty.value + 1
this.size = this.s.size this.size = this.s.size
} }
add(value: ColumnInfo | ColumnName): this { add(value: ColumnInfo | ColumnName): this {
@ -104,7 +97,26 @@ export class InventoryTable {
getTableColumnNames(): string[] { getTableColumnNames(): string[] {
return this.o.columns.map(x=>x.displayName) return this.o.columns.map(x=>x.displayName)
} }
getTableColumnSettings() {} getTableColumnSettings(): ColumnSettings[] {
return this.o.columns.map(x=>{
let out:any = {
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
})
}
getTableRows():any[][] { getTableRows():any[][] {
return Object.values(this.inv.items) return Object.values(this.inv.items)
.filter((item):boolean=>{ .filter((item):boolean=>{
@ -112,11 +124,11 @@ export class InventoryTable {
return false return false
} }
let found = true let found = true
const hasAll = this.o.tags.has('All') let hasAll = this.o.tags.has("All")
if(this.o.tags.s.size > 0) { if(this.o.tags.s.size > 0) {
found = hasAll found = hasAll
for(const tag of this.o.tags.values()) { for(const tag of this.o.tags.values()) {
if (tag.name === 'All') { if(tag.name =="All") {
continue continue
} }
if(tag.getter(item) === 1) { if(tag.getter(item) === 1) {
@ -126,7 +138,7 @@ export class InventoryTable {
} }
return found return found
}) })
.map(item => { .map((item)=>{
return this.o.columns.map(x=>{ return this.o.columns.map(x=>{
return x.getter(item) return x.getter(item)
}) })
@ -141,7 +153,7 @@ export class InventoryTable {
data: dat, data: dat,
colHeaders:this.getTableColumnNames(), colHeaders:this.getTableColumnNames(),
columns:this.getTableColumnSettings(), columns:this.getTableColumnSettings(),
...s, ...s
}, },
} }
} }
@ -151,3 +163,49 @@ export interface TableRecipe {
data: any[][] data: any[][]
settings: HotTableProps 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();
this.deselectCell();
}
},
className: 'htLeft',
contextMenu: false,
readOnlyCellClassName: "",
licenseKey:"non-commercial-and-evaluation",
}
}

View File

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

View File

@ -1,239 +0,0 @@
import { createColumnHelper } from '@tanstack/react-table'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useMemo } from 'react'
import {
currentItemSelectionAtom,
itemSelectionSetActionAtom,
mouseDragSelectionStateAtom,
} from '@/state/atoms'
import { StatsColumns } from '../columns'
import { ItemWithSelection } from './defs'
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 [dragState, setDragState] = useAtom(mouseDragSelectionStateAtom)
const selected = useMemo(() => {
return c[0].has(row.original.item.id)
}, [c])
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
const newSelected = !selected
setItemSelection({
[row.original.item.id]: newSelected ? row.original.item.item_count : undefined,
})
setDragState({
isDragging: true,
lastAction: newSelected ? 'select' : 'deselect',
lastItemId: row.original.item.id,
})
}
const handleMouseEnter = () => {
if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) {
if (dragState.lastAction === 'select' && !selected) {
setItemSelection({
[row.original.item.id]: row.original.item.item_count,
})
} else if (dragState.lastAction === 'deselect' && selected) {
setItemSelection({
[row.original.item.id]: undefined,
})
}
}
}
return (
<button
type="button"
className={`no-select flex flex-row ${row.original.status?.selected ? 'animate-pulse' : ''}`}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
>
<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 pointer-events-none"
draggable={false}
/>
</div>
</button>
)
},
}),
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 dragState = useAtomValue(mouseDragSelectionStateAtom)
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
const selected = useMemo(() => {
return c[0].has(row.original.item.id)
}, [c])
const handleMouseEnter = () => {
if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) {
if (dragState.lastAction === 'select' && !selected) {
setItemSelection({
[row.original.item.id]: row.original.item.item_count,
})
} else if (dragState.lastAction === 'deselect' && selected) {
setItemSelection({
[row.original.item.id]: undefined,
})
}
}
}
return (
// biome-ignore lint/a11y/useSemanticElements: Using div for layout with input child
// biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select
<div
className={`flex flex-row select-none ${row.original.status?.selected ? 'bg-gray-200' : ''}`}
onMouseEnter={handleMouseEnter}
>
<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 (Number.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 }) {
const c = useAtomValue(currentItemSelectionAtom)
const setItemSelection = useSetAtom(itemSelectionSetActionAtom)
const [dragState, setDragState] = useAtom(mouseDragSelectionStateAtom)
const selected = useMemo(() => {
return c[0].has(row.original.item.id)
}, [c])
const handleMouseEnter = () => {
if (dragState.isDragging && dragState.lastItemId !== row.original.item.id) {
if (dragState.lastAction === 'select' && !selected) {
setItemSelection({
[row.original.item.id]: row.original.item.item_count,
})
} else if (dragState.lastAction === 'deselect' && selected) {
setItemSelection({
[row.original.item.id]: undefined,
})
}
}
}
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
const newSelected = !selected
setItemSelection({
[row.original.item.id]: newSelected ? row.original.item.item_count : undefined,
})
setDragState({
isDragging: true,
lastAction: newSelected ? 'select' : 'deselect',
lastItemId: row.original.item.id,
})
}
return (
// biome-ignore lint/a11y/useSemanticElements: Using div for text content
// biome-ignore lint/a11y/noStaticElementInteractions: Mouse interaction needed for drag select
<div
className="flex flex-row whitespace-pre cursor-pointer select-none hover:bg-gray-100"
onMouseEnter={handleMouseEnter}
onMouseDown={handleMouseDown}
>
<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 border-l border-r h-full">
<span>{stat}</span>
</div>
)
},
})
}),
],
}),
} as const
export const InventoryColumns = columns

View File

@ -1,27 +1,25 @@
export interface TricksterItem { export interface ItemExpireTime {
id: string text: string
unique_id: number us: string
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
item_expire_time?: string
refine_level?: number
refine_type?: number
refine_state?: number
item_image?: string
stats?: { [key: string]: any }
} }
export interface TricksterAccountInfo { export interface TricksterItem {
community_name: string unique_id: number;
email: string item_name: string;
item_id: number;
item_count: number;
item_desc: string;
item_use: string;
item_slots?: number;
item_min_level?: number;
is_equip?: boolean;
is_drill?: boolean;
item_expire_time?: ItemExpireTime;
refine_level?: number;
refine_type?: number;
refine_state?: number;
image?: string;
stats?: {[key: string]:any}
} }
export interface TricksterAccount { export interface TricksterAccount {
@ -30,7 +28,6 @@ export interface TricksterAccount {
} }
export interface Identifier { export interface Identifier {
account_name: string
account_id: number account_id: number
id: number id: number
name: string name: string
@ -41,58 +38,58 @@ export interface TricksterCharacter extends Identifier {
class: number class: number
base_job: number base_job: number
current_job: number current_job: number
current_type: number
} }
export interface TricksterInventory extends Identifier{ export interface TricksterInventory extends Identifier{
galders?:number galders?:number
items: Map<string, TricksterItem> items:{[key:string]:TricksterItem}
} }
const jobMap:{[key:number]:string} = { const jobMap:{[key:number]:string} = {
//---- job 1, fm //---- job 1, fm
1: 'schoolgirl', 1: "schoolgirl",
2: 'fighter', 2: "fighter",
3: 'librarian', 3: "librarian",
4: 'shaman', 4: "shaman",
5: 'archeologist', 5: "archeologist",
6: 'engineer', 6: "engineer",
7: 'model', 7: "model",
8: 'teacher', 8: "teacher",
//---- job 2 fm //---- job 2 fm
9: 'boxer', 9: "boxer",
10: 'warrior', 10: "warrior",
11: 'bard', 11: "bard",
12: 'magician', 12: "magician",
13: 'explorer', 13: "explorer",
14: 'inventor', 14: "inventor",
15: 'entertainer', 15: "entertainer",
16: 'card master', 16: "card master",
//---- //----
17: 'champion', 17: "champion",
18: 'duelist', 18: "duelist",
19: 'mercinary', 19: "mercinary",
20: 'gladiator', 20: "gladiator",
21: 'soul master', 21: "soul master",
22: 'witch', 22: "witch",
23: 'wizard', 23: "wizard",
24: 'dark lord', 24: "dark lord",
25: 'priest', 25: "priest",
26: 'thief master', 26: "thief master",
27: 'hunter lord', 27: "hunter lord",
28: 'cyber hunter', 28: "cyber hunter",
29: 'scientist', 29: "scientist",
30: 'primadonna', 30: "primadonna",
31: 'diva', 31: "diva",
32: 'duke', 32: "duke",
33: 'gambler', 33: "gambler",
} }
export const JobNumberToString = (n:number):string=> { export const JobNumberToString = (n:number):string=> {
if (n === -8) { if(n == -8) {
return 'bank' return "bank"
} }
if (jobMap[n] !== undefined) { if(jobMap[n] != undefined) {
return jobMap[n] return jobMap[n]
} }
return n.toString() return n.toString()

12
src/main.ts Normal file
View File

@ -0,0 +1,12 @@
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')

91
src/pages/login.vue Normal file
View File

@ -0,0 +1,91 @@
<template>
<div> {{loginString}} </div>
<section class="login_modal">
<div class="login_field">
<label for="userName">Username: </label>
<input
type="text"
id="login-username"
v-model="username"
required
/>
</div>
<div class="login_field">
<label for="password">Password: </label>
<input
type="password"
id="login-password"
v-model="password"
required
/>
</div>
<div class="login_field">
<button
type="button"
id="loginButton"
v-on:click="login()"
>Login</button>
<button
type="button"
id="logoutButton"
v-on:click="logout()"
>Logout</button>
</div>
</section>
</template>
<script lang="ts" setup>
const username = ref("")
const password = ref("")
const loginString = ref("not logged in")
const login = () => {
new LoginHelper(username.value, password.value).login()
.then((session)=>{
console.log(session, "adding to storage")
storage.AddSession(session)
window.location.reload()
}).catch((e)=>{
if(e.code == "ERR_BAD_REQUEST") {
alert("invalid username/password")
return
}
alert("unknown error, please report")
console.log(e)
})
}
const logout = () => {
new LogoutHelper().logout().then(()=>{
storage.RemoveSession()
localStorage.clear()
window.location.reload()
})
}
const s = storage.GetSession()
const api = new LTOApiv0(s)
if (s != undefined) {
username.value = s.user
}
const updateLogin = () => {
api.GetLoggedin().then((res)=>{
if(res) {
loginString.value = "logged in as " + s.user
}
})
}
updateLogin()
</script>
<script lang="ts">
import { defineComponent, computed, PropType, defineProps, defineEmits, ref} from 'vue';
import { LTOApiv0 } from '../lib/lifeto';
import { LoginHelper, LogoutHelper, Session } from '../lib/session';
import { storage } from '../session_storage';
</script>

View File

@ -1,19 +1,36 @@
import { Cookies, getCookie, removeCookie, setCookie} from 'typescript-cookie'
import { Session, TokenSession } from './lib/session' import { Session, TokenSession } from './lib/session'
export const LIFETO_COOKIE_PREFIX = 'LIFETO_PANEL_'
export const LIFETO_COOKIE_PREFIX="LIFETO_PANEL_"
export const nameCookie = (...s:string[]):string=>{ export const nameCookie = (...s:string[]):string=>{
return LIFETO_COOKIE_PREFIX + s.join('_').toUpperCase() return LIFETO_COOKIE_PREFIX+s.join("_").toUpperCase()
} }
export class Storage { export class Storage {
GetSession():Session { GetSession():Session {
return new TokenSession() const {user, xsrf, csrf} = {
user: getCookie(nameCookie("user"))!,
xsrf: getCookie(nameCookie("xsrf"))!,
csrf: getCookie(nameCookie("csrf"))!
} }
RemoveSession() {} return new TokenSession(user, xsrf, csrf)
AddSession(_s: Session) { }
// setCookie(nameCookie("xsrf"),s.xsrf)
RemoveSession() {
removeCookie(nameCookie("user"))
removeCookie(nameCookie("xsrf"))
removeCookie(nameCookie("csrf"))
}
AddSession(s:Session) {
setCookie(nameCookie("user"),s.user)
setCookie(nameCookie("xsrf"),s.xsrf)
setCookie(nameCookie("csrf"),s.csrf)
} }
} }
export const storage = new Storage() export const storage = new Storage()

View File

@ -1,44 +0,0 @@
// Re-export all atoms from the separate files for backward compatibility
// Auth-related atoms
export {
LTOApi,
loginStatusAtom,
charactersAtom,
selectedCharacterAtom,
} from './auth.atoms'
// 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'

View File

@ -1,91 +0,0 @@
import { AxiosError } from 'axios'
import { atomWithStorage } from 'jotai/utils'
import { atomWithQuery } from 'jotai-tanstack-query'
import { LTOApiv0 } from '../lib/lifeto'
import { LoginHelper, TokenSession } from '../lib/session'
import { TricksterCharacter } from '../lib/trickster'
export const LTOApi = new LTOApiv0(new TokenSession())
export const loginStatusAtom = atomWithQuery((_get) => {
return {
queryKey: ['login_status'],
enabled: true,
placeholderData: {
logged_in: false,
community_name: '...',
code: 102,
},
queryFn: async () => {
return LoginHelper.info()
.then(info => {
return {
logged_in: true,
community_name: info.community_name,
code: 200,
}
})
.catch(e => {
if (e instanceof AxiosError) {
return {
logged_in: false,
community_name: '...',
code: e.response?.status || 500,
}
}
throw e
})
},
}
})
export const charactersAtom = atomWithQuery((get) => {
const { data: loginStatus } = get(loginStatusAtom)
return {
queryKey: ['characters', loginStatus?.community_name || '...'],
enabled: !!loginStatus?.logged_in,
refetchOnMount: true,
queryFn: async () => {
return LTOApi.GetAccounts().then(x => {
if (!x) {
return undefined
}
const rawCharacters = x.flatMap(x => {
return x?.characters
})
const characterPairs: Record<
string,
{ bank?: TricksterCharacter; character?: TricksterCharacter }
> = {}
rawCharacters.forEach(
x => {
let item = characterPairs[x.account_name]
if (!item) {
item = {}
}
if (x.class === -8) {
item.bank = x
} else {
item.character = x
}
characterPairs[x.account_name] = item
},
[rawCharacters],
)
const cleanCharacterPairs = Object.values(characterPairs).filter(x => {
if (!(!!x.bank && !!x.character)) {
return false
}
return true
}) as Array<{ bank: TricksterCharacter; character: TricksterCharacter }>
return cleanCharacterPairs
})
},
}
})
export const selectedCharacterAtom = atomWithStorage<TricksterCharacter | undefined>(
'lto_state.selected_character',
undefined,
)

View File

@ -1,428 +0,0 @@
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
})

89
src/state/state.ts Normal file
View File

@ -0,0 +1,89 @@
import { defineStore, storeToRefs } from 'pinia'
import { BasicColumns, ColumnInfo, ColumnName, Columns, DetailsColumns, MoveColumns } from '../lib/columns'
import { OrderTracker } from '../lib/lifeto/order_manager'
import { Reviver, StoreAccounts, StoreChars, StoreColSet, StoreInvs, StoreSerializable, StoreStr, StoreStrSet } 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: ()=> {
let 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 = ()=> {
let 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 = ()=> {
let store = useStoreRef()
for(const [k, v] of Object.entries(StoreReviver)){
let coke;
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>;

View File

@ -1,113 +0,0 @@
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') {
}
}
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

@ -1,27 +1,18 @@
{ {
"compilerOptions": { "compilerOptions": {
"incremental": true,
"target": "esnext", "target": "esnext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "esnext",
"paths": { "moduleResolution": "node",
"@/*": ["./src/*"]
},
"types": ["node"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"module": "ESNext", "jsx": "preserve",
"moduleResolution": "bundler", "sourceMap": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": false, "isolatedModules": true,
"noEmit": true, "esModuleInterop": true,
"jsx": "react-jsx" "lib": ["esnext", "dom"],
"skipLibCheck": true
}, },
"include": ["src", "app", "index"], "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@ -1,9 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "ESNext", "module": "esnext",
"moduleResolution": "Node", "moduleResolution": "node"
"allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@ -1,40 +1,17 @@
// ignore the type error onthe next line
// @ts-ignore
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: { server: {
proxy: { proxy: {
// with options // with options
'/lifeto': { '/lifeto': {
target: 'https://beta.lifeto.co/', target: "https://beta.lifeto.co/",
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/lifeto/, ''), rewrite: (path) => path.replace(/^\/lifeto/, ''),
},
},
}, },
}
}
}) })
// https://vitejs.dev/config/
//export default defineConfig({
// plugins: [vue()],
// server: {
// proxy: {
// // with options
// '/lifeto': {
// target: "https://beta.lifeto.co/",
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/lifeto/, ''),
// },
// }
// }
//})

2594
yarn.lock

File diff suppressed because it is too large Load Diff