1
0
forked from a/lifeto-shop
This commit is contained in:
a 2025-05-13 16:02:59 -05:00
parent 908b4da72d
commit 79f90a7478
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
30 changed files with 2891 additions and 1800 deletions

28
Caddyfile Normal file
View File

@ -0,0 +1,28 @@
{
admin off
}
:{$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 X-Forwarded-For {remote_host}
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
}
}
log {
output stdout
format console
}
}

View File

@ -1,19 +1,10 @@
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:18.1-alpine as NODEBUILDER
WORKDIR /wd WORKDIR /wd
COPY . . COPY . .
RUN npm install RUN npm install
RUN npx vite build RUN npx vite build
FROM alpine:3.16 FROM caddyserver/caddy:2.10-alpine
WORKDIR /wd WORKDIR /wd
COPY --from=GOBUILDER /wd/app.exe app.exe COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=NODEBUILDER /wd/dist dist COPY --from=NODEBUILDER /wd/dist dist
ENTRYPOINT [ "/wd/app.exe" ]

View File

@ -8,40 +8,46 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@handsontable/react": "^14.5.0", "@floating-ui/react": "^0.27.8",
"@tanstack/react-query": "^5.51.21", "@handsontable/react": "^15.3.0",
"@types/qs": "^6.9.7", "@mantine/hooks": "^8.0.0",
"@types/react": "^18.3.3", "@tanstack/react-query": "^5.76.0",
"@types/react-dom": "^18.3.0", "@types/qs": "^6.9.18",
"@types/uuid": "^8.3.4", "@types/react": "^19.1.4",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@types/react-dom": "^19.1.5",
"@typescript-eslint/parser": "^8.0.1", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.1", "@typescript-eslint/eslint-plugin": "^8.32.1",
"axios": "^0.27.2", "@typescript-eslint/parser": "^8.32.1",
"eslint": "^9.8.0", "@vitejs/plugin-react": "^4.4.1",
"axios": "^1.9.0",
"eslint": "^9.26.0",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.35.0", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.9", "eslint-plugin-react-refresh": "^0.4.20",
"handsontable": "^14.5.0", "fuse.js": "^7.1.0",
"loglevel": "^1.8.0", "handsontable": "^15.3.0",
"pinia": "^2.0.14", "jotai": "^2.12.4",
"prettier": "^3.3.3", "jotai-tanstack-query": "^0.9.0",
"qs": "^6.10.5", "loglevel": "^1.9.2",
"react": "^18.3.1", "pinia": "^3.0.2",
"react-dom": "^18.3.1", "prettier": "^3.5.3",
"react-spinners": "^0.14.1", "qs": "^6.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-select": "^5.10.1",
"react-spinners": "^0.17.0",
"typescript-cookie": "^1.0.6", "typescript-cookie": "^1.0.6",
"use-local-storage": "^3.0.0", "use-local-storage": "^3.0.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.1",
"uuid": "^10.0.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.6", "@tailwindcss/postcss": "^4.1.6",
"postcss": "^8.4.40", "postcss": "^8.5.3",
"tailwindcss": "^4.1.6", "tailwindcss": "^4.1.6",
"typescript": "^5.5.4", "typescript": "^5.8.3",
"vite": "^5.3.5" "vite": "^6.3.5"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

