1
0
forked from a/lifeto-shop

Compare commits

...

22 Commits

Author SHA1 Message Date
a
3f58fac4a2
noot 2025-06-24 18:44:08 -05:00
a
0e759282b3
noot 2025-06-24 16:39:49 -05:00
a
21b6041941
noot 2025-06-24 00:37:47 -05:00
a
4d10b45b1e
noot 2025-06-23 01:36:30 -05:00
a
a0754399c7
noot 2025-06-23 01:33:03 -05:00
a
f00708e80d
noot 2025-06-20 01:18:37 -05:00
a
bd20e23b15
noot 2025-06-20 00:41:10 -05:00
a
8c9437c0be
noot 2025-05-25 00:17:58 -05:00
a
3d30ab085f
noot 2025-05-25 00:17:41 -05:00
a
79f90a7478
noot 2025-05-13 16:02:59 -05:00
a
908b4da72d
noot 2025-05-12 20:16:13 -05:00
a
50eda14192
noot 2025-05-12 20:16:01 -05:00
a
6e99ef1501
noot 2024-08-11 20:13:42 -05:00
a
e39ba4d052
noot 2024-08-05 16:22:28 -05:00
a
140c604502
noot 2024-08-05 16:08:21 -05:00
a
e913f85a0a
noot 2024-08-05 15:58:47 -05:00
a
cdbb83d6af
a 2024-04-08 22:56:47 -05:00
a
182bcfc13e
Merge branch 'raymonf-v2-api' 2024-04-08 22:53:11 -05:00
a
9869512d3c Merge branch 'master' into v2-api 2024-04-09 03:52:57 +00:00
a
a7ecda47d0
a 2024-04-08 22:49:29 -05:00
Raymonf
dfbb8ea7af the 2024-04-08 23:46:51 -04:00
Raymonf
09b4dcbea9 v2 2024-04-08 23:42:34 -04:00
66 changed files with 14244 additions and 3167 deletions

8
.biomeignore Normal file
View File

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

26
.dockerignore Normal file
View File

@ -0,0 +1,26 @@
# 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

74
CLAUDE.md Normal file
View File

@ -0,0 +1,74 @@
# 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

31
Caddyfile Normal file
View File

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

View File

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

View File

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

60
biome.json Normal file
View File

@ -0,0 +1,60 @@
{
"$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
}
}
}

22
fly.toml Normal file
View File

@ -0,0 +1,22 @@
# 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>Vite App</title> <title>lto inventory</title>
</head> </head>
<body style="overflow-y: hidden;"> <body style="overflow-y: hidden;" class="w-screen h-screen">
<div id="app"></div> <div id="app" class="w-full h-full"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>

View File

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

BIN
public/assets/cursor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

44
src/App.tsx Normal file
View File

@ -0,0 +1,44 @@
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>
*/

View File

@ -1,106 +0,0 @@
<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>

View File

@ -1,35 +0,0 @@
<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

@ -1,100 +0,0 @@
<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

@ -1,127 +0,0 @@
<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

@ -1,60 +0,0 @@
<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

@ -1,34 +0,0 @@
<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

@ -1,122 +0,0 @@
<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

@ -1,35 +0,0 @@
<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

@ -1,123 +0,0 @@
<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

@ -1,127 +0,0 @@
<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

@ -1,34 +0,0 @@
<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

@ -0,0 +1,260 @@
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

@ -0,0 +1,207 @@
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

@ -0,0 +1,182 @@
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

@ -0,0 +1,188 @@
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

@ -0,0 +1,235 @@
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

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

132
src/components/login.tsx Normal file
View File

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

28
src/index.css Normal file
View File

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

20
src/index.tsx Normal file
View File

