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
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
FROM node:24-alpine as NODEBUILDER
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 . .
RUN npm install
RUN npx vite build
RUN corepack yarn build
FROM alpine:3.16
FROM caddy:2.10-alpine
WORKDIR /wd
COPY --from=GOBUILDER /wd/app.exe app.exe
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=NODEBUILDER /wd/dist dist
ENTRYPOINT [ "/wd/app.exe" ]

View File

@ -1,6 +1,9 @@
VERSION=v0.0.2
build:
docker build -t cr.aaaaa.news/lto:latest .
docker build -t tuxpa.in/a/lto:${VERSION} .
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" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<title>lto inventory</title>
</head>
<body style="overflow-y: hidden;">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<body style="overflow-y: hidden;" class="w-screen h-screen">
<div id="app" class="w-full h-full"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@ -2,29 +2,58 @@
"name": "lifeto-shop",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
"build": "vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
},
"dependencies": {
"@handsontable/vue3": "^12.0.1",
"@types/qs": "^6.9.7",
"@types/uuid": "^8.3.4",
"@vueuse/core": "^8.7.5",
"axios": "^0.27.2",
"handsontable": "^12.0.1",
"loglevel": "^1.8.0",
"pinia": "^2.0.14",
"qs": "^6.10.5",
"typescript-cookie": "^1.0.4",
"uuid": "^8.3.2",
"vue": "^3.2.25"
"@floating-ui/react": "^0.27.8",
"@handsontable/react": "^15.3.0",
"@mantine/hooks": "^8.0.0",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.76.0",
"@tanstack/react-table": "^8.21.3",
"@types/qs": "^6.9.18",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.4.1",
"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": {
"@vitejs/plugin-vue": "^2.3.3",
"typescript": "^4.5.4",
"vite": "^2.9.9",
"vue-tsc": "^0.34.7"
}
"@biomejs/biome": "^2.0.0",
"@tailwindcss/postcss": "^4.1.6",
"@types/node": "^22.15.18",
"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 numbro from 'numbro';
import { textRenderer } from "handsontable/renderers"
import { TricksterItem } from "../trickster"
import Core from "handsontable/core";
import { TricksterItem } from '../trickster'
export const BasicColumns = [
"uid","Image","Name","Count",
] as const
export const BasicColumns = ['uid', 'Image', 'Name', 'Count'] as const
export const DetailsColumns = [
"Desc","Use",
] as const
export const DetailsColumns = ['Desc', 'Use'] as const
export const MoveColumns = [
"MoveCount","Move",
] as const
export const MoveColumns = ['MoveCount', 'Move'] as const
export const TagColumns = [
"All","Equip","Drill","Card","Quest","Consume", "Compound"
] as const
export const TagColumns = ['All', 'Equip', 'Drill', 'Card', 'Quest', 'Consume', 'Compound'] as const
export const EquipmentColumns = [
"MinLvl","Slots","RefineNumber","RefineState",
] as const
export const EquipmentColumns = ['MinLvl', 'Slots', 'RefineNumber', 'RefineState'] as const
export const StatsColumns = [
"AP","GunAP","AC","DX","MP","MA","MD","WT","DA","LK","HP","DP","HV",
'HV',
'AC',
'LK',
'WT',
'HP',
'MA',
'DP',
'DX',
'MP',
'AP',
'MD',
'DA',
'GunAP',
] as const
export const DebugColumns = [
]
export const HackColumns = [
] as const
export const DebugColumns = []
export const HackColumns = [] as const
export const ColumnNames = [
...BasicColumns,
@ -44,22 +39,22 @@ export const ColumnNames = [
...HackColumns,
] as const
export type ColumnName = typeof ColumnNames[number]
export type ColumnName = (typeof ColumnNames)[number]
const c = (a: ColumnName | ColumnInfo): ColumnName => {
switch (typeof a) {
case "string":
case 'string':
return a
case "object":
case 'object':
return a.name
}
}
export const LazyColumn = c;
export const LazyColumn = c
export const ColumnSorter = (a: ColumnName | ColumnInfo, b: ColumnName | ColumnInfo): number => {
let n1 = ColumnNames.indexOf(c(a))
let n2 = ColumnNames.indexOf(c(b))
if(n1 == n2) {
const n1 = ColumnNames.indexOf(c(a))
const n2 = ColumnNames.indexOf(c(b))
if (n1 === n2) {
return 0
}
return n1 > n2 ? 1 : -1
@ -73,6 +68,5 @@ export interface ColumnInfo {
renderer?: any
filtering?: boolean
writable?: boolean
getter(item:TricksterItem):(string | number)
getter(item: TricksterItem): string | number
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -1,132 +1,128 @@
import axios, { AxiosResponse, Method } from "axios";
import qs from "qs";
import { getCookie, removeCookie } from "typescript-cookie";
import axios, { AxiosError, AxiosResponse, Method } from 'axios'
import qs from 'qs'
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/"
export const BANK_ROOT = "item-manager-action/"
export const MARKET_ROOT = "marketplace-api/"
const login_endpoint = (name:string)=>{
const raw_endpoint = (name: string): string => {
return SITE_ROOT + name
}
const login_endpoint = (name: string) => {
return `${SITE_ROOT + name}?canonical=1`
}
export const api_endpoint = (name: string): string => {
return SITE_ROOT + API_ROOT + name
}
export const bank_endpoint = (name: string): string => {
return SITE_ROOT+BANK_ROOT + name
return SITE_ROOT + API_ROOT + BANK_ROOT + name
}
export const market_endpoint = (name: string): string => {
return SITE_ROOT + MARKET_ROOT + name
}
export const EndpointCreators = [
api_endpoint,
bank_endpoint,
market_endpoint,
]
export const EndpointCreators = [api_endpoint, bank_endpoint, market_endpoint]
export type EndpointCreator = typeof EndpointCreators[number]
export type EndpointCreator = (typeof EndpointCreators)[number]
export interface Session {
user:string
xsrf:string
csrf:string
request: (verb: Method, url: string, data: any, c?: EndpointCreator) => Promise<any>
}
export class LoginHelper {
user:string
pass:string
csrf?:string
constructor(user:string, pass:string){
this.user = user;
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!)
export const login = async (user: string, pass: string): Promise<TokenSession> => {
return axios
.get(login_endpoint('login'), {
withCredentials: false,
maxRedirects: 0,
xsrfCookieName: 'XSRF-TOKEN',
})
.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{
constructor(){
export const getAccountInfo = async (): Promise<TricksterAccountInfo> => {
return axios
.get(raw_endpoint('settings/info'), { withCredentials: false })
.then((ans: AxiosResponse) => {
return ans.data
})
}
logout = async ():Promise<void> =>{
return axios.get(login_endpoint("logout"),{withCredentials:false}).catch((e)=>{})
export const logout = async (): Promise<void> => {
return axios
.get(login_endpoint('logout'), { withCredentials: false })
.catch(() => {})
.then(() => {})
}
}
const sleep = async(ms:number)=> {
return new Promise(resolve => setTimeout(resolve, ms))
// Keep LoginHelper for backwards compatibility
export const LoginHelper = {
login,
info: getAccountInfo,
logout,
}
export class TokenSession implements Session {
csrf:string
xsrf:string
user:string
constructor(name:string, csrf:string, xsrf: string){
this.user = name
this.csrf = csrf
this.xsrf = xsrf;
}
request = async (verb:string,url:string,data:any, c:EndpointCreator = api_endpoint):Promise<AxiosResponse> => {
let promise
request = async (
verb: string,
url: string,
data: any,
c: EndpointCreator = api_endpoint,
): Promise<AxiosResponse> => {
let promise: Promise<AxiosResponse>
switch (verb.toLowerCase()) {
case "post":
case 'post':
promise = axios.post(c(url), data, this.genHeaders())
break;
case "postform":
break
case 'postform':
promise = axios.postForm(c(url), data)
break;
case "postraw":
break
case 'postraw': {
const querystring = qs.stringify(data)
promise = axios.post(c(url), querystring, this.genHeaders())
break;
case "get":
break
}
default:
promise = axios.get(c(url), this.genHeaders())
}
return promise.then(x=>{
if(x.data){
try{
this.xsrf = x.data.split("xsrf-token")[1].split('\">')[0].replace("\" content=\"",'')
}catch(e){
}
}
if(x.headers['set-cookie']){
const cookies = x.headers['set-cookie'].map((y)=>{
return y.split("=")[1].split(";")[0];
})
this.xsrf = cookies[0]
}
return x
})
return promise
}
genHeaders = () => {
const out = {
headers: {
Accept: "application/json",
"Update-Insecure-Requests": 1,
Accept: 'application/json',
'Update-Insecure-Requests': 1,
},
withCredentials:true
}
if(this.xsrf){
(out.headers as any)["X-XSRF-TOKEN"] = this.xsrf.replace("%3D","=")
withCredentials: true,
}
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 { TricksterInventory } from "./trickster"
import {ColumnInfo, ColumnName, Columns, ColumnSorter, LazyColumn} from "./columns"
import { ColumnSettings } from "handsontable/settings"
import { PredefinedMenuItemKey } from "handsontable/plugins/contextMenu"
import { ref } from "vue"
import Handsontable from "handsontable"
import { HotTableProps } from '@handsontable/react'
import { ColumnInfo, ColumnName, ColumnSorter, Columns, LazyColumn } from './columns'
import { TricksterInventory } from './trickster'
export interface InventoryTableOptions {
columns: ColumnSet
@ -16,12 +10,12 @@ export interface InventoryTableOptions {
}
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()
size: number;
dirty = ref(0)
size: number
dirty = 0
constructor(i?: Iterable<ColumnInfo | ColumnName>) {
if (i) {
for (const a of i) {
@ -33,33 +27,46 @@ export class ColumnSet implements Set<ColumnInfo>, Mappable<ColumnInfo>{
this.size = 0
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)
}
[Symbol.iterator](): IterableIterator<ColumnInfo> {
return this.values()
}
[Symbol.toStringTag] = "ColumnSet";
[Symbol.toStringTag] = 'ColumnSet'
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> {
return this.values()
}
forEach(callbackfn: (value: ColumnInfo, value2: ColumnInfo, set: Set<ColumnInfo>) => void, thisArg?: any): void{
Array.from(this.values()).forEach((v)=>{
forEach(
callbackfn: (value: ColumnInfo, value2: ColumnInfo, set: Set<ColumnInfo>) => void,
thisArg?: any,
): void {
Array.from(this.values()).forEach(v => {
if (this.has(v)) {
callbackfn(v, v, new Set(this.values()))
}
}, thisArg)
}
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]
}).values()
})
.values()
}
mark() {
this.dirty.value = this.dirty.value + 1
this.dirty = this.dirty + 1
this.size = this.s.size
}
add(value: ColumnInfo | ColumnName): this {
@ -97,26 +104,7 @@ export class InventoryTable {
getTableColumnNames(): string[] {
return this.o.columns.map(x => x.displayName)
}
getTableColumnSettings(): ColumnSettings[] {
return this.o.columns.map(x=>{
let out:any = {
renderer: x.renderer ? x.renderer : "text",
filters: true,
dropdownMenu: x.filtering ? DefaultDropdownItems() : false,
readOnly: x.writable ? false : true,
selectionMode: (x.writable ? "multiple" : 'single') as any,
}
if(x.options) {
out.type = 'dropdown'
if(typeof x.options == "function") {
out.source = x.options(this.o.accounts)
}else {
out.source = x.options
}
}
return out
})
}
getTableColumnSettings() {}
getTableRows(): any[][] {
return Object.values(this.inv.items)
.filter((item): boolean => {
@ -124,11 +112,11 @@ export class InventoryTable {
return false
}
let found = true
let hasAll = this.o.tags.has("All")
const hasAll = this.o.tags.has('All')
if (this.o.tags.s.size > 0) {
found = hasAll
for (const tag of this.o.tags.values()) {
if(tag.name =="All") {
if (tag.name === 'All') {
continue
}
if (tag.getter(item) === 1) {
@ -138,7 +126,7 @@ export class InventoryTable {
}
return found
})
.map((item)=>{
.map(item => {
return this.o.columns.map(x => {
return x.getter(item)
})
@ -153,7 +141,7 @@ export class InventoryTable {
data: dat,
colHeaders: this.getTableColumnNames(),
columns: this.getTableColumnSettings(),
...s
...s,
},
}
}
@ -163,49 +151,3 @@ export interface TableRecipe {
data: any[][]
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,25 +1,27 @@
export interface ItemExpireTime {
text: string
us: string
export interface TricksterItem {
id: string
unique_id: number
item_name: string
item_count: number
item_comment: string
item_use: string
item_slots?: number
item_tab: number
item_type: number
item_min_level?: number
is_equip?: boolean
is_drill?: boolean
item_expire_time?: string
refine_level?: number
refine_type?: number
refine_state?: number
item_image?: string
stats?: { [key: string]: any }
}
export interface TricksterItem {
unique_id: number;
item_name: string;
item_id: number;
item_count: number;
item_desc: string;
item_use: string;
item_slots?: number;
item_min_level?: number;
is_equip?: boolean;
is_drill?: boolean;
item_expire_time?: ItemExpireTime;
refine_level?: number;
refine_type?: number;
refine_state?: number;
image?: string;
stats?: {[key: string]:any}
export interface TricksterAccountInfo {
community_name: string
email: string
}
export interface TricksterAccount {
@ -28,6 +30,7 @@ export interface TricksterAccount {
}
export interface Identifier {
account_name: string
account_id: number
id: number
name: string
@ -38,58 +41,58 @@ export interface TricksterCharacter extends Identifier {
class: number
base_job: number
current_job: number
current_type: number
}
export interface TricksterInventory extends Identifier {
galders?: number
items:{[key:string]:TricksterItem}
items: Map<string, TricksterItem>
}
const jobMap: { [key: number]: string } = {
//---- job 1, fm
1: "schoolgirl",
2: "fighter",
3: "librarian",
4: "shaman",
5: "archeologist",
6: "engineer",
7: "model",
8: "teacher",
1: 'schoolgirl',
2: 'fighter',
3: 'librarian',
4: 'shaman',
5: 'archeologist',
6: 'engineer',
7: 'model',
8: 'teacher',
//---- job 2 fm
9: "boxer",
10: "warrior",
11: "bard",
12: "magician",
13: "explorer",
14: "inventor",
15: "entertainer",
16: "card master",
9: 'boxer',
10: 'warrior',
11: 'bard',
12: 'magician',
13: 'explorer',
14: 'inventor',
15: 'entertainer',
16: 'card master',
//----
17: "champion",
18: "duelist",
19: "mercinary",
20: "gladiator",
21: "soul master",
22: "witch",
23: "wizard",
24: "dark lord",
25: "priest",
26: "thief master",
27: "hunter lord",
28: "cyber hunter",
29: "scientist",
30: "primadonna",
31: "diva",
32: "duke",
33: "gambler",
17: 'champion',
18: 'duelist',
19: 'mercinary',
20: 'gladiator',
21: 'soul master',
22: 'witch',
23: 'wizard',
24: 'dark lord',
25: 'priest',
26: 'thief master',
27: 'hunter lord',
28: 'cyber hunter',
29: 'scientist',
30: 'primadonna',
31: 'diva',
32: 'duke',
33: 'gambler',
}
export const JobNumberToString = (n: number): string => {
if(n == -8) {
return "bank"
if (n === -8) {
return 'bank'
}
if(jobMap[n] != undefined) {
if (jobMap[n] !== undefined) {
return jobMap[n]
}
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'
export const LIFETO_COOKIE_PREFIX="LIFETO_PANEL_"
export const LIFETO_COOKIE_PREFIX = 'LIFETO_PANEL_'
export const nameCookie = (...s: string[]): string => {
return LIFETO_COOKIE_PREFIX+s.join("_").toUpperCase()
return LIFETO_COOKIE_PREFIX + s.join('_').toUpperCase()
}
export class Storage {
GetSession(): Session {
const {user, xsrf, csrf} = {
user: getCookie(nameCookie("user"))!,
xsrf: getCookie(nameCookie("xsrf"))!,
csrf: getCookie(nameCookie("csrf"))!
return new TokenSession()
}
return new TokenSession(user, xsrf, csrf)
}
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)
RemoveSession() {}
AddSession(_s: Session) {
// setCookie(nameCookie("xsrf"),s.xsrf)
}
}
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": {
"incremental": true,
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["esnext", "dom"],
"skipLibCheck": true
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"paths": {
"@/*": ["./src/*"]
},
"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" }]
}

View File

@ -1,8 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"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 vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
// with options
'/lifeto': {
target: "https://beta.lifeto.co/",
target: 'https://beta.lifeto.co/',
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