@ -6,14 +6,34 @@ import { Inventory } from "./components/inventory";
export const App: FC = () => { export const App: FC = () => {
return ( return (
<> <>
<div className="flex flex-col p-4 h-full"> <div className="flex flex-col mx-auto p-4 w-full">
<div className="grid grid-cols-6 gap-x-4"> <div className="flex flex-row max-w-6xl">
<div className="col-span-1"> <div className="flex flex-row justify-end w-full">
<LoginWidget/> <LoginWidget/>
</div> </div>
</div>
<div>
<CharacterRoulette/>
</div>
<div className="overflow-hidden">
<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"> <div className="col-span-5 h-full">
<CharacterRoulette/> <CharacterRoulette/>
</div> </div>
<div className="col-span-1">
<div className="flex flex-col border border-gray-400">
<LoginWidget/>
</div>
</div>
</div> </div>
<div className="grid grid-cols-6 h-full"> <div className="grid grid-cols-6 h-full">
<div className="col-span-1"> <div className="col-span-1">
@ -25,6 +45,4 @@ export const App: FC = () => {
</div> </div>
</div> </div>
</div> </div>
</> */
);
};

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 || currentInv.money
}
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

@ -1,46 +1,103 @@
import { useEffect, useState } from "react" import { TricksterCharacter } from "../lib/trickster"
import { useLtoContext } from "../context/LtoContext"
import { JobNumberToString, TricksterAccount, TricksterCharacter } from "../lib/trickster"
import { keepPreviousData, useQuery } from "@tanstack/react-query"
import { useSessionContext } from "../context/SessionContext" import { useSessionContext } from "../context/SessionContext"
import Fuse from 'fuse.js'
import { useAtom, useSetAtom } from "jotai"
import { charactersAtom, selectedCharacterAtom } from "../state/atoms"
import { useMemo, useState } from "react";
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
FloatingPortal
} from "@floating-ui/react";
export const CharacterCard = ({character}:{ export const CharacterCard = ({character}:{
character: TricksterCharacter, character: TricksterCharacter,
})=>{ })=>{
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 {activeTable, setActiveTable} = useSessionContext()
return <> return <>
<div onClick={()=>{ <div onClick={()=>{
setActiveTable(character.path) setSelectedCharacter(character)
}} }}
ref={refs.setReference} {...getReferenceProps()}
className={` className={`
flex flex-col border border-black flex flex-col border border-black
hover:cursor-pointer hover:cursor-pointer
hover:bg-blue-100 hover:bg-blue-100
h-full p-2 ${character.path === selectedCharacter?.path? `bg-blue-200 hover:bg-blue-100` : ""}`}>
p-2 ${character.path === activeTable ? `bg-blue-200 hover:bg-blue-100 border-double border-4` : ""}`}>
<div className="flex"></div>
<div className="flex flex-col justify-between h-full"> <div className="flex flex-col justify-between h-full">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-row justify-center text-md"> <div className="flex flex-row justify-center"
<span>{character.name}</span> >
</div>
<div className="flex flex-row justify-center">
{character.base_job === -8 ? {character.base_job === -8 ?
<img src={`https://knowledge.lifeto.co/animations/npc/npc041_5.png`}/> <img
: className="h-8"
<img src="https://beta.lifeto.co/item_img/gel.nri.003.000.png"
src={`https://knowledge.lifeto.co/animations/character/chr00${character.base_job}_13.png`}/> />
:
<img
className="h-16"
src={`https://knowledge.lifeto.co/animations/character/chr${
(character.current_job - character.base_job - 1).toString().padStart(3,"0")
}_13.png`}/>
} }
</div> </div>
<div className="flex flex-row gap-1"> <FloatingPortal>
<span>class: </span> {isOpen && (
<span>{JobNumberToString(character.current_job)}</span> <div
</div> className="Tooltip"
</div> ref={refs.setFloating}
<div className="flex flex-row gap-1 text-xs"> style={floatingStyles}
<span>path: </span> {...getFloatingProps()}
<span>{character.path}</span> >
<div className="flex flex-col gap-1 bg-white">
{character.base_job === -8 ? "bank" : character.name}
</div>
</div>
)}
</FloatingPortal>
</div> </div>
</div> </div>
@ -53,30 +110,53 @@ const PleaseLogin = () => {
} }
export const CharacterRoulette = ()=>{ export const CharacterRoulette = ()=>{
const {API, loggedIn} = useLtoContext() const [{data: rawCharacters}] = useAtom(charactersAtom)
const {data:characters} = useQuery({
queryKey:["characters", API.s.user],
queryFn: async ()=> {
return API.GetAccounts().then(x=>{
if(!x) {
return undefined
}
return x.flatMap(x=>{return x?.characters})
})
},
enabled: loggedIn,
placeholderData: keepPreviousData,
})
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])
if(!characters || characters.length == 0) { if(!characters || characters.length == 0) {
return <PleaseLogin/> return <PleaseLogin/>
} }
const searchResults = fuse.search(search || "!-----", {
limit: 20,
}).map((x)=>{
return <div className="flex flex-col" key={`${x.item.character.account_id}`}>
<CharacterCard key={x.item.bank.id} character={x.item.bank} />
<CharacterCard key={x.item.character.id} character={x.item.character} />
</div>
})
return <> return <>
<div className="flex flex-row overflow-x-scroll gap-4 h-full"> <div className="flex flex-col gap-1">
{characters.map(x=>{ <input
return <CharacterCard key={x.id} character={x} /> className="border border-black-1 bg-gray-100 placeholder-gray-600 p-1 max-w-[200px]"
})} placeholder="search character..."
value={search}
onChange={(e)=>{
setSearch(e.target.value)
}}
></input>
<div className="flex flex-row overflow-x-scroll gap-1 h-full min-h-36">
{searchResults ? searchResults : <>
</>}
</div>
</div> </div>
</> </>

View File

@ -1,116 +1,237 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query"
import { TricksterCharacter } from "../lib/trickster" import { TricksterCharacter } from "../lib/trickster"
import { useSessionContext } from "../context/SessionContext" import { useSessionContext } from "../context/SessionContext"
import { useLtoContext } from "../context/LtoContext"
import 'handsontable/dist/handsontable.full.min.css'; import 'handsontable/dist/handsontable.full.min.css';
import { registerAllModules } from 'handsontable/registry'; import { registerAllModules } from 'handsontable/registry';
import { HotTable, HotTableClass } from '@handsontable/react'; import { HotTable, HotTableClass } from '@handsontable/react';
import { useCallback, useEffect, useRef, useState } from "react"; import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState} from "react";
import { InventoryTable } from "../lib/table"; import { InventoryTable } from "../lib/table";
import { DotLoader } from "react-spinners"; import { DotLoader } from "react-spinners";
import { useDebounceCallback, useResizeObserver } from "usehooks-ts"; import { useResizeObserver } from "@mantine/hooks";
import { Columns } from "../lib/columns"; import { Columns } from "../lib/columns";
import { OrderDetails, OrderSender } from "../lib/lifeto/order_manager"; import { OrderDetails, OrderSender } from "../lib/lifeto/order_manager";
import log from "loglevel"; import log from "loglevel";
import { charactersAtom, currentCharacterInventoryAtom, currentCharacterItemsAtom, LTOApi, selectedTargetInventoryAtom } from "../state/atoms";
import Select from 'react-select';
import { useAtom, useAtomValue } from "jotai";
import { autoUpdate, flip, FloatingFocusManager, FloatingPortal, size, useDismiss, useFloating, useInteractions, useListNavigation, useRole } from "@floating-ui/react";
import Fuse from "fuse.js";
registerAllModules(); registerAllModules();
type Size = { type Size = {
width?: number width?: number
height?: number height?: number
} }
interface InventoryItemProps {
children: React.ReactNode;
active: boolean;
}
const InventoryItem = forwardRef<
HTMLDivElement,
InventoryItemProps & React.HTMLProps<HTMLDivElement>
>(({ children, active, ...rest }, ref) => {
const id = useId();
return (
<div
ref={ref}
role="option"
id={id}
aria-selected={active}
{...rest}
style={{
background: active ? "lightblue" : "none",
padding: 4,
cursor: "default",
...rest.style,
}}
>
{children}
</div>
);
});
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: `${rects.reference.width}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 [selectedTargetInventory, setSelectedTargetInventory] = useAtom(selectedTargetInventoryAtom)
const searcher = useMemo(()=>{
return new Fuse(subaccounts?.flatMap(x=>[
x.bank,
x.character,
])||[], {
keys:["path","name"],
findAllMatches: true,
threshold: 0.8,
useExtendedSearch: true,
})
}, [subaccounts])
const items = searcher.search(inputValue || "!-", {limit: 10}).map(x=>x.item)
return (
<>
<input
className="border border-black-1 bg-gray-100 placeholder-gray-600"
{...getReferenceProps({
ref: refs.setReference,
onChange,
value: selectedTargetInventory !== undefined ? selectedTargetInventory.name : inputValue,
placeholder: "Target Inventory",
"aria-autocomplete": "list",
onKeyDown(event) {
if (
event.key === "Enter" &&
activeIndex != null &&
items[activeIndex]
) {
setSelectedTargetInventory(items[activeIndex])
setInputValue(items[activeIndex].name);
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",
},
})}
>
{items.map((item, index) => (
<InventoryItem
{...getItemProps({
key: item.path,
ref(node) {
listRef.current[index] = node;
},
onClick() {
setInputValue(item.name);
setSelectedTargetInventory(item);
setOpen(false);
refs.domReference.current?.focus();
},
})}
active={activeIndex === index}
>
{item.name}
</InventoryItem>
))}
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</>
);
}
export const Inventory = () => { export const Inventory = () => {
const {activeTable, columns, tags, orders} = useSessionContext() const {activeTable, columns, tags, orders} = useSessionContext()
const {API, loggedIn} = useLtoContext()
const ref = useRef<HTMLDivElement>(null) const [ref, {height}] = useResizeObserver({})
const [{ height }, setSize] = useState<Size>({
width: 100,
height: 100,
})
const onResize = useDebounceCallback(setSize, 200) const {data:character, isLoading, isFetching } = useAtomValue(currentCharacterInventoryAtom)
//const sendOrders = useCallback(()=>{
useResizeObserver({ // if(!hotTableComponent.current?.hotInstance){
ref, // return
onResize, // }
}) // const hott = hotTableComponent.current?.hotInstance
// const headers = hott.getColHeader()
const hotTableComponent = useRef<HotTableClass>(null); // const dat = hott.getData()
const {data:character, isLoading, isFetching } = useQuery({ // const idxNumber = headers.indexOf(Columns.MoveCount.displayName)
queryKey:["inventory", activeTable], // const idxTarget = headers.indexOf(Columns.Move.displayName)
queryFn: async ()=> { // const origin = activeTable
return API.GetInventory(activeTable) // const pending:OrderDetails[] = [];
}, // for(const row of dat) {
enabled: loggedIn, // try{
// placeholderData: keepPreviousData, // const nm = Number(row[idxNumber].replace("x",""))
}) // const target = (row[idxTarget] as string).replaceAll("-","").trim()
const {data:characters} = useQuery({ // if(!isNaN(nm) && nm > 0 && target.length > 0){
queryKey:["characters", API.s.user], // const info:OrderDetails = {
queryFn: async ()=> { // item_uid: row[0].toString(),
return API.GetAccounts().then(x=>{ // count: nm,
return x.flatMap(x=>{return x.characters}) // origin_path: activeTable,
}) // target_path: target,
}, // }
enabled: loggedIn, // pending.push(info)
placeholderData: keepPreviousData, // }
}) // }catch(e){
// }
// }
useEffect(()=>{ // log.debug("OrderDetails", pending)
if(!character) { // const chars = new Map<string,TricksterCharacter>()
hotTableComponent.current?.hotInstance?.updateSettings({ // const manager = new OrderSender(orders, chars)
data: [], // for(const d of pending){
}) // const order = manager.send(d)
return // //order.tick(api)
} // }
const it = new InventoryTable(character, { //}, [orders])
columns: columns,
tags: tags,
accounts: characters?.map(x=>{
return x.name
}) || [],
})
const build = it.BuildTable()
hotTableComponent.current?.hotInstance?.updateSettings(build.settings)
}, [hotTableComponent, character, height])
const sendOrders = useCallback(()=>{
if(!hotTableComponent.current?.hotInstance){
return
}
const hott = hotTableComponent.current?.hotInstance
const headers = hott.getColHeader()
const dat = hott.getData()
const idxNumber = headers.indexOf(Columns.MoveCount.displayName)
const idxTarget = headers.indexOf(Columns.Move.displayName)
const origin = activeTable
const pending:OrderDetails[] = [];
for(const row of dat) {
try{
const nm = Number(row[idxNumber].replace("x",""))
const target = (row[idxTarget] as string).replaceAll("-","").trim()
if(!isNaN(nm) && nm > 0 && target.length > 0){
const info:OrderDetails = {
item_uid: row[0].toString(),
count: nm,
origin_path: activeTable,
target_path: target,
}
pending.push(info)
}
}catch(e){
}
}
log.debug("OrderDetails", pending)
const chars = new Map<string,TricksterCharacter>()
const manager = new OrderSender(orders, chars)
for(const d of pending){
const order = manager.send(d)
//order.tick(api)
}
}, [orders])
const Loading = ()=>{ const Loading = ()=>{
return <div role="status" className="flex align-center justify-center"> return <div role="status" className="flex align-center justify-center">
@ -119,26 +240,27 @@ export const Inventory = () => {
</div> </div>
</div> </div>
} }
const items = useAtomValue(currentCharacterItemsAtom)
return <div ref={ref} className={``}> return <div ref={ref} className={``}>
<div className="flex flex-row py-2 px-3"> <div className="flex flex-row py-2 justify-end">
<InventoryTargetSelector/>
<div <div
onClick={(e)=>{ onClick={(e)=>{
sendOrders() // sendOrders()
}} }}
className=" className="
hover:cursor-pointer hover:cursor-pointer
border border-black-1 border border-black-1
bg-green-200 bg-green-200
px-2 py-1 px-2 py-1
">ayy lmao button</div> ">Move Selected</div>
</div>
{(isLoading || isFetching) ? <Loading/> : <></> }
<div
className={`${isLoading || isFetching ? "invisible" : ""}`}>
<HotTable
ref={hotTableComponent as any}
licenseKey="non-commercial-and-evaluation"
/>
</div> </div>
{(isLoading || isFetching) ? <Loading/> : <>
<div>
total: {items.size}
</div>
</> }
</div> </div>
} }

View File

@ -1,60 +1,78 @@
import { useEffect, useState } from "react" import { useState } from "react"
import { useLtoContext } from "../context/LtoContext"
import useLocalStorage from "use-local-storage" import useLocalStorage from "use-local-storage"
import { useAtom } from "jotai"
import { loginStatusAtom } from "../state/atoms"
import { LoginHelper } from "../lib/session"
export const LoginWidget = () => { export const LoginWidget = () => {
const {loggedIn, login, logout} = useLtoContext()
const [username, setUsername] = useLocalStorage("input_username","", {syncData: false}) const [username, setUsername] = useLocalStorage("input_username","", {syncData: false})
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
return <>
<div className="flex flex-col border border-gray-400"> const [{data:loginState, refetch: refetchLoginState}] = useAtom(loginStatusAtom)
<div className="flex flex-col">
<div className="flex flex-row bg-blue-400"> const [loginError, setLoginError] = useState("")
<span className="text-white pb-1 pl-2 m-y-1">
account if(loginState?.logged_in){
</span> return <>
<div className="flex flex-row justify-between px-2">
<div>
{loginState.community_name}
</div> </div>
<div className="flex flex-row flex-wrap gap-1 p-2 justify-center"> <div className="flex flex-row gap-2">
<div>
<input
onChange={(e)=>{
setUsername(e.target.value)
}}
value={username}
placeholder="username" className="w-32 pl-2 pb-1 border border-gray-600"/>
</div>
<div>
<input
onChange={(e)=>{
setPassword(e.target.value)
}}
value={password}
type="password" placeholder="password" className="w-32 pl-2 pb-1 border border-gray-600"/>
</div>
</div>
<div className="flex flex-row p-2 justify-center gap-4">
<button
onClick={async ()=>{
login(username,password).catch((e)=>{
alert(e.toString())
})
}}
className="border border-gray-600 px-2 py-1 hover:bg-blue-200">
login
</button>
<button <button
onClick={()=>{ onClick={()=>{
logout() LoginHelper.logout().finally(()=>{
refetchLoginState()
})
return return
}} }}
disabled={!loggedIn} className="border border-gray-600 px-2 py-1 hover:bg-red-200 disabled:border-gray-400 disabled:text-gray-300 disabled:bg-white "> className="text-blue-400 text-xs hover:cursor-pointer hover:text-blue-600">
logout logout
</button> </button>
</div> </div>
</div> </div>
</>
}
return <>
<div className="flex flex-col">
<form action={
()=>{
LoginHelper.login(username,password).catch((e)=>{
setLoginError(e.message)
}).finally(()=>{
refetchLoginState()
refetchLoginState()
})
}}
className="flex flex-col gap-1 p-2 justify-left">
{ loginError ? (<div className="text-red-500 text-xs">
{loginError}
</div>) : null}
<div>
<input
onChange={(e)=>{
setUsername(e.target.value)
}}
value={username}
id="username"
placeholder="username" className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500"/>
</div>
<div>
<input
onChange={(e)=>{
setPassword(e.target.value)
}}
value={password}
type="password" placeholder="password" className="w-32 pl-2 pb-1 border-b border-gray-600 placeholder-gray-500"/>
</div>
<button
type="submit"
className="border-b border-gray-600 px-2 py-1 hover:text-gray-600 hover:cursor-pointer">
login
</button>
</form>
</div> </div>
</> </>
} }

View File

@ -1,4 +1,3 @@
import { LtoContextProvider } from "./LtoContext";
import { SessionContextProvider } from "./SessionContext"; import { SessionContextProvider } from "./SessionContext";
interface IContext { interface IContext {
@ -9,7 +8,6 @@ function AppContext(props: IContext): any {
const { children } = props; const { children } = props;
const providers = [ const providers = [
SessionContextProvider, SessionContextProvider,
LtoContextProvider
]; ];
const res = providers.reduceRight( const res = providers.reduceRight(
(acc, CurrVal) => <CurrVal>{acc as any}</CurrVal>, (acc, CurrVal) => <CurrVal>{acc as any}</CurrVal>,

View File

@ -1,68 +0,0 @@
import { createContext, useContext, useEffect, useState } from "react";
import { LTOApiv0 } from "../lib/lifeto";
import { storage } from "../session_storage";
import { LoginHelper, LogoutHelper } from "../lib/session";
interface LtoContextProps {
API: LTOApiv0;
login: (username:string, password:string)=>Promise<void>;
logout: ()=>void;
loggedIn: boolean
}
const LtoContext = createContext({} as LtoContextProps);
export const LtoContextProvider = ({ children }: { children: any }) => {
const [API, setAPI] = useState(new LTOApiv0(storage.GetSession()));
const login = async (username:string , password:string ) =>{
console.log("attempting logiun", username)
return new LoginHelper(username, password).login()
.catch((e)=>{
if(e.code == "ERR_BAD_REQUEST") {
throw "invalid username/password"
}
console.warn("throwing error", e)
throw "unknown error, please report"
})
.then((session)=>{
setAPI(new LTOApiv0(session))
storage.AddSession(session)
setLoggedIn(true)
}) }
const logout = () => {
new LogoutHelper().logout().then(()=>{
storage.RemoveSession()
localStorage.clear()
window.location.reload()
})
}
const [loggedIn, setLoggedIn] = useState(false)
useEffect(()=>{
if(!API) {
return
}
API?.GetLoggedin().then((x)=>{
setLoggedIn(x)
})
}, [API])
return <LtoContext.Provider value={{
API,
login,
logout,
loggedIn
}}>{children}</LtoContext.Provider>;
};
export const useLtoContext = (): LtoContextProps => {
const context = useContext<LtoContextProps>(LtoContext);
if (context === null) {
throw new Error(
'"useLtoContext" should be used inside a "LtoContextProvider"',
);
}
return context;
};

View File

@ -1,5 +1,4 @@
import { createContext, Dispatch, SetStateAction, useContext, useState } from "react"; import { createContext, Dispatch, SetStateAction, useContext, useState } from "react";
import { LTOApiv0 } from "../lib/lifeto";
type Setter<T> = React.Dispatch<React.SetStateAction<T | undefined>>; type Setter<T> = React.Dispatch<React.SetStateAction<T | undefined>>;
type MustSetter<T> = React.Dispatch<React.SetStateAction<T>>; type MustSetter<T> = React.Dispatch<React.SetStateAction<T>>;

View File

@ -1,8 +1,4 @@
import Handsontable from "handsontable"
import numbro from 'numbro';
import { textRenderer } from "handsontable/renderers"
import { TricksterItem } from "../trickster" import { TricksterItem } from "../trickster"
import Core from "handsontable/core";
export const BasicColumns = [ export const BasicColumns = [
"uid","Image","Name","Count", "uid","Image","Name","Count",

View File

@ -1,7 +1,4 @@
import { trace } from "loglevel"
import { TricksterAccount, TricksterInventory } from "../trickster" import { TricksterAccount, TricksterInventory } from "../trickster"
import { v4 as uuidv4 } from 'uuid';
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
export const BankEndpoints = ["internal-xfer-item", "bank-item", "sell-item","buy-from-order","cancel-order"] as const export const BankEndpoints = ["internal-xfer-item", "bank-item", "sell-item","buy-from-order","cancel-order"] as const
export type BankEndpoint = typeof BankEndpoints[number] export type BankEndpoint = typeof BankEndpoints[number]

View File

@ -1,7 +1,7 @@
import { Axios, AxiosResponse, Method } from "axios" import { Axios, AxiosResponse, Method } from "axios"
import log, { debug } from "loglevel" import log from "loglevel"
import { bank_endpoint, EndpointCreator, market_endpoint, Session } from "../session" import { bank_endpoint, EndpointCreator, market_endpoint, Session } from "../session"
import { dummyChar, TricksterAccount, TricksterInventory, TricksterItem, TricksterWallet } from "../trickster" import { TricksterAccount, TricksterAccountInfo, TricksterInventory, TricksterItem} from "../trickster"
import { BankEndpoint, LTOApi } from "./api" import { BankEndpoint, LTOApi } from "./api"
export const pathIsBank = (path:string):boolean => { export const pathIsBank = (path:string):boolean => {
@ -47,7 +47,7 @@ export class LTOApiv0 implements LTOApi {
char_path = char_path.replace(":","") char_path = char_path.replace(":","")
} }
let type = char_path.includes("/") ? "char" : "account" let type = char_path.includes("/") ? "char" : "account"
return this.s.request("GET", `v2/item-manager/items/${type}/${char_path}`,undefined).then((ans:AxiosResponse)=>{ return this.s.request("GET", `v3/item-manager/items/${type}/${char_path}`,undefined).then((ans:AxiosResponse)=>{
const o = ans.data const o = ans.data
log.debug("GetInventory", o) log.debug("GetInventory", o)
let name = "bank" let name = "bank"
@ -71,7 +71,7 @@ export class LTOApiv0 implements LTOApi {
id, id,
path: char_path, path: char_path,
galders, galders,
items: Object.fromEntries((Object.entries(o.items) as any).map(([k, v]: [string, TricksterItem]):[string, TricksterItem]=>{ items: new Map((Object.entries(o.items) as any).map(([k, v]: [string, TricksterItem]):[string, TricksterItem]=>{
v.unique_id = Number(k) v.unique_id = Number(k)
return [k, v] return [k, v]
})), })),
@ -86,7 +86,8 @@ export class LTOApiv0 implements LTOApi {
return { return {
name: x.name, name: x.name,
characters: [ 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},...Object.values(x.characters).map((z:any)=>{ {account_name:x.name, id: x.id,account_id:x.id, path:x.name, name: x.name+'/bank', class:-8, base_job: -8, current_job: -8},
...Object.values(x.characters).map((z:any)=>{
return { return {
account_name:x.name, account_name:x.name,
account_id: x.id, account_id: x.id,

View File

@ -1,14 +1,19 @@
import axios, { AxiosResponse, Method } from "axios"; import axios, { AxiosError, AxiosResponse, Method } from "axios";
import qs from "qs"; import qs from "qs";
import { getCookie, removeCookie } from "typescript-cookie"; import { getCookie, removeCookie } from "typescript-cookie";
import { TricksterAccountInfo } from "./trickster";
export const SITE_ROOT = "/lifeto/" export const SITE_ROOT = "/lifeto/"
export const API_ROOT = "api/lifeto/" export const API_ROOT = "api/lifeto/"
export const BANK_ROOT = "api/lifeto/v2/item-manager/" export const BANK_ROOT = "v2/item-manager/"
export const MARKET_ROOT = "marketplace-api/" export const MARKET_ROOT = "marketplace-api/"
const raw_endpoint = (name:string):string =>{
return SITE_ROOT+name
}
const login_endpoint = (name:string)=>{ const login_endpoint = (name:string)=>{
return SITE_ROOT + name + "?canonical=1" return SITE_ROOT + name + "?canonical=1"
} }
@ -32,56 +37,53 @@ export const EndpointCreators = [
export type EndpointCreator = typeof EndpointCreators[number] export type EndpointCreator = typeof EndpointCreators[number]
export interface Session { export interface Session {
user:string
xsrf:string
csrf:string
request:(verb:Method,url:string,data:any,c?:EndpointCreator)=>Promise<any> request:(verb:Method,url:string,data:any,c?:EndpointCreator)=>Promise<any>
} }
export class LoginHelper { 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 ()=>{
return axios.post(login_endpoint("login"),{
login:this.user,
password:this.pass,
redirectTo:"lifeto"
},{withCredentials:false})
}).then(async ()=>{
await sleep(100)
let xsrf= getCookie("XSRF-TOKEN")
return new TokenSession(this.user,this.csrf!, xsrf!)
})
}
}
export class LogoutHelper{
constructor(){ constructor(){
} }
logout = async ():Promise<void> =>{ static 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 "invalid username/password"
}
throw e.message
}
throw e
})
}
static info = async ():Promise<TricksterAccountInfo> =>{
return axios.get(raw_endpoint("settings/info"),{withCredentials:false}).then((ans:AxiosResponse)=>{
return ans.data
})
}
static logout = async ():Promise<void> =>{
return axios.get(login_endpoint("logout"),{withCredentials:false}).catch(()=>{}).then(()=>{}) return axios.get(login_endpoint("logout"),{withCredentials:false}).catch(()=>{}).then(()=>{})
} }
} }
const sleep = async(ms:number)=> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export class TokenSession implements Session { export class TokenSession implements Session {
csrf:string constructor(){
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> => { request = async (verb:string,url:string,data:any, c:EndpointCreator = api_endpoint):Promise<AxiosResponse> => {
@ -101,21 +103,7 @@ export class TokenSession implements Session {
default: default:
promise = axios.get(c(url),this.genHeaders()) promise = axios.get(c(url),this.genHeaders())
} }
return promise.then(x=>{ return promise
if(x.data){
try{
this.xsrf = x.data.split("xsrf-token")[1].split('\">')[0].replace("\" content=\"",'')
}catch(e){
}
}
if(x.headers['set-cookie']){
const cookies = x.headers['set-cookie'].map((y)=>{
return y.split("=")[1].split(";")[0];
})
this.xsrf = cookies[0]
}
return x
})
} }
genHeaders = ()=>{ genHeaders = ()=>{
const out = { const out = {
@ -125,9 +113,6 @@ export class TokenSession implements Session {
}, },
withCredentials:true withCredentials:true
} }
if(this.xsrf){
(out.headers as any)["X-XSRF-TOKEN"] = this.xsrf.replace("%3D","=")
}
return out return out
} }
} }

View File

@ -16,6 +16,11 @@ export interface TricksterItem {
stats?: {[key: string]:any} stats?: {[key: string]:any}
} }
export interface TricksterAccountInfo {
community_name: string
email: string
}
export interface TricksterAccount { export interface TricksterAccount {
name:string name:string
characters: TricksterCharacter[] characters: TricksterCharacter[]
@ -37,7 +42,7 @@ export interface TricksterCharacter extends Identifier {
export interface TricksterInventory extends Identifier{ export interface TricksterInventory extends Identifier{
galders?:number galders?:number
items:{[key:string]:TricksterItem} items: Map<string, TricksterItem>
} }

View File

@ -1,90 +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") {
throw "invalid username/password"
}
console.warn(e)
throw "unknown error, please report"
})
}
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,4 +1,3 @@
import { Cookies, getCookie, removeCookie, setCookie} from 'typescript-cookie'
import { Session, TokenSession } from './lib/session' import { Session, TokenSession } from './lib/session'
@ -11,23 +10,12 @@ export const nameCookie = (...s:string[]):string=>{
export class Storage { export class Storage {
GetSession():Session { GetSession():Session {
const {user, xsrf, csrf} = { return new TokenSession()
user: getCookie(nameCookie("user"))!,
xsrf: getCookie(nameCookie("xsrf"))!,
csrf: getCookie(nameCookie("csrf"))!
}
return new TokenSession(user, xsrf, csrf)
} }
RemoveSession() { RemoveSession() {
removeCookie(nameCookie("user"))
removeCookie(nameCookie("xsrf"))
removeCookie(nameCookie("csrf"))
} }
AddSession(s:Session) { AddSession(s:Session) {
setCookie(nameCookie("user"),s.user) // setCookie(nameCookie("xsrf"),s.xsrf)
setCookie(nameCookie("xsrf"),s.xsrf)
setCookie(nameCookie("csrf"),s.csrf)
} }
} }

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

@ -0,0 +1,104 @@
import { AxiosError } from 'axios';
import { LTOApiv0 } from '../lib/lifeto'
import { LoginHelper, TokenSession } from '../lib/session'
import { atomWithQuery } from 'jotai-tanstack-query'
import {atomFamily, atomWithRefresh, atomWithStorage} from "jotai/utils";
import { atom } from 'jotai';
import { TricksterCharacter, TricksterInventory, TricksterItem } 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: "...",
},
queryFn: async () => {
return LoginHelper.info().then(info => {
return {
logged_in: true,
community_name: info.community_name,
}
}).catch(e => {
if(e instanceof AxiosError) {
return {
logged_in: false,
community_name: "...",
}
}
throw e
})
},
}
})
export const charactersAtom = atomWithQuery((get) => {
const {data: loginStatus} = get(loginStatusAtom)
console.log("charactersAtom", loginStatus)
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 = atom<TricksterCharacter | undefined>(undefined)
export const selectedTargetInventoryAtom = atom<TricksterCharacter | undefined>(undefined)
export const pageSize = atomWithStorage("preference.page_size", 250)
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,
}
})
export const currentCharacterItemsAtom = atom((get)=>{
const {data: inventory} = get(currentCharacterInventoryAtom)
return inventory?.items || new Map<string, TricksterItem>()
})

View File

@ -1,7 +1,7 @@
import { defineStore, storeToRefs } from 'pinia' import { defineStore, storeToRefs } from 'pinia'
import { BasicColumns, ColumnInfo, ColumnName, Columns, DetailsColumns, MoveColumns } from '../lib/columns' import { BasicColumns, ColumnInfo, ColumnName, Columns, DetailsColumns, MoveColumns } from '../lib/columns'
import { OrderTracker } from '../lib/lifeto/order_manager' import { OrderTracker } from '../lib/lifeto/order_manager'
import { Reviver, StoreAccounts, StoreChars, StoreColSet, StoreInvs, StoreSerializable, StoreStr, StoreStrSet } from '../lib/storage' import { StoreAccounts, StoreChars, StoreColSet, StoreStr } from '../lib/storage'
import { ColumnSet } from '../lib/table' import { ColumnSet } from '../lib/table'
import { TricksterAccount, TricksterCharacter, TricksterInventory } from '../lib/trickster' import { TricksterAccount, TricksterCharacter, TricksterInventory } from '../lib/trickster'
import { nameCookie} from '../session_storage' import { nameCookie} from '../session_storage'

View File

@ -1,5 +1,4 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'

2786
yarn.lock

File diff suppressed because it is too large Load Diff