@ -0,0 +1,20 @@
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,38 +1,33 @@
import Handsontable from "handsontable" import { TricksterItem } from '../trickster'
import numbro from 'numbro';
import { textRenderer } from "handsontable/renderers"
import { TricksterItem } from "../trickster"
import Core from "handsontable/core";
export const BasicColumns = [ export const BasicColumns = ['uid', 'Image', 'Name', 'Count'] as const
"uid","Image","Name","Count",
] as const
export const DetailsColumns = [ export const DetailsColumns = ['Desc', 'Use'] as const
"Desc","Use",
] as const
export const MoveColumns = [ export const MoveColumns = ['MoveCount', 'Move'] as const
"MoveCount","Move",
] as const
export const TagColumns = [ export const TagColumns = ['All', 'Equip', 'Drill', 'Card', 'Quest', 'Consume', 'Compound'] as const
"All","Equip","Drill","Card","Quest","Consume", "Compound"
] as const
export const EquipmentColumns = [ export const EquipmentColumns = ['MinLvl', 'Slots', 'RefineNumber', 'RefineState'] as const
"MinLvl","Slots","RefineNumber","RefineState",
] as const
export const StatsColumns = [ export const StatsColumns = [
"AP","GunAP","AC","DX","MP","MA","MD","WT","DA","LK","HP","DP","HV", 'HV',
'AC',
'LK',
'WT',
'HP',
'MA',
'DP',
'DX',
'MP',
'AP',
'MD',
'DA',
'GunAP',
] as const ] as const
export const DebugColumns = []
export const DebugColumns = [ export const HackColumns = [] as const
]
export const HackColumns = [
] as const
export const ColumnNames = [ export const ColumnNames = [
...BasicColumns, ...BasicColumns,
@ -44,22 +39,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 => {
let n1 = ColumnNames.indexOf(c(a)) const n1 = ColumnNames.indexOf(c(a))
let n2 = ColumnNames.indexOf(c(b)) const 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
@ -67,12 +62,11 @@ export const ColumnSorter = (a:ColumnName | ColumnInfo, b: ColumnName | ColumnI
export interface ColumnInfo { export interface ColumnInfo {
name: ColumnName name: ColumnName
displayName:string displayName: string
options?:(s:string[])=>string[] options?: (s: string[]) => string[]
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,432 +1,481 @@
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 {ColumnName, ColumnInfo} from "./column" import { ColumnInfo, ColumnName } from './column'
export const ColumnByNames = (...n:ColumnName[]) => { export const ColumnByNames = (...n: ColumnName[]) => {
return n.map(ColumnByName) return n.map(ColumnByName)
} }
export const ColumnByName = (n:ColumnName) => { export const ColumnByName = (n: ColumnName) => {
return Columns[n] return Columns[n]
} }
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.image ? item.image : "" return item.item_image ? item.item_image : ''
} }
} }
function coverRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function coverRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
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(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function nameRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
let showText = stringifiedValue; td: any,
const div= document.createElement('div'); _row: any,
_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(instance:Core, td:any, row:number, col:number, prop:any, value:any, cellProperties:any) { function moveCountRenderer(
let newValue = value; instance: Core,
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 && numericFormat.culture || '-'; const cellCulture = numericFormat?.culture || '-'
const cellFormatPattern = numericFormat && numericFormat.pattern; const cellFormatPattern = 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 (classArr.indexOf('htLeft') < 0 && classArr.indexOf('htCenter') < 0 && if (
classArr.indexOf('htRight') < 0 && classArr.indexOf('htJustify') < 0) { classArr.indexOf('htLeft') < 0 &&
classArr.push('htRight'); classArr.indexOf('htCenter') < 0 &&
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(instance:Core, td:any, row:number, col:number, prop:any, value:any, cellProperties:any) { function invisibleRenderer(
Handsontable.dom.empty(td); _instance: Core,
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_desc.toLowerCase().includes("compound item")) return item.item_comment.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_desc return item.item_comment
} }
} }
function descRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function descRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
let showText = stringifiedValue; td: any,
const div= document.createElement('div'); _row: any,
_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(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function useRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
let showText = stringifiedValue; td: any,
const div= document.createElement('div'); _row: any,
_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 } = {
Use: new Use(), Use: new Use(),
Desc: new Desc(), Desc: new Desc(),
Image: new Image(), Image: new Image(),
@ -460,5 +509,3 @@ export const Columns:{[Property in ColumnName]:ColumnInfo}= {
Compound: new Compound(), Compound: new Compound(),
uid: new uid(), uid: new uid(),
} }

View File

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

View File

@ -1,15 +1,18 @@
import { trace } from "loglevel" import { TricksterAccount, TricksterInventory } from '../trickster'
import { TricksterAccount, TricksterInventory } from "../trickster"
import { v4 as uuidv4 } from 'uuid';
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
export const BankEndpoints = ["internal-xfer-item", "bank-item", "sell-item","buy-from-order","cancel-order"] as const export const BankEndpoints = [
export type BankEndpoint = typeof BankEndpoints[number] 'internal-xfer-item',
'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>
GetAccounts:() =>Promise<Array<TricksterAccount>> GetAccounts: () => Promise<Array<TricksterAccount>>
GetLoggedin:() =>Promise<boolean> GetLoggedin: () => Promise<boolean>
BankAction:<T, D>(e:BankEndpoint, t:T) => Promise<D> BankAction: <T, D>(e: BankEndpoint, t: T) => Promise<D>
} }

View File

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

View File

@ -0,0 +1,121 @@
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,108 +1,137 @@
import { Axios, AxiosResponse, Method } from "axios" import { AxiosResponse, Method } from 'axios'
import log, { debug } from "loglevel" import log from 'loglevel'
import { bank_endpoint, EndpointCreator, market_endpoint, Session } from "../session" import { bank_endpoint, EndpointCreator, market_endpoint, Session } from '../session'
import { dummyChar, TricksterAccount, TricksterInventory, TricksterItem, TricksterWallet } from "../trickster" import { TricksterAccount, TricksterInventory, TricksterItem } 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
constructor(s:Session) { constructor(s: Session) {
this.s = s this.s = s
} }
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
case "sell-item": break
VERB = "POSTFORM" case 'sell-item':
//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(':', '')
} }
return this.s.request("GET", `item-manager/items/account/${char_path}`,undefined).then((ans:AxiosResponse)=>{ const type = char_path.includes('/') ? 'char' : 'account'
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)) {
let [char, val] = Object.entries(o.characters)[0] as [string,any] const [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 {
let [char, val] = Object.entries(o.characters)[0] as [string,any] const [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
} }
let out = { const out: TricksterInventory = {
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: Object.fromEntries((Object.entries(o.items) as any).map(([k, v]: [string, TricksterItem]):[string, TricksterItem]=>{ items: new Map(
(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)=>{ return ans.data.map((x: any): TricksterAccount => {
return { return {
name: x.name, name: x.name,
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)=>{ characters: [
{
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,56 +1,55 @@
import { LTOApi } from "./api" import { debug } from 'loglevel'
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid'
import { RefStore } from "../../state/state"; import { RefStore } from '../../state/state'
import { debug } from "loglevel"; import { LTOApi } from './api'
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
origin_path:string origin_path: string
target_path:string target_path: string
origin_account:string origin_account: string
target_account:string target_account: string
} }
export interface Envelope<REQ,RESP> { export interface Envelope<REQ, RESP> {
req: REQ req: REQ
resp: RESP resp: 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) {
this.state = t this.state = t
} }
abstract tick(r:RefStore, api:LTOApi):Promise<any> abstract tick(r: RefStore, api: LTOApi): Promise<any>
abstract status():string abstract status(): string
abstract progress():[number, number] abstract progress(): [number, number]
abstract error():string abstract error(): string
abstract order_type:OrderType abstract order_type: OrderType
parse(i:any):Order { parse(i: any): Order {
this.action_id = i.action_id this.action_id = i.action_id
this.details = i.details this.details = i.details
this.created = new Date(i.created) this.created = new Date(i.created)
@ -63,20 +62,20 @@ export abstract class BasicOrder extends Order {
stage: number stage: number
err?: string err?: string
constructor(details:TxnDetails) { constructor(details: TxnDetails) {
super(details) super(details)
this.stage = 0 this.stage = 0
} }
progress():[number,number]{ progress(): [number, number] {
return [this.stage, 1] return [this.stage, 1]
} }
status():string { status(): string {
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
this.err = i.err this.err = i.err
super.parse(i) super.parse(i)
@ -85,31 +84,38 @@ export abstract class BasicOrder extends Order {
} }
/// start user defined /// start user defined
export const OrderTypes = ["InvalidOrder","BankItem","InternalXfer", "PrivateMarket","MarketMove", "MarketMoveToChar"] export const OrderTypes = [
export type OrderType = typeof OrderTypes[number] 'InvalidOrder',
'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]
} }
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 {
super.parse(i) super.parse(i)
this.msg = i.msg this.msg = i.msg
return this return this
@ -117,27 +123,26 @@ export class InvalidOrder extends Order{
} }
export interface BasicResponse { export interface BasicResponse {
status: number status: string
data: any data: any
msg?: string message?: string
} }
export interface InternalXferRequest { export interface InternalXferRequest {
item_uid:string item_uid: string
qty:string qty: string
account:string account: string
new_char:string new_char: string
} }
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
constructor(details:TxnDetails) { constructor(details: TxnDetails) {
super(details) super(details)
this.originalRequest = { this.originalRequest = {
item_uid: details.item_uid, item_uid: details.item_uid,
@ -146,31 +151,40 @@ export class InternalXfer extends BasicOrder{
account: details.origin, account: details.origin,
} }
} }
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.BankAction<InternalXferRequest, InternalXferResponse>("internal-xfer-item",this.originalRequest) return api
.then((x:InternalXferResponse)=>{ .BankAction<InternalXferRequest, InternalXferResponse>(
if(x.status == 200){ 'internal-xfer-item',
this.originalRequest,
)
.then((x: InternalXferResponse) => {
if (x.status === 'success') {
this.originalResponse = x this.originalResponse = x
this.stage = 1 this.stage = 1
this.mark("SUCCESS") this.mark('SUCCESS')
const origin_item = r.invs.value.get(this.details?.origin_path!)!.items[this.details?.item_uid!]! if (this.details?.origin_path && this.details?.item_uid && this.details?.count) {
origin_item.item_count = origin_item.item_count - this.details?.count! const inventory = r.invs.value.get(this.details.origin_path)
}else{ const origin_item = inventory?.items[this.details.item_uid]
throw x.msg if (origin_item) {
origin_item.item_count = origin_item.item_count - this.details.count
}
}
} else {
throw x.message
} }
}) })
.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 {
super.parse(i) super.parse(i)
this.originalRequest = i.originalRequest this.originalRequest = i.originalRequest
this.originalResponse = i.originalResponse this.originalResponse = i.originalResponse
@ -179,19 +193,19 @@ export class InternalXfer extends BasicOrder{
} }
export interface BankItemRequest { export interface BankItemRequest {
item_uid:string item_uid: string
qty:string qty: string
account:string account: string
} }
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
constructor(details:TxnDetails) { constructor(details: TxnDetails) {
super(details) super(details)
this.originalRequest = { this.originalRequest = {
item_uid: details.item_uid, item_uid: details.item_uid,
@ -199,32 +213,38 @@ export class BankItem extends BasicOrder{
account: details.target, account: details.target,
} }
} }
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.BankAction<BankItemRequest, BankItemResponse>("bank-item",this.originalRequest) return api
.then((x)=>{ .BankAction<BankItemRequest, BankItemResponse>('bank-item', this.originalRequest)
debug("BankItem",x) .then(x => {
if(x.status == 200){ debug('BankItem', x)
if (x.status === 'success') {
this.stage = 1 this.stage = 1
this.originalResponse = x this.originalResponse = x
this.mark("SUCCESS") this.mark('SUCCESS')
const origin_item = r.invs.value.get(this.details?.origin_path!)!.items[this.details?.item_uid!]! if (this.details?.origin_path && this.details?.item_uid && this.details?.count) {
origin_item.item_count = origin_item.item_count - this.details?.count! const inventory = r.invs.value.get(this.details.origin_path)
}else { const origin_item = inventory?.items[this.details.item_uid]
throw x.msg ? x.msg : "unknown error" if (origin_item) {
origin_item.item_count = origin_item.item_count - this.details.count
}
}
} else {
throw x.message ? x.message : 'unknown error'
} }
}) })
.catch((e)=>{ .catch(e => {
this.stage = 1 this.stage = 1
this.err = e this.err = e
this.mark("ERROR") this.mark('ERROR')
}) })
} }
parse(i:any):BankItem { parse(i: any): BankItem {
super.parse(i) super.parse(i)
this.originalRequest = i.originalRequest this.originalRequest = i.originalRequest
this.originalResponse = i.originalResponse this.originalResponse = i.originalResponse
@ -232,69 +252,71 @@ export class BankItem extends BasicOrder{
} }
} }
export interface PrivateMarketRequest { export interface PrivateMarketRequest {
item_uid:string item_uid: string
qty:string qty: string
account:string account: string
currency:string currency: string
price:number price: number
private:number private: number
} }
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
listingId?: string listingId?: string
listingHash?: string listingHash?: string
constructor(details:TxnDetails) { constructor(details: TxnDetails) {
super(details) super(details)
this.originalRequest = { this.originalRequest = {
item_uid: details.item_uid, item_uid: details.item_uid,
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.BankAction<PrivateMarketRequest, PrivateMarketResponse>("sell-item",this.originalRequest) return api
.then((x)=>{ .BankAction<PrivateMarketRequest, PrivateMarketResponse>('sell-item', this.originalRequest)
debug("PrivateMarket",x) .then(x => {
if(x.status == 200){ debug('PrivateMarket', x)
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
try{ 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]
}catch(e){ if (origin_item) {
origin_item.item_count = origin_item.item_count - this.details.count
} }
}else { }
throw x.msg ? x.msg : "unknown error" } else {
throw x.message ? x.message : 'unknown error'
} }
}) })
.catch((e)=>{ .catch(e => {
this.stage = 1 this.stage = 1
this.err = e this.err = e
this.mark("ERROR") this.mark('ERROR')
}) })
} }
parse(i:any):PrivateMarket { parse(i: any): PrivateMarket {
super.parse(i) super.parse(i)
this.originalRequest = i.originalRequest this.originalRequest = i.originalRequest
this.originalResponse = i.originalResponse this.originalResponse = i.originalResponse
@ -304,11 +326,10 @@ export class PrivateMarket extends BasicOrder{
} }
} }
export interface MarketMoveRequest { export interface MarketMoveRequest {
listing_id?: string listing_id?: string
qty:string qty: string
account:string account: string
character: string character: string
} }
@ -316,77 +337,77 @@ 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
moveStage:number moveStage: number
moveState: TxnState moveState: TxnState
newUid: string newUid: string
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.BankAction<MarketMoveRequest, MarketMoveResponse>("buy-from-order",this.moveRequest) return api
.then((x)=>{ .BankAction<MarketMoveRequest, MarketMoveResponse>('buy-from-order', this.moveRequest)
debug("MarketMove",x) .then(x => {
debug('MarketMove', x)
this.moveResponse = x this.moveResponse = x
if(x.status == 200){ if (x.status === 'success') {
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'
}) })
} }
progress():[number,number]{ progress(): [number, number] {
return [this.stage + this.moveStage, 2] return [this.stage + this.moveStage, 2]
} }
status():string { status(): string {
return this.moveState return this.moveState
} }
parse(i:any):MarketMove { parse(i: any): MarketMove {
super.parse(i) super.parse(i)
this.moveRequest = i.moveRequest this.moveRequest = i.moveRequest
this.moveResponse = i.moveResponse this.moveResponse = i.moveResponse
@ -397,71 +418,72 @@ 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
charStage:number charStage: number
charState: TxnState charState: TxnState
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,
} }
} }
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.BankAction<InternalXferRequest, InternalXferResponse>("internal-xfer-item",this.charRequest) return api
.then((x)=>{ .BankAction<InternalXferRequest, InternalXferResponse>('internal-xfer-item', this.charRequest)
debug("MarketMoveToChar",x) .then(x => {
debug('MarketMoveToChar', x)
this.charResponse = x this.charResponse = x
if(x.status == 200){ if (x.status === 'success') {
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'
}) })
} }
progress():[number,number]{ progress(): [number, number] {
return [this.stage +this.moveStage+ this.charStage, 3] return [this.stage + this.moveStage + this.charStage, 3]
} }
status():string { status(): string {
return this.charState return this.charState
} }
parse(i:any):MarketMoveToChar { parse(i: any): MarketMoveToChar {
super.parse(i) super.parse(i)
this.charRequest = i.charRequest this.charRequest = i.charRequest
this.charResponse = i.charResponse this.charResponse = i.charResponse

View File

@ -1,72 +1,79 @@
import { RefStore } from "../../state/state"; import { RefStore } from '../../state/state'
import { Serializable } from "../storage"; import { Serializable } from '../storage'
import { LTOApi } from "./api"; import { TricksterCharacter } from '../trickster'
import { pathIsBank, splitPath } from "./lifeto"; import { LTOApi } from './api'
import { BankItem, InternalXfer, InvalidOrder, MarketMove, Order,MarketMoveToChar, TxnDetails } from "./order"; import { pathIsBank, splitPath } from './lifeto'
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]
} }
order.tick(r,api) order.tick(r, api)
} }
if(hasDirty){ if (hasDirty) {
r.dirty.value++ r.dirty.value++
} }
return return
} }
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 = undefined let newOrder: Order | 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)
case "MarketMoveToChar": break
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
} }
} }
@ -76,81 +83,85 @@ export class OrderTracker implements Serializable<OrderTracker> {
} }
export class OrderSender { export class OrderSender {
r: RefStore constructor(
constructor(r:RefStore) { private orders: OrderTracker,
this.r = r private chars: Map<string, TricksterCharacter>,
} ) {}
send(o:OrderDetails):Order { send(o: OrderDetails): Order {
const formed = this.form(o) const formed = this.form(o)
this.r.orders.value.orders[formed.action_id] = formed this.orders.orders[formed.action_id] = formed
return formed return formed
} }
form(o:OrderDetails):Order { form(o: OrderDetails): Order {
// bank to bank // bank to bank
if(pathIsBank(o.origin_path) && pathIsBank(o.target_path)) { if (pathIsBank(o.origin_path) && pathIsBank(o.target_path)) {
return this.bank_to_bank(o) return this.bank_to_bank(o)
} }
// bank to user // bank to user
if(pathIsBank(o.origin_path) && !pathIsBank(o.target_path)) { if (pathIsBank(o.origin_path) && !pathIsBank(o.target_path)) {
return this.bank_to_user(o) return this.bank_to_user(o)
} }
// user to bank // user to bank
if(!pathIsBank(o.origin_path) && pathIsBank(o.target_path)) { if (!pathIsBank(o.origin_path) && pathIsBank(o.target_path)) {
return this.user_to_bank(o) return this.user_to_bank(o)
} }
// user to user // user to user
if(!pathIsBank(o.origin_path) && !pathIsBank(o.target_path)) { if (!pathIsBank(o.origin_path) && !pathIsBank(o.target_path)) {
return this.user_to_user(o) return this.user_to_user(o)
} }
return notSupported return notSupported
} }
bank_to_bank(o:OrderDetails): Order{ bank_to_bank(o: OrderDetails): Order {
const origin = this.r.chars.value.get(o.origin_path) const origin = this.chars.get(o.origin_path)
const target = this.r.chars.value.get(o.target_path) const target = this.chars.get(o.target_path)
if(!(origin && target)) { if (!(origin && target)) {
return notFound return notFound
} }
return new MarketMove(this.transformInternalOrder(o)) return new MarketMove(this.transformInternalOrder(o))
} }
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.r.chars.value.get(o.origin_path) const origin = this.chars.get(o.origin_path)
const target = this.r.chars.value.get(o.target_path) const target = this.chars.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.r.chars.value.get(o.origin_path) const origin = this.chars.get(o.origin_path)
const target = this.r.chars.value.get(o.target_path) const target = this.chars.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.r.chars.value.get(o.origin_path) const origin = this.chars.get(o.origin_path)
const target = this.r.chars.value.get(o.target_path) const target = this.chars.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.r.chars.value.get(o.origin_path)! const origin = this.chars.get(o.origin_path)
const target = this.r.chars.value.get(o.target_path)! const target = this.chars.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(),
@ -163,4 +174,3 @@ export class OrderSender {
} }
} }
} }

View File

@ -1,49 +1,53 @@
import { RefStore } from "../../state/state"; import { RefStore } from '../../state/state'
import { bank_endpoint, Session } from "../session"; import { 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>(c: new (s:Session) => A,s:Session, r:RefStore): LTOApi => { export const getLTOState = <A extends LTOApi>(
return new StatefulLTOApi(new c(s),r); c: new (s: Session) => A,
s: Session,
r: RefStore,
): LTOApi => {
return new StatefulLTOApi(new c(s), r)
} }
export class StatefulLTOApi implements LTOApi { export class StatefulLTOApi implements LTOApi {
u: LTOApi u: LTOApi
r: RefStore r: RefStore
constructor(s:LTOApi, r:RefStore){ constructor(s: LTOApi, r: RefStore) {
this.u = s this.u = s
this.r=r this.r = r
} }
BankAction = <T,D>(e: BankEndpoint, t: T):Promise<D> => { BankAction = <T, D>(e: BankEndpoint, t: T): Promise<D> => {
return this.u.BankAction(e,t) return this.u.BankAction(e, t)
} }
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)
if(this.r.invs.value.get(inv.path)){ const existingInv = this.r.invs.value.get(inv.path)
this.r.invs.value.get(inv.path)!.items = inv.items if (existingInv) {
}else{ existingInv.items = inv.items
this.r.invs.value.set(inv.path,inv) if (inv.galders) {
existingInv.galders = inv.galders
} }
if(inv.galders) { } else {
this.r.invs.value.get(inv.path)!.galders = inv.galders this.r.invs.value.set(inv.path, inv)
} }
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)
}) })
}) })
return xs return xs
} }
GetLoggedin= async ():Promise<boolean>=>{ GetLoggedin = async (): Promise<boolean> => {
return this.u.GetLoggedin() return this.u.GetLoggedin()
} }
} }

View File

@ -1,37 +1,36 @@
import Handsontable from "handsontable" import Handsontable from 'handsontable'
import numbro from 'numbro'; import Core from 'handsontable/core'
import { textRenderer } from "handsontable/renderers" import { textRenderer } from 'handsontable/renderers'
import { TricksterInventory, TricksterItem } from "./trickster" import numbro from 'numbro'
import Core from "handsontable/core"; import { TricksterItem } from './trickster'
import { RefStore } from "../state/state";
export const BasicColumns = ['Image', 'Name', 'Count'] as const
export const BasicColumns = [ export const DetailsColumns = ['Desc', 'Use'] as const
"Image","Name","Count",
] as const
export const DetailsColumns = [ export const MoveColumns = ['MoveCount', 'Move'] as const
"Desc","Use",
] as const
export const MoveColumns = [ export const TagColumns = ['All', 'Equip', 'Drill', 'Card', 'Quest', 'Consume', 'Compound'] as const
"MoveCount","Move",
] as const
export const TagColumns = [ export const EquipmentColumns = ['MinLvl', 'Slots', 'RefineNumber', 'RefineState'] as const
"All","Equip","Drill","Card","Quest","Consume", "Compound"
] as const
export const EquipmentColumns = [
"MinLvl","Slots","RefineNumber","RefineState",
] as const
export const StatsColumns = [ export const StatsColumns = [
"AP","GunAP","AC","DX","MP","MA","MD","WT","DA","LK","HP","DP","HV", 'AP',
'GunAP',
'AC',
'DX',
'MP',
'MA',
'MD',
'WT',
'DA',
'LK',
'HP',
'DP',
'HV',
] as const ] as const
export const HackColumns = [ export const HackColumns = [] as const
] as const
export const ColumnNames = [ export const ColumnNames = [
...BasicColumns, ...BasicColumns,
@ -43,22 +42,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 => {
let n1 = ColumnNames.indexOf(c(a)) const n1 = ColumnNames.indexOf(c(a))
let n2 = ColumnNames.indexOf(c(b)) const 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
@ -66,426 +65,468 @@ export const ColumnSorter = (a:ColumnName | ColumnInfo, b: ColumnName | ColumnI
export interface ColumnInfo { export interface ColumnInfo {
name: ColumnName name: ColumnName
displayName:string displayName: string
options?:(s:string[])=>string[] options?: (s: string[]) => string[]
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.image ? item.image : "" return item.item_image ? item.item_image : ''
} }
} }
function coverRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function coverRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
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(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function nameRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
let showText = stringifiedValue; td: any,
const div= document.createElement('div'); _row: any,
_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 { const getMoveTargets = (invs: string[]): string[] => {
name:ColumnName = "Move" const out: string[] = []
displayName = "Target" for (const k of invs) {
writable = true
options = getMoveTargets
getter(item:TricksterItem):(string|number){
return "---------------------------------------------"
}
}
const getMoveTargets = (invs: string[]):string[] => {
let out:string[] = [];
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 MoveCount implements ColumnInfo { class Move implements ColumnInfo {
name:ColumnName = "MoveCount" name: ColumnName = 'Move'
displayName = "Move #" displayName = 'Target'
renderer = moveCountRenderer
writable = true writable = true
getter(item:TricksterItem):(string|number){ options = getMoveTargets
return "" getter(_item: TricksterItem): string | number {
return '---------------------------------------------'
} }
} }
function moveCountRenderer(instance:Core, td:any, row:number, col:number, prop:any, value:any, cellProperties:any) { class MoveCount implements ColumnInfo {
let newValue = value; name: ColumnName = 'MoveCount'
displayName = 'Move #'
renderer = moveCountRenderer
writable = true
getter(_item: TricksterItem): string | number {
return ''
}
}
function moveCountRenderer(
instance: Core,
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 && numericFormat.culture || '-'; const cellCulture = numericFormat?.culture || '-'
const cellFormatPattern = numericFormat && numericFormat.pattern; const cellFormatPattern = 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 (classArr.indexOf('htLeft') < 0 && classArr.indexOf('htCenter') < 0 && if (
classArr.indexOf('htRight') < 0 && classArr.indexOf('htJustify') < 0) { classArr.indexOf('htLeft') < 0 &&
classArr.push('htRight'); classArr.indexOf('htCenter') < 0 &&
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_desc.toLowerCase().includes("compound item")) return item.item_comment.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_desc return item.item_comment
} }
} }
function descRenderer(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function descRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
let showText = stringifiedValue; td: any,
const div= document.createElement('div'); _row: any,
_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(instance:any, td:any, row:any, col:any, prop:any, value:any, cellProperties:any) { function useRenderer(
const stringifiedValue = Handsontable.helper.stringify(value); _instance: any,
let showText = stringifiedValue; td: any,
const div= document.createElement('div'); _row: any,
_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)
} }
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] => {
let nn = new n() const nn = new n()
return [nn.name, nn] return [nn.name, nn]
} }
export const Columns:{[Property in ColumnName]:ColumnInfo}= { export const Columns: { [Property in ColumnName]: ColumnInfo } = {
Use: new Use(), Use: new Use(),
Desc: new Desc(), Desc: new Desc(),
Image: new Image(), Image: new Image(),

File diff suppressed because one or more lines are too long

View File

@ -1,132 +1,128 @@
import axios, { AxiosResponse, Method } from "axios"; import axios, { AxiosError, AxiosResponse, Method } from 'axios'
import qs from "qs"; import qs from 'qs'
import { getCookie, removeCookie } from "typescript-cookie"; import { TricksterAccountInfo } from './trickster'
export const SITE_ROOT = '/lifeto/'
export const SITE_ROOT = "/lifeto/" export const API_ROOT = 'api/lifeto/'
export const BANK_ROOT = 'v3/item-manager/'
export const MARKET_ROOT = 'marketplace-api/'
export const API_ROOT = "api/lifeto/" const raw_endpoint = (name: string): string => {
export const BANK_ROOT = "item-manager-action/"
export const MARKET_ROOT = "marketplace-api/"
const login_endpoint = (name:string)=>{
return SITE_ROOT + name return SITE_ROOT + name
} }
export const api_endpoint = (name:string):string =>{ const login_endpoint = (name: string) => {
return SITE_ROOT+API_ROOT + name return `${SITE_ROOT + name}?canonical=1`
} }
export const bank_endpoint = (name:string):string =>{ export const api_endpoint = (name: string): string => {
return SITE_ROOT+BANK_ROOT + name return SITE_ROOT + API_ROOT + name
}
export const bank_endpoint = (name: string): string => {
return SITE_ROOT + API_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 = [ export const EndpointCreators = [api_endpoint, bank_endpoint, market_endpoint]
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 request: (verb: Method, url: string, data: any, c?: EndpointCreator) => Promise<any>
xsrf:string
csrf:string
request:(verb:Method,url:string,data:any,c?:EndpointCreator)=>Promise<any>
} }
export class LoginHelper { export const login = async (user: string, pass: string): Promise<TokenSession> => {
user:string return axios
pass:string .get(login_endpoint('login'), {
csrf?:string withCredentials: false,
constructor(user:string, pass:string){ maxRedirects: 0,
this.user = user; xsrfCookieName: 'XSRF-TOKEN',
this.pass = pass;
}
login = async ():Promise<TokenSession> =>{
return axios.get(login_endpoint("login"),{withCredentials:false})
.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!)
}) })
.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)
}
throw e
})
} }
export class LogoutHelper{ export const getAccountInfo = async (): Promise<TricksterAccountInfo> => {
constructor(){ return axios
} .get(raw_endpoint('settings/info'), { withCredentials: false })
logout = async ():Promise<void> =>{ .then((ans: AxiosResponse) => {
return axios.get(login_endpoint("logout"),{withCredentials:false}).catch((e)=>{}) return ans.data
} })
} }
const sleep = async(ms:number)=> {
return new Promise(resolve => setTimeout(resolve, ms)) export const logout = async (): Promise<void> => {
return axios
.get(login_endpoint('logout'), { withCredentials: false })
.catch(() => {})
.then(() => {})
}
// Keep LoginHelper for backwards compatibility
export const LoginHelper = {
login,
info: getAccountInfo,
logout,
} }
export class TokenSession implements Session { export class TokenSession implements Session {
csrf:string request = async (
xsrf:string verb: string,
user:string url: string,
constructor(name:string, csrf:string, xsrf: string){ data: any,
this.user = name c: EndpointCreator = api_endpoint,
this.csrf = csrf ): Promise<AxiosResponse> => {
this.xsrf = xsrf; let promise: Promise<AxiosResponse>
} switch (verb.toLowerCase()) {
case 'post':
request = async (verb:string,url:string,data:any, c:EndpointCreator = api_endpoint):Promise<AxiosResponse> => { promise = axios.post(c(url), data, this.genHeaders())
let promise break
switch (verb.toLowerCase()){ case 'postform':
case "post": promise = axios.postForm(c(url), data)
promise = axios.post(c(url),data,this.genHeaders()) break
break; case 'postraw': {
case "postform":
promise = axios.postForm(c(url),data)
break;
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.then(x=>{ return promise
if(x.data){
try{
this.xsrf = x.data.split("xsrf-token")[1].split('\">')[0].replace("\" content=\"",'')
}catch(e){
} }
} genHeaders = () => {
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 = ()=>{
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
} }

View File

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

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

View File

@ -1,12 +1,6 @@
import { HotTableProps } from "@handsontable/vue3/types" import { HotTableProps } from '@handsontable/react'
import { TricksterInventory } from "./trickster" import { ColumnInfo, ColumnName, ColumnSorter, Columns, LazyColumn } from './columns'
import {ColumnInfo, ColumnName, Columns, ColumnSorter, LazyColumn} from "./columns" import { TricksterInventory } from './trickster'
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
@ -16,16 +10,16 @@ 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 Set<ColumnInfo>, Mappable<ColumnInfo>{ export class ColumnSet implements Mappable<ColumnInfo> {
s: Set<ColumnName> = new Set() s: Set<ColumnName> = new Set()
size: number; size: number
dirty = ref(0) dirty = 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) {
if(Columns[LazyColumn(a)]){ if (Columns[LazyColumn(a)]) {
this.s.add(LazyColumn(a)) this.s.add(LazyColumn(a))
} }
} }
@ -33,33 +27,46 @@ export class ColumnSet implements Set<ColumnInfo>, Mappable<ColumnInfo>{
this.size = 0 this.size = 0
this.mark() this.mark()
} }
map<U>(callbackfn: (value: ColumnInfo, index: number, array: ColumnInfo[]) => U, thisArg?: any): U[] { map<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()).map((x):[ColumnInfo,ColumnInfo]=>{return [x,x]}).values() return Array.from(this.values())
.map((x): [ColumnInfo, ColumnInfo] => {
return [x, x]
})
.values()
} }
keys(): IterableIterator<ColumnInfo>{ keys(): IterableIterator<ColumnInfo> {
return this.values() return this.values()
} }
forEach(callbackfn: (value: ColumnInfo, value2: ColumnInfo, set: Set<ColumnInfo>) => void, thisArg?: any): void{ forEach(
Array.from(this.values()).forEach((v)=>{ callbackfn: (value: ColumnInfo, value2: ColumnInfo, set: Set<ColumnInfo>) => void,
if(this.has(v)) { thisArg?: any,
): void {
Array.from(this.values()).forEach(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()).sort(ColumnSorter).map((a, b)=>{ return Array.from(this.s.values())
.sort(ColumnSorter)
.map((a, _b) => {
return Columns[a] return Columns[a]
}).values() })
.values()
} }
mark() { mark() {
this.dirty.value = this.dirty.value + 1 this.dirty = this.dirty + 1
this.size = this.s.size this.size = this.s.size
} }
add(value: ColumnInfo | ColumnName): this { add(value: ColumnInfo | ColumnName): this {
@ -71,89 +78,70 @@ export class ColumnSet implements Set<ColumnInfo>, Mappable<ColumnInfo>{
this.mark() this.mark()
this.s.clear() this.s.clear()
} }
delete(value: ColumnInfo | ColumnName): boolean{ delete(value: ColumnInfo | ColumnName): boolean {
this.mark() this.mark()
return this.s.delete(LazyColumn(value)) return this.s.delete(LazyColumn(value))
} }
has(value: ColumnInfo | ColumnName): boolean{ has(value: ColumnInfo | ColumnName): boolean {
return this.s.has(LazyColumn(value)) return this.s.has(LazyColumn(value))
} }
} }
export class InventoryTable { export class InventoryTable {
inv!:TricksterInventory inv!: TricksterInventory
o!: InventoryTableOptions o!: InventoryTableOptions
constructor(inv:TricksterInventory, o:InventoryTableOptions) { constructor(inv: TricksterInventory, o: InventoryTableOptions) {
this.setInv(inv) this.setInv(inv)
this.setOptions(o) this.setOptions(o)
} }
setOptions(o:InventoryTableOptions) { setOptions(o: InventoryTableOptions) {
this.o = o this.o = o
} }
setInv(inv:TricksterInventory) { setInv(inv: TricksterInventory) {
this.inv = inv this.inv = inv
} }
getTableColumnNames(): string[] { getTableColumnNames(): string[] {
return this.o.columns.map(x=>x.displayName) return this.o.columns.map(x => x.displayName)
} }
getTableColumnSettings(): ColumnSettings[] { getTableColumnSettings() {}
return this.o.columns.map(x=>{ getTableRows(): any[][] {
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[][] {
return Object.values(this.inv.items) return Object.values(this.inv.items)
.filter((item):boolean=>{ .filter((item): boolean => {
if(item.item_count <= 0) { if (item.item_count <= 0) {
return false return false
} }
let found = true let found = true
let hasAll = this.o.tags.has("All") const 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) {
return !hasAll return !hasAll
} }
} }
} }
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)
}) })
}) })
} }
BuildTable():TableRecipe { BuildTable(): TableRecipe {
const s = DefaultSettings() const s = DefaultSettings()
const dat = this.getTableRows() const dat = this.getTableRows()
return { return {
data: dat, data: dat,
settings: { settings: {
data: dat, data: dat,
colHeaders:this.getTableColumnNames(), colHeaders: this.getTableColumnNames(),
columns:this.getTableColumnSettings(), columns: this.getTableColumnSettings(),
...s ...s,
}, },
} }
} }
@ -163,49 +151,3 @@ 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",
}
}

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

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

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

@ -0,0 +1,239 @@
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,33 +1,36 @@
export interface ItemExpireTime { export interface TricksterItem {
text: string id: string
us: string unique_id: number
item_name: string
item_count: number
item_comment: string
item_use: string
item_slots?: number
item_tab: number
item_type: number
item_min_level?: number
is_equip?: boolean
is_drill?: boolean
item_expire_time?: string
refine_level?: number
refine_type?: number
refine_state?: number
item_image?: string
stats?: { [key: string]: any }
} }
export interface TricksterItem { export interface TricksterAccountInfo {
unique_id: number; community_name: string
item_name: string; email: 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 {
name:string name: string
characters: TricksterCharacter[] characters: TricksterCharacter[]
} }
export interface Identifier { export interface Identifier {
account_name: string
account_id: number account_id: number
id: number id: number
name: string name: string
@ -38,58 +41,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:{[key:string]:TricksterItem} items: Map<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()

View File

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

View File

@ -1,91 +0,0 @@
<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,36 +1,19 @@
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 nameCookie = (...s: string[]): string => {
export const LIFETO_COOKIE_PREFIX="LIFETO_PANEL_" return LIFETO_COOKIE_PREFIX + s.join('_').toUpperCase()
export const nameCookie = (...s:string[]):string=>{
return LIFETO_COOKIE_PREFIX+s.join("_").toUpperCase()
} }
export class Storage { export class Storage {
GetSession():Session { GetSession(): Session {
const {user, xsrf, csrf} = { return new TokenSession()
user: getCookie(nameCookie("user"))!,
xsrf: getCookie(nameCookie("xsrf"))!,
csrf: getCookie(nameCookie("csrf"))!
} }
return new TokenSession(user, xsrf, csrf) RemoveSession() {}
} 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()

44
src/state/atoms.ts Normal file
View File

@ -0,0 +1,44 @@
// 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'

91
src/state/auth.atoms.ts Normal file
View File

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

View File

@ -0,0 +1,428 @@
import Fuse from 'fuse.js'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { focusAtom } from 'jotai-optics'
import { atomWithQuery } from 'jotai-tanstack-query'
import { ItemWithSelection } from '@/lib/table/defs'
import { ItemMover } from '../lib/lifeto/item_mover'
import { TricksterCharacter, TricksterItem } from '../lib/trickster'
import { createSuperjsonStorage } from './storage'
import { LTOApi, selectedCharacterAtom } from './auth.atoms'
export const selectedTargetInventoryAtom = atom<TricksterCharacter | undefined>(undefined)
export const currentFilter = atom<undefined>(undefined)
export const currentCharacterInventoryAtom = atomWithQuery(get => {
const currentCharacter = get(selectedCharacterAtom)
return {
queryKey: ['inventory', currentCharacter?.path || '-'],
queryFn: async () => {
return LTOApi.GetInventory(currentCharacter?.path || '-')
},
enabled: !!currentCharacter,
// placeholderData: keepPreviousData,
}
})
const inventoryDisplaySettings = atomWithStorage<{
page_size: number
}>(
'preference.inventory_display_settings',
{
page_size: 25,
},
createSuperjsonStorage(),
)
export const inventoryDisplaySettingsAtoms = {
pageSize: focusAtom(inventoryDisplaySettings, x => x.prop('page_size')),
}
export const currentCharacterItemsAtom = atom(get => {
const { data: inventory } = get(currentCharacterInventoryAtom)
const items = inventory?.items || new Map<string, TricksterItem>()
return {
items,
searcher: new Fuse(Array.from(items.values()), {
keys: ['item_name'],
useExtendedSearch: true,
}),
}
})
export interface InventoryFilter {
search: string
tab: string
sort: string
sort_reverse: boolean
}
export const inventoryFilterAtom = atomWithStorage<InventoryFilter>(
'preference.inventory_filter',
{
search: '',
tab: '',
sort: '',
sort_reverse: false,
},
createSuperjsonStorage(),
)
export const preferenceInventorySearch = focusAtom(inventoryFilterAtom, x => x.prop('search'))
export const preferenceInventoryTab = focusAtom(inventoryFilterAtom, x => x.prop('tab'))
export const preferenceInventorySort = focusAtom(inventoryFilterAtom, x => x.prop('sort'))
export const preferenceInventorySortReverse = focusAtom(inventoryFilterAtom, x =>
x.prop('sort_reverse'),
)
export const setInventoryFilterTabActionAtom = atom(null, (get, set, tab: string) => {
set(inventoryFilterAtom, x => {
return {
...x,
tab,
}
})
// Reset pagination to first page when switching tabs
const pageSize = get(inventoryDisplaySettingsAtoms.pageSize)
set(inventoryPageRangeAtom, {
start: 0,
end: pageSize,
})
})
export const inventoryPageRangeAtom = atom({
start: 0,
end: 25,
})
export const nextInventoryPageActionAtom = atom(null, (get, set) => {
const { start, end } = get(inventoryPageRangeAtom)
set(inventoryPageRangeAtom, {
start: start + end,
end: end + end,
})
})
export const currentItemSelectionAtom = atom<[Map<string, number>, number]>([
new Map<string, number>(),
0,
])
export const currentInventorySearchQueryAtom = atom('')
export const filteredCharacterItemsAtom = atom(get => {
const { items } = get(currentCharacterItemsAtom)
const [selection] = get(currentItemSelectionAtom)
const filter = get(inventoryFilterAtom)
const out: ItemWithSelection[] = []
for (const [_, value] of items.entries()) {
if (filter.search !== '') {
if (!value.item_name.toLowerCase().includes(filter.search)) {
continue
}
}
if (filter.tab !== '') {
if (value.item_tab !== parseInt(filter.tab)) {
continue
}
}
let status: { selected: boolean } | undefined
if (selection.has(value.id)) {
status = {
selected: true,
}
}
out.push({ item: value, status })
}
switch (filter.sort) {
case 'count':
out.sort((a, b) => {
return b.item.item_count - a.item.item_count
})
break
case 'type':
out.sort((a, b) => {
return a.item.item_tab - b.item.item_tab
})
break
case 'name':
out.sort((a, b) => {
return a.item.item_name.localeCompare(b.item.item_name)
})
break
}
if (filter.sort && filter.sort_reverse) {
out.reverse()
}
return out
})
export const inventoryItemsCurrentPageAtom = atom(get => {
const items = get(filteredCharacterItemsAtom)
const { start, end } = get(inventoryPageRangeAtom)
return items.slice(start, end).map((item): ItemWithSelection => {
return item
})
})
export const rowSelectionLastActionAtom = atom<
| {
index: number
action: 'add' | 'remove'
}
| undefined
>(undefined)
export const mouseDragSelectionStateAtom = atom({
isDragging: false,
lastAction: null as 'select' | 'deselect' | null,
lastItemId: null as string | null,
})
export const clearItemSelectionActionAtom = atom(null, (_get, set) => {
set(currentItemSelectionAtom, [new Map<string, number>(), 0])
})
export const itemSelectionSetActionAtom = atom(
null,
(get, set, arg: Record<string, number | undefined>) => {
const cur = get(currentItemSelectionAtom)
for (const [key, value] of Object.entries(arg)) {
if (value === undefined) {
cur[0].delete(key)
} else {
cur[0].set(key, value)
}
}
set(currentItemSelectionAtom, [cur[0], cur[1] + 1])
},
)
export const itemSelectionSelectAllFilterActionAtom = atom(null, (get, set) => {
const cur = get(currentItemSelectionAtom)
const items = get(filteredCharacterItemsAtom)
for (const item of items) {
cur[0].set(item.item.id, item.item.item_count)
}
set(currentItemSelectionAtom, [cur[0], cur[1] + 1])
})
export const itemSelectionSelectAllPageActionAtom = atom(null, (get, set) => {
const cur = get(currentItemSelectionAtom)
const items = get(inventoryItemsCurrentPageAtom)
for (const item of items) {
cur[0].set(item.item.id, item.item.item_count)
}
set(currentItemSelectionAtom, [cur[0], cur[1] + 1])
})
export const paginateInventoryActionAtom = atom(null, (get, set, pages: number | undefined) => {
const inventoryRange = get(inventoryPageRangeAtom)
const pageSize = get(inventoryDisplaySettingsAtoms.pageSize)
const filteredItems = get(filteredCharacterItemsAtom)
if (pages === undefined) {
set(inventoryPageRangeAtom, {
start: 0,
end: pageSize,
})
return
}
if (pageSize > filteredItems.length) {
set(inventoryPageRangeAtom, {
start: 0,
end: filteredItems.length,
})
return
}
if (pages > 0) {
if (inventoryRange.end >= filteredItems.length) {
set(inventoryPageRangeAtom, {
start: 0,
end: pageSize,
})
return
}
} else if (pages < 0) {
if (inventoryRange.start <= 0) {
// Wrap around to the last page
const lastPageStart = Math.max(0, filteredItems.length - pageSize)
set(inventoryPageRangeAtom, {
start: lastPageStart,
end: filteredItems.length,
})
return
}
}
const delta = pages * pageSize
let newStart = inventoryRange.start + delta
let newEnd = inventoryRange.end + delta
// Handle negative start
if (newStart < 0) {
newStart = 0
newEnd = Math.min(pageSize, filteredItems.length)
}
// Handle end beyond items length
if (newEnd > filteredItems.length) {
newEnd = filteredItems.length
newStart = Math.max(0, newEnd - pageSize)
}
set(inventoryPageRangeAtom, {
start: newStart,
end: newEnd,
})
})
export interface MoveItemsResult {
totalItems: number
successCount: number
failedCount: number
errors: Array<{ itemId: string; error: string }>
}
export interface MoveConfirmationState {
isOpen: boolean
selectedItems: Map<string, { item: TricksterItem; count: number }>
sourceCharacter?: TricksterCharacter
targetCharacter?: TricksterCharacter
}
export const moveConfirmationAtom = atom<MoveConfirmationState>({
isOpen: false,
selectedItems: new Map(),
})
export const openMoveConfirmationAtom = atom(null, (get, set) => {
const [selectedItems] = get(currentItemSelectionAtom)
const sourceCharacter = get(selectedCharacterAtom)
const targetCharacter = get(selectedTargetInventoryAtom)
const { data: inventory } = get(currentCharacterInventoryAtom)
if (!sourceCharacter || !targetCharacter || !inventory) {
return
}
const itemsWithDetails = new Map<string, { item: TricksterItem; count: number }>()
selectedItems.forEach((count, itemId) => {
const item = inventory.items.get(itemId)
if (item) {
itemsWithDetails.set(itemId, { item, count })
}
})
set(moveConfirmationAtom, {
isOpen: true,
selectedItems: itemsWithDetails,
sourceCharacter,
targetCharacter,
})
})
export const closeMoveConfirmationAtom = atom(null, (_get, set) => {
set(moveConfirmationAtom, {
isOpen: false,
selectedItems: new Map(),
})
})
export const moveSelectedItemsAtom = atom(null, async (get, _set): Promise<MoveItemsResult> => {
const itemMover = new ItemMover(LTOApi)
const confirmationState = get(moveConfirmationAtom)
const selectedItems = confirmationState.isOpen
? new Map(
Array.from(confirmationState.selectedItems.entries()).map(([id, { count }]) => [id, count]),
)
: get(currentItemSelectionAtom)[0]
const sourceCharacter = confirmationState.sourceCharacter || get(selectedCharacterAtom)
const targetCharacter = confirmationState.targetCharacter || get(selectedTargetInventoryAtom)
const { data: sourceInventory } = get(currentCharacterInventoryAtom)
const result: MoveItemsResult = {
totalItems: selectedItems.size,
successCount: 0,
failedCount: 0,
errors: [],
}
if (!sourceCharacter || !targetCharacter) {
throw new Error('Source or target character not selected')
}
if (!sourceInventory) {
throw new Error('Source inventory not loaded')
}
if (selectedItems.size === 0) {
return result
}
// Track successful moves to update counts
const successfulMoves: Array<{ itemId: string; count: number }> = []
// Process each selected item
const movePromises = Array.from(selectedItems.entries()).map(async ([itemId, count]) => {
const item = sourceInventory.items.get(itemId)
if (!item) {
result.errors.push({ itemId, error: 'Item not found in inventory' })
result.failedCount++
return
}
try {
const isTargetBank = !targetCharacter.path.includes('/')
const moveResult = await itemMover.moveItem(
item.unique_id.toString(),
count,
isTargetBank ? undefined : targetCharacter.id.toString(),
isTargetBank ? targetCharacter.account_id.toString() : undefined,
)
if (moveResult.success) {
result.successCount++
successfulMoves.push({ itemId, count })
} else {
result.errors.push({ itemId, error: moveResult.error || 'Unknown error' })
result.failedCount++
}
} catch (error) {
result.errors.push({
itemId,
error: error instanceof Error ? error.message : 'Unknown error',
})
result.failedCount++
}
})
await Promise.all(movePromises)
// Update the inventory optimistically
if (successfulMoves.length > 0 && sourceInventory) {
const updatedItems = new Map(sourceInventory.items)
for (const { itemId, count } of successfulMoves) {
const item = updatedItems.get(itemId)
if (item) {
const newCount = item.item_count - count
if (newCount <= 0) {
// Remove item if count reaches 0
updatedItems.delete(itemId)
} else {
// Update item count
updatedItems.set(itemId, { ...item, item_count: newCount })
}
}
}
// Update the local inventory state
sourceInventory.items = updatedItems
// Trigger a refetch to sync with server
const { refetch } = get(currentCharacterInventoryAtom)
refetch()
}
return result
})

View File

@ -1,89 +0,0 @@
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>;

113
src/state/storage/index.ts Normal file
View File

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

View File

@ -1,8 +1,9 @@
{ {
"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,17 +1,40 @@
// 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: [vue()], plugins: [react(), tailwindcss()],
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/, ''),
// },
// }
// }
//})

2604
yarn.lock

File diff suppressed because it is too large Load Diff