Compare commits

...

196 Commits

Author SHA1 Message Date
2d57345634 draft pref 2024-05-29 12:49:19 +02:00
0a82fca6d3 open in a new tab 2024-05-28 12:54:21 +02:00
1e57e7c33e fix cache 2024-05-27 19:21:38 +02:00
c484948a5e tracking item by item 2024-05-27 17:52:47 +02:00
4748b15cc4 cleanup formater 2024-05-27 12:16:50 +02:00
9ccba70ede fix acquisition creeation 2024-05-27 12:16:15 +02:00
1868b3e248 test instead of it 2024-05-24 21:28:23 +02:00
9f2627faf8 cache 2024-05-24 21:22:28 +02:00
a7b1fb902c cleanup 2024-05-24 18:00:59 +02:00
6afce2ef58 use define model 2024-05-24 15:07:47 +02:00
fff01ff30f remove export 2024-05-22 12:44:02 +02:00
a576a93a0b fix date format 2024-05-22 11:22:30 +02:00
a33426f3c2 cleanup 2024-05-22 11:08:40 +02:00
0dc309642c Flip tooltips 2024-05-22 11:06:27 +02:00
8dc1a2dc3c update 2024-05-22 10:24:35 +02:00
e477242f16 esi rate limit 2024-05-22 10:12:50 +02:00
f75156bc62 history cache 2024-05-22 09:59:13 +02:00
e379f490a4 fix z index 2024-05-22 09:41:38 +02:00
c210ed7fac add tests 2024-05-22 01:39:01 +02:00
92b7f60c75 fix null date 2024-05-22 01:28:13 +02:00
7e7c638ef1 finally table height 2024-05-21 21:55:52 +02:00
b19ef017d6 fix css 2024-05-21 18:52:35 +02:00
8bcbf3bd1d date + fix sort 2024-05-21 17:00:56 +02:00
540d4814d9 reprocess virtual scroll 2024-05-21 15:56:47 +02:00
884412f5a9 cleanup css 2024-05-21 15:54:20 +02:00
4814d24efb fix z index 2024-05-21 15:50:14 +02:00
34095e0d38 cleanup css 2024-05-21 15:15:29 +02:00
bbad25b55b cleanup style 2024-05-21 14:57:13 +02:00
c76f4be928 fix css 2024-05-21 14:55:43 +02:00
d89ff4ea7f fix line height 2024-05-21 14:10:48 +02:00
7a7dba010e virtual scroll table 2024-05-21 13:29:09 +02:00
b81282b42e all button 2024-05-20 13:21:15 +02:00
617d3b281e fi next 2024-05-19 19:32:55 +02:00
c52e92e3ce fix regex 2024-05-19 19:24:16 +02:00
a9e981baa0 prefix 2024-05-19 19:09:04 +02:00
8fdcc75826 hix https 2024-05-19 19:08:22 +02:00
d82f6b6965 lighter type info 2024-05-19 16:22:02 +02:00
3a3711b713 log level prop 2024-05-19 16:01:08 +02:00
c1778b3d49 sort aquired tyes 2024-05-19 13:33:41 +02:00
514c28b900 cleanup 2024-05-19 12:41:59 +02:00
09a3295920 Fix compilation error 2024-05-19 12:37:09 +02:00
400737dab8 Fix remove from multiple acquisitions 2024-05-19 12:19:43 +02:00
27f146b945 perfs 2024-05-19 11:58:36 +02:00
fb9a2f11fe fix potential issue with grouping 2024-05-19 10:28:07 +02:00
4e211c8834 cleanup 2024-05-19 00:54:05 +02:00
79bef2775c #default instead of v-slot 2024-05-18 22:40:25 +02:00
3fd4f5080d show item info button 2024-05-18 22:38:55 +02:00
f677a1d61b cleanup acquisitions 2024-05-18 21:12:11 +02:00
d5aafc88a9 show all acquisitions in typeinfo 2024-05-18 20:12:30 +02:00
52a4b99214 use oldest instead of group[0] 2024-05-18 20:00:33 +02:00
ff4c9c6bf0 group acquisitions 2024-05-18 19:55:48 +02:00
2756bbb2c2 fix acquisition list key 2024-05-18 19:13:14 +02:00
6c99fa0401 draft character page 2024-05-18 18:55:37 +02:00
c38f44c182 <Misc acquisitions 2024-05-18 18:36:27 +02:00
167788ac15 titles 2024-05-18 18:23:54 +02:00
2c64cca921 fix typeinfo acquiisition table alwayse present 2024-05-18 14:38:15 +02:00
894b23166c Remaining amount over total amont 2024-05-18 14:18:44 +02:00
e3a5eeb50d Fix marbas integration 2024-05-18 14:14:57 +02:00
5887ecb638 Acquisitions in typeinfo 2024-05-18 00:15:02 +02:00
78c96f8bce Fix tracking id 2024-05-17 23:57:26 +02:00
5ddee59227 Fix acquisition list 2024-05-17 23:46:28 +02:00
94992afbe3 Fix remove acquisiton 2024-05-17 23:44:11 +02:00
717eaa6ed8 Rework to use marbas and authentik instead of poketbase (#1)
Reviewed-on: #1
2024-05-17 23:00:52 +02:00
9fb78329cc add redirects to nginx config 2023-12-03 16:34:48 +01:00
af9465c127 fix nginix 2023-11-16 20:42:34 +01:00
5c0b83a0a3 fix showColumn 2023-11-16 18:48:48 +01:00
b1da083557 fix crash 2023-11-16 17:49:20 +01:00
a1bffa1cdb fuzzwork 2023-11-16 11:26:17 +01:00
98ce81dfb2 extract evepraisal from apraisal 2023-11-16 09:54:23 +01:00
f115381955 style 2023-11-08 11:05:14 +01:00
088ea5d929 ignorable columns 2023-11-07 13:47:42 +01:00
ee6bbfd442 type info 2023-11-07 11:00:08 +01:00
75f70cfd25 fix marbas proxy 2023-10-30 10:50:47 +01:00
1f1821d607 fix proxy 2023-10-30 09:35:08 +01:00
98c818f028 fix non pageables 2023-10-29 18:35:37 +01:00
0ea65867a8 update to mabras rework 2023-10-29 18:28:05 +01:00
2b59f8719a cleanup 2023-10-29 12:57:14 +01:00
4e4a700ced better label 2023-10-28 16:06:18 +02:00
c432450455 arrow trending 2023-10-26 14:48:16 +02:00
5082cfaac9 fix position 2023-10-26 14:23:31 +02:00
9dd60ae054 filter market groups 2023-10-24 10:21:10 +02:00
ac6c51a714 cleanup 2023-10-23 19:21:55 +02:00
7d608c19e7 margin 2023-10-23 16:23:23 +02:00
a48e49ab9c fix click outside 2023-10-23 16:08:40 +02:00
9bd1ced9d4 search item 2023-10-18 17:59:30 +02:00
4fca2712bf fix sell at 0 2023-10-16 09:01:16 +02:00
c2a09f1c2a fix format 2023-10-13 20:58:31 +02:00
c2c8f2a65b buy/sel double slider 2023-10-13 20:11:20 +02:00
4cb3de356f score weight + quantils tooltip 2023-10-12 10:22:40 +02:00
0e883dd688 q1 based score 2023-10-07 18:09:44 +02:00
4536d34b92 fix score 2023-10-06 09:05:33 +02:00
a9cd258af8 fix score 2023-10-02 22:07:09 +02:00
e78f59b78a fix 2023-10-02 21:59:01 +02:00
7167640e43 cleanup 2023-10-02 21:48:36 +02:00
866ff0a42b fix 2023-10-02 21:41:50 +02:00
b2304916d6 score formater 2023-10-02 21:31:21 +02:00
7f83ee2ee2 fix score 2023-10-02 19:49:25 +02:00
7d33b77410 score + filter 2023-10-02 19:38:46 +02:00
66f88ef1b1 about page 2023-10-02 10:08:04 +02:00
2b513a91b0 add taxes 2023-10-02 09:52:11 +02:00
0026cba23d postcss 2023-10-02 09:30:26 +02:00
c587fb75f2 cleanup ui 2023-10-01 22:04:01 +02:00
b9eedf4f07 rename to gemory 2023-10-01 18:17:48 +02:00
a5e365328c batch size 2023-10-01 11:54:20 +02:00
7253b864d9 update 2023-10-01 11:50:52 +02:00
0ce205a4a0 batching 2023-10-01 11:49:02 +02:00
c3205a3e74 axios logs 2023-09-23 12:21:02 +02:00
575d4dc5ab remove scan 2023-09-23 11:50:28 +02:00
2c728c7037 fix 2023-09-23 11:44:04 +02:00
14b2f01ef1 cahce apraisal 2023-09-23 10:56:17 +02:00
1c882e0d1c fi 2023-09-21 17:56:54 +02:00
f8e7c95c8b http2 2023-09-21 17:54:21 +02:00
7bd48b5e8d fix tracking 2023-09-21 08:16:13 +02:00
1ac7539dd0 default 2023-09-21 00:12:25 +02:00
eb74ef389e fix tracking 2023-09-21 00:11:07 +02:00
dad7bcfbed http version 2023-09-21 00:04:45 +02:00
892fda3f47 fix enter key 2023-09-20 23:45:16 +02:00
2a798744fb fix 2023-09-20 23:32:31 +02:00
ac8e41fcce fix ngnix 2023-09-20 23:18:42 +02:00
80fdc45174 fix subscription 2023-09-20 23:06:39 +02:00
dabadaa1c9 pinia+track 2023-09-20 21:42:31 +02:00
d64cb69f1e pocketbase login 2023-09-20 17:03:50 +02:00
6a675c28bc pb 2023-09-20 14:03:05 +02:00
7c645b0d0b tracking 2023-09-19 16:04:32 +02:00
6587e4f522 rourtes 2023-09-19 12:02:44 +02:00
cd75aa5b13 rework hierarchy 2023-09-19 11:57:48 +02:00
a483580906 deprecated 2023-09-19 10:49:04 +02:00
e8898f76f0 rename storage 2023-09-19 10:47:59 +02:00
51a37342dd rename 2023-09-19 10:46:04 +02:00
205aef7a3c threshold 2023-09-19 10:45:34 +02:00
4a0da46f2c overal cleanup 2023-09-19 10:42:38 +02:00
158914048b margin 2023-09-18 11:13:34 +02:00
5cce3e6eca position 2023-09-18 11:13:02 +02:00
2b80724692 clipboard 2023-09-18 11:09:56 +02:00
3ac39dcd45 line colors 2023-09-18 11:00:51 +02:00
20defc5b0f alpine + env 2023-09-16 20:55:14 +02:00
dcdb24c591 Revert "cleanup conf"
This reverts commit e499c5aee2.
2023-09-16 20:44:58 +02:00
e499c5aee2 cleanup conf 2023-09-16 20:29:01 +02:00
76131aac07 API_URL 2023-09-16 20:24:26 +02:00
adfafb94e4 EVEPRAISAL_URL 2023-09-16 20:20:40 +02:00
e48fdd3c5c fix ngnix 2023-09-16 20:18:10 +02:00
0e1cb94be0 fix dockerfile 2023-09-16 20:14:31 +02:00
ef627d06bc test envsubst 2023-09-16 20:05:25 +02:00
092b7a9763 search cancel 2023-09-16 19:12:02 +02:00
9dea0b08a6 cleanup 2023-09-16 16:49:11 +02:00
b80d43c375 red lines 2023-09-16 16:48:48 +02:00
f9eb368fe5 CLEANUP 2023-09-16 16:25:57 +02:00
cd52e36f70 user agent 2023-09-16 16:25:13 +02:00
78c07c7806 proxy through ngnix 2023-09-16 16:04:30 +02:00
6580924bbe style 2023-09-16 13:08:05 +02:00
c1f00da176 get orders 2023-09-16 13:02:24 +02:00
3de8f53e0f rework buttons 2023-09-16 12:21:32 +02:00
4301c84b33 use goonpraisal 2023-09-16 12:11:38 +02:00
145af06874 days and filter 2023-09-16 12:10:45 +02:00
c889a813f3 cleanup 2023-09-15 19:29:22 +02:00
d4ded694d6 fix perfs 2023-09-15 19:21:36 +02:00
7d946f49c4 fix request 2023-09-15 18:45:05 +02:00
94e8d03aa2 use alpine 2023-09-15 18:37:01 +02:00
5c84af12ea cache history 2023-09-15 18:32:47 +02:00
018e59a492 update 2023-09-15 16:57:50 +02:00
aebb8b90a4 cleanup 2023-09-13 19:02:28 +02:00
7b765d884e Market quartils 2023-09-13 16:24:55 +02:00
0e378c2c24 nginx 2023-09-03 21:46:30 +02:00
46351fe76c Revert "remove nginx"
This reverts commit de685af94a.
2023-09-03 21:44:45 +02:00
3a08658970 changed step 2023-09-02 10:32:05 +02:00
88fdd5207a module damage bonus 2023-09-02 10:23:14 +02:00
0b5d1a6a22 scale 2023-09-02 10:13:43 +02:00
9ac9296a12 Haulable Value 2023-09-02 10:09:22 +02:00
c2e729b534 update 2023-09-02 09:45:49 +02:00
875eab1dc6 cleanup 2023-07-27 10:48:32 +02:00
c9b23df46b cleanup 2023-07-27 10:48:13 +02:00
cd649473d3 sort 2023-07-27 10:42:05 +02:00
dc9be7db98 draft market 2023-07-27 09:22:47 +02:00
c75f3b6321 Format isk 2023-07-26 17:25:56 +02:00
afa4020b31 market 2023-07-26 16:44:06 +02:00
b078f30bf3 storage 2023-07-26 16:08:20 +02:00
6697bde5ed style 2023-07-26 16:07:42 +02:00
c00bd956e7 style 2023-07-26 16:06:28 +02:00
ac682f168d style 2023-07-26 16:05:02 +02:00
45f202ab23 cleanup 2023-07-26 16:03:17 +02:00
a08c9782eb cleanup 2023-07-26 16:02:54 +02:00
fb0143f5e4 buy sel slider 2023-07-26 15:59:58 +02:00
b78501208e fix dockerfile 2023-07-26 15:38:33 +02:00
b92afbe3e1 coppy to clipboard 2023-07-26 15:37:10 +02:00
dcb3c445bd fix threshold 2023-07-26 15:32:23 +02:00
de685af94a remove nginx 2023-07-26 15:12:34 +02:00
7210f55d42 fix conf 2023-07-26 15:02:24 +02:00
b80c100953 cleanup 2023-07-26 14:46:14 +02:00
298b5c645d nginx conf 2023-07-26 14:41:16 +02:00
d9e2751a4f fix conf 2023-07-26 14:23:04 +02:00
16c827cb18 fix nginx 2023-07-26 14:19:07 +02:00
7b39195873 cors 2023-07-26 14:13:41 +02:00
f81e10c53c ngnix conf 2023-07-26 14:11:41 +02:00
8e1be17f29 fix env prod 2023-07-26 13:57:30 +02:00
3fbcdaaf56 fix build 2023-07-26 13:54:28 +02:00
527b70122d fix env 2023-07-26 13:52:08 +02:00
88 changed files with 4653 additions and 656 deletions

View File

@@ -1,2 +0,0 @@
eveal_api_url=/api/
evepraisal_api_url=/appraisal/

View File

@@ -1,2 +0,0 @@
eveal_api_url=https://api.eveal.shendai.rip/
evepraisal_api_url=https://evepraisal.shendai.rip/

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
docker-compose.yml
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -1,9 +1,13 @@
FROM node:18-alpine as build FROM node:18-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . ./ COPY . ./
RUN npm run build RUN npm run build
FROM nginx FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html
RUN mkdir etc/nginx/templates && \
mv /usr/share/nginx/html/index.html /etc/nginx/templates/index.html.template
COPY nginx.conf /etc/nginx/conf.d/default.conf

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80 http2;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ $uri.html /index.html;
}
}

2522
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,38 @@
{ {
"name": "eveal-frontend", "name": "gemory",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host --debug",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.0.18",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.2.1", "@vueuse/core": "^10.2.1",
"@vueuse/integrations": "^10.2.1", "@vueuse/integrations": "^10.2.1",
"axios": "^1.4.0", "axios": "^1.4.0",
"vue": "^3.3.4" "axios-rate-limit": "^1.3.1",
"loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4",
"oidc-client-ts": "^3.0.1",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.2.3", "@types/node": "^20.4.5",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.27", "postcss": "^8.4.27",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5.0.2", "typescript": "^5.0.2",
"vite": "^4.4.5", "vite": "^5.2.11",
"vue-tsc": "^1.8.5" "vite-plugin-runtime-env": "^0.1.1",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.18"
} }
} }

View File

@@ -1,5 +1,7 @@
export default { export default {
plugins: { plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },

View File

@@ -1,7 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { Reprocess } from './reprocess'; import { useAuthStore } from '@/auth';
import { computed } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { Sidebar } from './sidebar';
const route = useRoute();
const authStore = useAuthStore();
const hideSidebar = computed(() => {
return !authStore.isLoggedIn || route.name === 'callback' || route.name === 'about';
});
</script> </script>
<template> <template>
<Reprocess /> <template v-if="hideSidebar">
<RouterView />
</template>
<template v-else>
<Sidebar />
<div class="main-container">
<RouterView />
</div>
</template>
</template> </template>
<style scoped lang="postcss">
div.main-container {
@apply px-4 sm:ml-64;
}
</style>

49
src/auth.ts Normal file
View File

@@ -0,0 +1,49 @@
import log from "loglevel";
import { Log, User, UserManager, WebStorageStateStore } from "oidc-client-ts";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
Log.setLogger(log);
export const useAuthStore = defineStore('auth', () => {
const userManager = new UserManager({
authority: import.meta.env.VITE_AUTH_AUTHORITY,
client_id: import.meta.env.VITE_AUTH_CLIENT_ID,
client_secret: import.meta.env.VITE_AUTH_CLIENT_SECRET,
redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI,
scope: import.meta.env.VITE_AUTH_SCOPE,
stateStore: new WebStorageStateStore({ store: window.localStorage }),
userStore: new WebStorageStateStore({ store: window.localStorage })
});
const user = ref<User>();
const isLoggedIn = computed(() => !!user.value);
const accessToken = computed(() => user.value?.access_token);
const username = computed(() => user.value?.profile.name ?? "");
const userId = computed(() => user.value?.profile.sub ?? "");
const redirect = async () => {
await userManager.signinRedirect();
log.info("Redirecting to login page");
}
const login = async () => {
await userManager.signinCallback();
log.debug("Logged in");
}
const logout = async () => {
await userManager.signoutRedirect();
log.debug("Logged out");
}
const setUser = (u?: User | null) => {
if (u) {
user.value = u;
log.debug("User loaded", u.profile.name);
} else {
user.value = undefined;
}
}
userManager.events.addUserLoaded(setUser);
userManager.getUser().then(setUser);
return { redirect, login, logout, isLoggedIn, accessToken, username, userId };
});

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { copyToClipboard } from '@/utils';
import { ClipboardIcon } from '@heroicons/vue/24/outline';
interface Props {
value?: string;
}
const props = defineProps<Props>();
const doCopy = () => {
if (!props.value) {
return;
}
copyToClipboard(props.value);
}
</script>
<template>
<button class="btn-icon" title="Copy to clipboard" @click="doCopy">
<ClipboardIcon />
</button>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline';
import { vOnClickOutside } from '@vueuse/components';
import { useEventListener } from '@vueuse/core';
import { ref } from 'vue';
const isOpen = ref(false);
useEventListener('keyup', e => {
if (e.key === 'Escape') {
isOpen.value = false;
}
});
</script>
<template>
<div class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="() => isOpen = false">
<button @click="isOpen = !isOpen">
<slot name="button" />
<Transition name="flip">
<ChevronDownIcon v-if="!isOpen" class="chevron" />
<ChevronUpIcon v-else class="chevron" />
</Transition>
</button>
<Transition name="fade">
<div v-if="isOpen" class="relative">
<div class="z-10 divide-y rounded-b-md absolute">
<slot />
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="postcss">
.chevron {
@apply w-4 h-4 ms-1;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.fade-enter-active, .fade-leave-active {
@apply transition-opacity;
}
.flip-enter-from, .flip-leave-to {
transform: rotate(180deg);
}
.flip-enter-active {
@apply transition-transform;
}
.flip-leave-active {
display: none;
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div role="status">
<svg aria-hidden="true" class="inline w-6 h-6 text-slate-500 animate-spin fill-slate-100" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</template>

45
src/components/Modal.vue Normal file
View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { vOnClickOutside } from '@vueuse/components';
import { useEventListener } from '@vueuse/core';
import { watch } from 'vue';
const open = defineModel('open', { default: false });
watch(open, value => {
if (value) {
document.body.classList.add('overflow-hidden');
} else {
document.body.classList.remove('overflow-hidden');
}
});
useEventListener('keyup', e => {
if (e.key === 'Escape') {
open.value = false;
}
});
</script>
<template>
<Transition name="fade">
<template v-if="open">
<div class="fixed inset-0 z-10">
<div class="absolute bg-black opacity-80 inset-0 z-0" />
<div class="absolute grid inset-0">
<div class="justify-self-center m-auto" v-on-click-outside="() => open = false">
<slot />
</div>
</div>
</div>
</template>
</Transition>
</template>
<style scoped lang="postcss">
.fade-enter-from, .fade-leave-to {
@apply opacity-0;
}
.fade-enter-active, .fade-leave-active {
@apply transition-opacity;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
const modelValue = defineModel({ default: false });
</script>
<template>
<label class="flex items-center relative w-max cursor-pointer select-none">
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-10 h-5 rounded-full checked:bg-emerald-500 peer" v-model="modelValue" />
<span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" />
</label>
</template>
<style scoped lang="postcss">
input:checked ~ span:last-child {
--tw-translate-x: 1.25rem;
}
</style>

7
src/components/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export { default as ClipboardButton } from './ClipboardButton.vue';
export { default as Dropdown } from './Dropdown.vue';
export { default as LoadingSpinner } from './LoadingSpinner.vue';
export { default as Modal } from './Modal.vue';
export { default as SliderCheckbox } from './SliderCheckbox.vue';
export { default as Tooltip } from './tooltip/Tooltip.vue';

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { HeaderComponent, SortDirection } from './sort';
interface Props {
currentSortKey: string | null;
sortDirection?: SortDirection | null;
showColumn?: (k: string) => boolean;
unsortable?: boolean;
sortKey: string;
headerComponent?: HeaderComponent;
}
interface Emit {
(e: 'sort', key: string, direction: SortDirection): void;
}
withDefaults(defineProps<Props>(), {
showColumn: () => () => true,
unsortable: false,
headerComponent: 'th',
});
const emit = defineEmits<Emit>();
</script>
<template>
<component v-if="showColumn(sortKey)" :is="headerComponent" class="sort-header">
<slot />
<template v-if="!unsortable">
<span class="asc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'asc')}" @click="emit('sort', sortKey, 'asc')"></span>
<span class="desc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'desc')}" @click="emit('sort', sortKey, 'desc')"></span>
</template>
</component>
</template>
<style scoped lang="postcss">
.sort-header {
@apply relative h-8 pe-3;
}
span.asc, span.desc {
@apply absolute end-1 cursor-pointer text-xs transition-opacity;
}
span.asc {
@apply top-0.5;
}
span.desc {
@apply bottom-0.5;
}
</style>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { useElementBounding, useVirtualList } from '@vueuse/core';
import { computed, ref } from 'vue';
interface Props {
list?: any[];
itemHeight: number;
headerHeight?: number;
bottom?: string;
}
const props = withDefaults(defineProps<Props>(), {
list: () => [],
});
const { list: values, containerProps, wrapperProps } = useVirtualList(computed(() => props.list), {
itemHeight: () => props.itemHeight,
overscan: 3
})
const tableTop = ref<HTMLSpanElement | null>(null);
const { bottom: offset } = useElementBounding(tableTop);
const ypx = computed(() => {
let y = (offset.value ?? 0) + 'px';
if (props.bottom) {
y = `calc(${y} + ${props.bottom})`;
}
return y;
})
const computedHeaderHeight = computed(() => {
const h = props.headerHeight ?? props.itemHeight ?? 0;
return h + 'px';
})
const computedWrapperProps = computed(() => ({
...wrapperProps.value,
style: {
...wrapperProps.value.style,
height: `calc(${wrapperProps.value.style.height} + ${computedHeaderHeight.value} + 1px)`
}
}))
const itemHeightStyle = computed(() => {
const h = props.itemHeight ?? 0;
return h + 'px';
})
</script>
<template>
<span ref="tableTop" class="h-0" />
<div v-if="list.length > 0" v-bind="containerProps" class="table-container">
<div v-bind="computedWrapperProps">
<table>
<slot :list="values" />
</table>
</div>
</div>
<slot v-else name="empty" />
</template>
<style scoped lang="postcss">
div.table-container {
@apply bg-slate-600;
max-height: calc(100vh - v-bind(ypx));
:deep(>div) {
@apply bg-slate-800;
>table {
>thead {
@apply sticky z-10;
top: -1px;
}
>*>tr, >*>tr>td {
height: v-bind(itemHeightStyle);
}
}
}
&::-webkit-scrollbar-track {
margin-top: v-bind(computedHeaderHeight);
}
}
</style>

View File

@@ -0,0 +1,6 @@
export * from './sort';
export { default as SortableHeader } from './SortableHeader.vue';
export { default as VirtualScrollTable } from './VirtualScrollTable.vue';

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from 'vitest'
import { ref } from 'vue'
import { useSort } from './sort'
describe('useSort', () => {
const array = ref([{ key1: 'b', key2: 'a' }, { key1: 'a', key2: 'b' }])
test('Returns expected properties with default options', () => {
const { sortedArray, headerProps, showColumn } = useSort(array)
expect(sortedArray).toBeDefined()
expect(headerProps).toBeDefined()
expect(showColumn).toBeDefined()
})
test('Returns expected properties with custom options', () => {
const { sortedArray, headerProps, showColumn } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
expect(sortedArray.value[0].key1).toBe('a')
expect(headerProps.value.currentSortKey).toBe('key1')
expect(headerProps.value.sortDirection).toBe('asc')
expect(showColumn('key1')).toBe(true)
})
test('Sorts array in ascending order', () => {
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
headerProps.value.onSort('key1', 'asc')
expect(sortedArray.value[0].key1).toBe('a')
})
test('Sorts array in descending order', () => {
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'desc' })
headerProps.value.onSort('key1', 'desc')
expect(sortedArray.value[0].key1).toBe('b')
})
test('Hides ignored columns', () => {
const { showColumn } = useSort(array, { ignoredColums: ['key1'] })
expect(showColumn('key1')).toBe(false)
})
})

View File

@@ -0,0 +1,48 @@
import { Component, DefineComponent, MaybeRefOrGetter, computed, ref, toValue } from "vue";
export type HeaderComponent = Component | DefineComponent | string;
export type SortDirection = "asc" | "desc";
export type UseSortOptions = {
defaultSortKey?: string;
defaultSortDirection?: SortDirection;
ignoredColums?: MaybeRefOrGetter<string[]>;
headerComponent?: HeaderComponent;
};
export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => {
const sortKey = ref<string | null>(options?.defaultSortKey ?? null);
const sortDirection = ref<SortDirection | null>(options?.defaultSortDirection ?? null);
const sortBy = (key: string, direction: SortDirection) => {
sortKey.value = key;
sortDirection.value = direction;
};
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
const headerProps = computed(() => ({
onSort: sortBy,
showColumn,
currentSortKey: sortKey.value,
sortDirection: sortDirection.value,
headerComponent: options?.headerComponent,
}));
const sortedArray = computed(() => toValue(array).toSorted((a, b) => {
if (sortKey.value === null || sortDirection.value === null) {
return 0;
}
const aValue = (a as any)[sortKey.value];
const bValue = (b as any)[sortKey.value];
if (aValue === bValue) {
return 0;
}
if (sortDirection.value === "asc") {
return aValue > bValue ? 1 : -1;
} else {
return aValue > bValue ? -1 : 1;
}
}));
return { sortedArray, headerProps, showColumn };
}

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { vElementHover } from '@vueuse/components';
import { useElementBounding } from '@vueuse/core';
import { computed, ref } from 'vue';
import { useSharedWindowSize } from './tooltip';
const open = defineModel('open', { default: false });
const { width, height } = useSharedWindowSize();
const mainDiv = ref<HTMLDivElement | null>(null);
const { top, left } = useElementBounding(mainDiv);
const positions = computed(() => {
if (top.value < height.value / 2) {
if (left.value < width.value / 2) {
return ['top', 'left'];
}
return ['top', 'right'];
}
if (left.value < width.value / 2) {
return ['bottom', 'left'];
}
return ['bottom', 'right'];
})
</script>
<template>
<div ref="mainDiv" clas="flex flex-col items-center justify-center" :class="{
'open': open,
'tooltip-top': positions.includes('top'),
'tooltip-bottom': positions.includes('bottom'),
'tooltip-left': positions.includes('left'),
'tooltip-right': positions.includes('right')
}">
<div v-element-hover="(h: boolean) => open = h" class="m-auto header">
<slot name="header" />
</div>
<div v-if="open" class="m-auto">
<div class="z-10 relative">
<div class="absolute">
<slot />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,3 @@
import { createSharedComposable, useWindowSize } from "@vueuse/core";
export const useSharedWindowSize = createSharedComposable(useWindowSize);

23
src/formaters.spec.ts Normal file
View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from 'vitest'
import { formatEveDate, formatIsk } from './formaters'
describe('formatIsk', () => {
test('Formats ISK correctly', () => {
expect(formatIsk(123456789)).toBe('123.456.789,00 ISK')
})
})
describe('formatEveDate', () => {
test('Formats EVE date correctly', () => {
const date = new Date(Date.UTC(2022, 0, 1, 0, 0))
expect(formatEveDate(date)).toBe('2022.01.01 00:00')
})
test('Returns empty string for undefined date', () => {
expect(formatEveDate()).toBe('')
})
test('Returns empty string for null date', () => {
expect(formatEveDate(null)).toBe('')
})
})

View File

@@ -1,10 +1,21 @@
export const iskFormater = new Intl.NumberFormat("is-IS", { const iskFormater = new Intl.NumberFormat("is-IS", {
style: "currency",
currency: "ISK",
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2
}); });
export const formatIsk = (value: number | bigint) => iskFormater.format(value) + " ISK";
export const percentFormater = new Intl.NumberFormat("en-US", { export const percentFormater = new Intl.NumberFormat("en-US", {
style: "percent", style: "percent",
minimumFractionDigits: 0 minimumFractionDigits: 0,
}); maximumFractionDigits: 0
});
const timeFormat = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
minimumIntegerDigits: 2
});
export const formatEveDate = (date?: Date | null) => !date ? '' : `${date.getUTCFullYear()}.${timeFormat.format(date.getUTCMonth() + 1)}.${timeFormat.format(date.getUTCDate())} ${timeFormat.format(date.getUTCHours())}:${timeFormat.format(date.getUTCMinutes())}`;

8
src/logger.ts Normal file
View File

@@ -0,0 +1,8 @@
import log from "loglevel";
import { apply, reg } from "loglevel-plugin-prefix";
export function initLogger() {
log.setLevel(import.meta.env.VITE_LOG_LEVEL);
reg(log);
apply(log, {template: '[%t] %l:'});
}

View File

@@ -1,5 +1,34 @@
import { createApp } from 'vue' import { useAuthStore } from "@/auth";
import App from './App.vue' import { createPinia } from 'pinia';
import './style.css' import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import { initLogger } from './logger';
import { routes } from './routes';
import './style.css';
createApp(App).mount('#app') initLogger();
const app = createApp(App);
const pinia = createPinia();
const router = createRouter({
history: createWebHistory(),
routes,
});
app.use(pinia);
const authStore = useAuthStore();
router.beforeEach(async to => {
if (to.name === 'callback') {
await authStore.login();
return { name: 'home' };
} else if (!authStore.isLoggedIn) {
await authStore.redirect();
}
});
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,3 @@
export type MarbasObject = {
id: number;
}

3
src/marbas/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './MarbasObject';
export * from './marbasService';

View File

@@ -0,0 +1,50 @@
import { useAuthStore } from "@/auth";
import { logResource } from "@/service";
import axios from "axios";
export const marbasAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_MARBAS_URL,
headers: {
'Accept': 'application/json',
"Content-Type": "application/json",
},
})
marbasAxiosInstance.interceptors.request.use(r => {
const authStore = useAuthStore();
if (!authStore.isLoggedIn) {
throw new Error("Not logged in");
}
const accessToken = authStore.accessToken;
if (accessToken) {
r.headers.Authorization = `Bearer ${accessToken}`;
}
if (!r.params?.page_size) {
r.params = { ...r.params, page_size: 250 };
}
return r;
})
logResource(marbasAxiosInstance)
marbasAxiosInstance.interceptors.response.use(async r => {
let next: string = r.data?.next;
let results = r.data?.results;
if (next) {
if (!next.startsWith(import.meta.env.VITE_MARBAS_URL)) { // FIME remove once the API is fixed
next = import.meta.env.VITE_MARBAS_URL + next.replace(/http(s)?:\/\/[^/]+\//g, '');
}
results = results.concat((await marbasAxiosInstance.request({
...r.config,
url: next,
baseURL: '',
})).data);
}
if (results) {
r.data = results;
}
return r;
})

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from 'vitest'
import { RegionalMarketCache } from './RegionalMarketCache'
describe('RegionalMarketCache', () => {
test('should cache and retrieve values', async () => {
const cache = new RegionalMarketCache<string>(1000)
cache.set(1, 1, 'test')
expect(cache.get(1, 1)).toBe('test')
})
test('should remove values', async () => {
const cache = new RegionalMarketCache<string>(1000)
cache.set(1, 1, 'test')
cache.remove(1, 1)
expect(cache.get(1, 1)).toBeUndefined()
})
test('should compute values if absent', async () => {
const cache = new RegionalMarketCache<string>(1000)
const value = await cache.computeIfAbsent(1, 1, () => Promise.resolve('test'))
expect(value).toBe('test')
expect(cache.get(1, 1)).toBe('test')
})
test('should expire values', async () => {
const cache = new RegionalMarketCache<string>(1)
cache.set(1, 1, 'test')
await new Promise(resolve => setTimeout(resolve, 10))
expect(cache.get(1, 1)).toBeUndefined()
})
})

View File

@@ -0,0 +1,50 @@
class CacheEntry<T> {
public value: T;
public expiration: Date;
constructor(value: T, expiration: Date) {
this.value = value;
this.expiration = expiration;
}
}
export type ExpirationSupplier<T> = (v: T) => Date;
export class RegionalMarketCache<T> {
private cache: Record<number, Record<number, CacheEntry<T>>>;
private expirationSupplier: (v: T) => Date;
constructor(expiration: ExpirationSupplier<T> | number) {
this.cache = {};
this.expirationSupplier = expiration instanceof Function ? expiration : () => new Date(Date.now() + expiration);
}
public get(regionId: number, typeId: number): T | undefined {
const entry = this.cache[regionId]?.[typeId];
if (entry && entry.expiration > new Date()) {
return entry.value;
}
this.remove(regionId, typeId);
return undefined;
}
public set(regionId: number, typeId: number, value: T): void {
this.cache[regionId] = this.cache[regionId] ?? {};
this.cache[regionId][typeId] = new CacheEntry(value, this.expirationSupplier(value));
}
public remove(regionId: number, typeId: number): void {
delete this.cache[regionId]?.[typeId];
}
public async computeIfAbsent(regionId: number, typeId: number, supplier: () => (Promise<T> | T)): Promise<T> {
let value = this.get(regionId, typeId);
if (!value) {
value = await supplier();
this.set(regionId, typeId, value);
}
return value;
}
};

View File

@@ -0,0 +1,10 @@
import { MarketType } from "..";
import { MarbasAcquiredType } from "./acquisition";
export type AcquiredType = Omit<MarbasAcquiredType, 'type'> & {
type: MarketType,
buy: number,
sell: number
}
export const acquiredTypesToSorted = <T extends {date: Date} = AcquiredType>(array: T[], reverse?: boolean) => array.toSorted((a, b) => reverse ? b.date.getTime() - a.date.getTime() : a.date.getTime() - b.date.getTime())

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { LoadingSpinner, Tooltip } from '@/components';
import { formatIsk } from '@/formaters';
import { getHistory, getHistoryQuartils } from '@/market';
import { ArrowTrendingDownIcon, ArrowTrendingUpIcon } from '@heroicons/vue/24/outline';
import { computedAsync } from '@vueuse/core';
import { ref, watchEffect } from 'vue';
const trendingScale = 3;
interface Props {
id: number;
buy: number;
sell: number;
}
const props = defineProps<Props>();
const open = ref(false);
const q1 = ref(0);
const median = ref(0);
const q3 = ref(0);
const lineColor = ref('');
const history = computedAsync(() => getHistory(props.id), []);
watchEffect(async () => {
if (!open.value || !props.id) {
return;
}
const quartils = getHistoryQuartils(history.value);
q1.value = quartils.q1;
median.value = quartils.median;
q3.value = quartils.q3;
if (props.buy >= quartils.q3) {
lineColor.value = 'line-blue';
} else if (props.sell >= quartils.q3) {
lineColor.value = 'line-green';
} else {
lineColor.value = '';
}
})
</script>
<template>
<Tooltip v-model:open="open" class="tooltip">
<template #header>
<LoadingSpinner v-if="history.length < trendingScale" />
<ArrowTrendingUpIcon v-else-if="history[0].average > history[trendingScale - 1].average" />
<ArrowTrendingDownIcon v-else />
</template>
<template #default>
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="history.length > 0">
<table>
<thead>
<tr>
<th>Q1</th>
<th>Median</th>
<th>Q3</th>
</tr>
</thead>
<tbody>
<tr :class="lineColor">
<td class="text-right text-nowrap">{{ formatIsk(q1) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(median) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(q3) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</Tooltip>
</template>
<style scoped lang="postcss">
.tooltip {
@apply ms-auto;
>:deep(div.header) {
@apply btn-icon px-2;
}
&.open {
&.tooltip-top>:deep(div.header) {
@apply rounded-t-md bg-slate-600;
}
&.tooltip-bottom {
.tooltip-content {
bottom: 79px;
}
>:deep(div.header) {
@apply rounded-b-md bg-slate-600;
}
}
}
}
</style>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
import { formatEveDate, formatIsk, percentFormater } from '@/formaters';
import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
import { MinusIcon, PlusIcon } from '@heroicons/vue/24/outline';
import { useStorage } from '@vueuse/core';
import { computed, ref } from 'vue';
import { AcquiredType } from './AcquiredType';
import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue';
type Result = {
id: number;
type: MarketType;
name: string;
buy: number;
sell: number;
price: number;
remaining: number;
quantity: number;
precentProfit: number;
iskProfit: number;
date: Date;
acquisitions: AcquiredType[];
}
interface Props {
items?: AcquiredType[];
infoOnly?: boolean;
showAll?: boolean;
ignoredColums?: string[] | string;
defaultSortKey?: string;
}
interface Emits {
(e: 'buy', type: AcquiredType[], price: number, buy: number, sell: number): void;
(e: 'sell', type: AcquiredType[]): void;
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
infoOnly: false,
showAll: false,
ignoredColums: () => [],
defaultSortKey: 'precentProfit',
});
defineEmits<Emits>();
const columnsToIgnore = computed(() => {
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
if (props.infoOnly && !ic.includes('buttons')) {
return [...ic, 'buttons'];
}
return ic;
});
const marketTaxStore = useMarketTaxStore();
const threshold = useStorage('market-acquisition-threshold', 10);
const filter = ref("");
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => {
const filteredItems = props.items.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()));
if (props.showAll) {
return filteredItems.map(r => {
const precentProfit = marketTaxStore.calculateProfit(r.price, r.sell);
return {
id: r.id,
type: r.type,
name: r.type.name,
buy: r.buy,
sell: r.sell,
price: r.price,
remaining: r.remaining,
quantity: r.quantity,
precentProfit,
iskProfit: r.price * precentProfit * r.remaining,
date: r.date,
acquisitions: [r]
};
});
}
const list: Result[] = [];
const groups = Map.groupBy(filteredItems, r => r.type.id);
groups.forEach((group, typeID) => {
const first = group[0];
if (!first) {
return;
}
const total = group.reduce((acc, r) => acc + r.quantity, 0);
const totalRemaining = group.reduce((acc, r) => acc + r.remaining, 0);
const price = group.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
list.push({
id: typeID,
type: first.type,
name: first.type.name,
buy: first.buy,
sell: first.sell,
price: price,
remaining: totalRemaining,
quantity: total,
precentProfit,
iskProfit: price * precentProfit * totalRemaining,
date: first.date,
acquisitions: group
});
});
return list;
}), {
defaultSortKey: props.defaultSortKey,
defaultSortDirection: 'desc',
ignoredColums: columnsToIgnore
})
const getLineColor = (result: Result) => {
if (result.precentProfit >= (threshold.value / 100)) {
return 'line-green';
} else if (result.precentProfit < 0) {
return 'line-red';
}
return '';
}
</script>
<template>
<div class="flex" v-if="!infoOnly">
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
<TaxInput />
<div class="end">
<span>Profit Threshold: </span>
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
</div>
<div class="end">
<span>Filter: </span>
<input type="search" class="w-96" v-model="filter" />
</div>
</div>
</div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="date">Bought at</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="precentProfit">Profit (%)</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="iskProfit">Profit (ISK)</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
</tr>
</thead>
<tbody>
<tr v-for="r in list" :key="r.index" :class="getLineColor(r.data)">
<td v-if="showColumn('name')">
<div class="flex">
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" />
<AcquisitionQuantilsTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" />
</div>
</td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
<td v-if="showColumn('date')" class="text-right">{{ formatEveDate(r.data.date) }}</td>
<td v-if="showColumn('price')" class="text-right">{{ formatIsk(r.data.price) }}</td>
<td v-if="showColumn('remaining')" class="text-right">{{ r.data.remaining }}/{{ r.data.quantity }}</td>
<td v-if="showColumn('precentProfit')" class="text-right">{{ percentFormater.format(r.data.precentProfit) }}</td>
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(r.data.iskProfit) }}</td>
<td v-if="showColumn('buttons')" class="text-right">
<button class="btn-icon me-1" @click="$emit('buy', r.data.acquisitions, r.data.price, r.data.buy, r.data.sell)"><PlusIcon /></button>
<button class="btn-icon me-1" @click="$emit('sell', r.data.acquisitions)"><MinusIcon /></button>
</td>
</tr>
</tbody>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>
<style scoped lang="postcss">
div.end {
@apply justify-self-end ms-2;
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Modal } from '@/components';
import { formatIsk } from '@/formaters';
import { MarketType, MarketTypeLabel } from '@/market';
import { ref } from 'vue';
import { useAcquiredTypesStore } from './acquisition';
const acquiredTypesStore = useAcquiredTypesStore();
const modalOpen = ref<boolean>(false);
const type = ref<MarketType>();
const suggestions = ref<Record<string, number>>({});
const price = ref(1000000);
const count = ref(1);
const open = (t: MarketType, s?: Record<string, number> | number) => {
type.value = t;
count.value = 1;
if (typeof s === 'number') {
suggestions.value = {};
price.value = s;
} else if (s) {
suggestions.value = s;
price.value = Object.values(s)[0];
} else {
suggestions.value = {};
price.value = 1000000;
}
modalOpen.value = true;
}
const add = () => {
const id = type.value?.id;
if (!id) {
modalOpen.value = false;
return;
}
acquiredTypesStore.addAcquiredType(id, count.value, price.value);
modalOpen.value = false;
}
defineExpose({ open });
</script>
<template>
<Modal v-model:open="modalOpen">
<div class="bg-slate-800 rounded">
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
<hr />
<div class="flex p-4">
<div class="flex me-2">
<span>Price: </span>
<div class="ms-2">
<input type="number" min="0" step="1" v-model="price" @keyup.enter="add" />
<div class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" v-for="(p, n) of suggestions" :key="n" @click="price = p">{{ n }}: {{ formatIsk(p) }}</div>
</div>
</div>
<div class="flex me-2 mb-auto">
<span>Count: </span>
<input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="add" />
</div>
<button class="mb-auto" @click="add">Add</button>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { Modal } from '@/components';
import { MarketType, MarketTypeLabel } from '@/market';
import { ref } from 'vue';
import { AcquiredType, acquiredTypesToSorted } from './AcquiredType';
import { useAcquiredTypesStore } from './acquisition';
const acquiredTypesStore = useAcquiredTypesStore();
const modalOpen = ref<boolean>(false);
const type = ref<MarketType>();
const count = ref(1);
const types = ref<AcquiredType[]>([]);
const open = (t: AcquiredType[]) => {
if (t.length === 0) {
return;
}
types.value = acquiredTypesToSorted(t);
type.value = t[0].type;
count.value = 1;
modalOpen.value = true;
}
const remove = async () => {
if (!types.value) {
modalOpen.value = false;
return;
}
let c = count.value;
for (const type of types.value) {
const remaining = type.remaining;
await acquiredTypesStore.removeAcquiredType(type.id, c);
c -= remaining;
if (c <= 0) {
break;
}
}
modalOpen.value = false;
}
defineExpose({ open });
</script>
<template>
<Modal v-model:open="modalOpen">
<div class="bg-slate-800 rounded">
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
<hr />
<div class="flex p-4">
<div class="flex me-2 mb-auto">
<span>Count: </span>
<div class="ms-2">
<input type="number" min="0" step="1" v-model="count" @keyup.enter="remove" />
<div>
<button class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" @click="count = types.reduce((acc, t) => acc + t.remaining, 0)">All</button>
</div>
</div>
</div>
<button class="mb-auto" @click="remove">Remove</button>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,74 @@
import { marbasAxiosInstance, MarbasObject } from "@/marbas";
import { AxiosResponse } from "axios";
import log from "loglevel";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export type AcquiredTypeSource = 'bo' | 'so' | 'prod' | 'misc';
export type MarbasAcquiredType = MarbasObject & {
type: number;
quantity: number;
remaining: number;
price: number;
date: Date;
source: AcquiredTypeSource;
user: number;
}
type RawMarbasAcquiredType = Omit<MarbasAcquiredType, 'date'> & {
date: string;
}
type InsertableRawMarbasAcquiredType = Omit<MarbasAcquiredType, 'id' | 'user' | 'date'>;
const mapRawMarbasAcquiredType = (raw: RawMarbasAcquiredType): MarbasAcquiredType => ({
...raw,
date: raw.date ? new Date(raw.date) : new Date()
});
const endpoint = '/api/acquisitions/';
export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
const acquiredTypes = ref<MarbasAcquiredType[]>([]);
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0));
const addAcquiredType = async (type: number, quantity: number, price: number, source?: AcquiredTypeSource) => {
const newItem = mapRawMarbasAcquiredType((await marbasAxiosInstance.post<RawMarbasAcquiredType, AxiosResponse<RawMarbasAcquiredType>, InsertableRawMarbasAcquiredType>(endpoint, {
type: type,
quantity: quantity,
remaining: quantity,
price: price,
source: source ?? 'misc',
})).data);
acquiredTypes.value = [...acquiredTypes.value, newItem];
log.info(`Acquired type ${newItem.id} with quantity ${newItem.quantity} and price ${newItem.price}`, newItem);
};
const removeAcquiredType = async (id: number, quantity: number) => {
const found = acquiredTypes.value.find(t => t.id === id);
if (!found) {
return 0;
}
const item = {
...found,
remaining: Math.max(0, found.remaining - quantity)
};
acquiredTypes.value = acquiredTypes.value.map(i => {
if (i.id === item.id) {
return item;
} else {
return i;
}
});
await marbasAxiosInstance.put(`${endpoint}${item.id}/`, item);
log.info(`Acquired type ${item.id} remaining: ${item.remaining}`, item);
};
marbasAxiosInstance.get<RawMarbasAcquiredType[]>(endpoint).then(res => acquiredTypes.value = res.data.map(mapRawMarbasAcquiredType));
return { acquiredTypes: types, addAcquiredType, removeAcquiredType };
});

View File

@@ -0,0 +1,7 @@
export * from './AcquiredType';
export * from './acquisition';
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
export { default as BuyModal } from './BuyModal.vue';
export { default as SellModal } from './SellModal.vue';

View File

@@ -0,0 +1,11 @@
import { MarketType } from "../type";
export type MarketTypePrice = {
type: MarketType;
buy: number;
sell: number;
orderCount: number;
};
export type PriceGetter = (types: MarketType[]) => Promise<MarketTypePrice[]>;

View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia';
import { RegionalMarketCache } from '../RegionalMarketCache';
import { jitaId } from '../market';
import { MarketType } from "../type";
import { MarketTypePrice } from './MarketTypePrice';
import { getEvepraisalPrices } from './evepraisal';
import { getfuzzworkPrices } from './fuzzwork';
const cacheDuration = 1000 * 60 * 5; // 5 minutes
const priceGetters = {
evepraisal: getEvepraisalPrices,
fuzzwork: getfuzzworkPrices
}
export const useApraisalStore = defineStore('appraisal', () => {
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(cacheDuration);
const getPricesUncached = priceGetters.fuzzwork;
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0];
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => {
const cached: MarketTypePrice[] = [];
const uncached: MarketType[] = [];
const rId = regionId ?? jitaId;
types.forEach(t => {
const cachedPrice = cache.get(rId, t.id);
if (cachedPrice) {
cached.push(cachedPrice);
} else {
uncached.push(t);
}
});
if (uncached.length > 0) {
const prices = await getPricesUncached(uncached);
prices.forEach(p => cache.set(rId, p.type.id, p));
return [ ...cached, ...prices ];
}
return cached;
};
return { getPrice, getPrices };
});

View File

@@ -0,0 +1,31 @@
import { logResource } from '@/service';
import axios from 'axios';
import { MarketType } from "../type";
import { PriceGetter } from './MarketTypePrice';
export const evepraisalAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"
},
})
logResource(evepraisalAxiosInstance)
const batchSize = 100;
export const getEvepraisalPrices: PriceGetter = async types => {
const batches = [];
for (let i = 0; i < types.length; i += batchSize) {
batches.push(evepraisalAxiosInstance.post(`/appraisal.json?market=jita&persist=no&raw_textarea=${types.slice(i, i + batchSize).map(t => t.name).join("%0A")}`));
}
return (await Promise.all(batches))
.flatMap(b => b.data.appraisal.items)
.map((item: any) => ({
type: types.find(t => t.name === item.typeName) as MarketType,
buy: item.prices.buy.max,
sell: item.prices.sell.min,
orderCount: item.prices.all.order_count
}));
};

View File

@@ -0,0 +1,39 @@
import { logResource } from '@/service';
import axios from 'axios';
import { MarketType } from "../type";
import { PriceGetter } from './MarketTypePrice';
export const fuzzworkAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_FUZZWORK_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"
},
})
logResource(fuzzworkAxiosInstance)
const batchSize = 100;
export const getfuzzworkPrices: PriceGetter = async types => {
const batches = [];
for (let i = 0; i < types.length; i += batchSize) {
batches.push(fuzzworkAxiosInstance.post(`/aggregates/?station=60003760&types=${types.slice(i, i + batchSize).map(t => t.id).join(",")}`));
}
return (await Promise.all(batches))
.flatMap(b => Object.entries(b.data))
.map(entry => {
const id = doParseInt(entry[0]);
const prices = entry[1] as any;
return {
type: types.find(t => t.id === id) as MarketType,
buy: doParseFloat(prices?.buy?.max),
sell: doParseFloat(prices?.sell?.min),
orderCount: doParseInt(prices?.buy?.order_count) + doParseInt(prices?.sell?.order_count)
}
});
};
const doParseInt = (s?: string) => s ? parseInt(s) : 0;
const doParseFloat = (s?: string) => s ? parseFloat(s) : 0;

View File

@@ -0,0 +1,2 @@
export * from './MarketTypePrice';
export * from './appraisal';

View File

@@ -0,0 +1,30 @@
import { esiAxiosInstance } from "@/service";
import { RegionalMarketCache } from '../RegionalMarketCache';
import { jitaId } from "../market";
export type EsiMarketOrderHistory = {
average: number;
date: string;
highest: number;
lowest: number;
order_count: number;
volume: number;
}
// TODO use pinia store
const historyCache: RegionalMarketCache<EsiMarketOrderHistory[]> = new RegionalMarketCache(() => {
const date = new Date();
if (date.getUTCHours() >= 11) {
date.setUTCDate(date.getUTCDate() + 1);
}
date.setUTCHours(11, 0, 0, 0);
return date;
});
export const getHistory = async (tyeId: number, regionId?: number): Promise<EsiMarketOrderHistory[]> => {
const rId = regionId ?? jitaId;
return historyCache.computeIfAbsent(rId, tyeId, async () => (await esiAxiosInstance.get(`/markets/${rId}/history/`, { params: { type_id: tyeId } })).data);
}

View File

@@ -0,0 +1,59 @@
import { EsiMarketOrderHistory } from "@/market";
export type HistoryQuartils = {
totalVolume: number,
q1: number,
median: number,
q3: number,
}
export const getHistoryQuartils = (history: EsiMarketOrderHistory[], days?: number): HistoryQuartils => {
const now = Date.now();
const volumes = history
.flatMap(h => {
const volume = h.volume;
if (volume === 0 || (days && new Date(h.date).getTime() < now - days * 24 * 60 * 60 * 1000)) {
return [];
}
const e = estimateVolume(h);
return [[h.highest, e], [h.lowest, volume - e]];
})
.filter(h => h[1] > 0)
.sort((a, b) => a[0] - b[0]);
const totalVolume = volumes.reduce((acc, [_, v]) => acc + v, 0);
const quartilVolume = totalVolume / 4;
const quartils: [number, number, number] = [0, 0, 0];
let currentVolume = 0;
let quartil = 0;
for (const [price, volume] of volumes) {
currentVolume += volume;
if (currentVolume >= quartilVolume * (quartil + 1)) {
quartils[quartil] = price;
if (quartil === 2) {
break;
}
quartil++;
}
}
return {
totalVolume,
q1: quartils[0],
median: quartils[1],
q3: quartils[2],
};
}
const estimateVolume = (history: EsiMarketOrderHistory): number => {
if (history.volume === 0) {
return 0;
}
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
}

View File

@@ -0,0 +1,2 @@
export * from './EsiMarketOrderHistory';
export * from './HistoryQuartils';

8
src/market/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from './RegionalMarketCache';
export * from './history';
export * from './tax';
export * from './type';
export * from './appraisal';
export * from './market';

3
src/market/market.ts Normal file
View File

@@ -0,0 +1,3 @@
export const jitaId = 10000002;

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useMarketTaxStore } from "./tax";
const { brokerFee, scc } = storeToRefs(useMarketTaxStore());
</script>
<template>
<div class="end">
<span>Broker Fee: </span>
<input type="number" min="1" max="3" step="0.01" v-model="brokerFee" />
</div>
<div class="end">
<span>SCC: </span>
<input type="number" min="3.6" max="8" step="0.01" v-model="scc" >
</div>
</template>
<style scoped lang="postcss">
div.end {
@apply justify-self-end ms-2;
}
</style>

4
src/market/tax/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './tax';
export { default as TaxInput } from './TaxInput.vue';

12
src/market/tax/tax.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useLocalStorage } from "@vueuse/core";
import { defineStore } from "pinia";
export const useMarketTaxStore = defineStore("marketTax", () => {
const brokerFee = useLocalStorage("market-brokerFee", 1.5);
const scc = useLocalStorage("market-scc", 3.6);
const applyTaxes = (price: number, sellOrder?: boolean) => sellOrder ? price * (1 - (brokerFee.value + scc.value) / 100) : price * (1 + brokerFee.value / 100);
const calculateProfit = (buy: number, sell: number) => (applyTaxes(sell, true) / applyTaxes(buy)) - 1;
return { brokerFee, scc, applyTaxes, calculateProfit };
});

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { SliderCheckbox } from '@/components';
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
import { formatIsk, percentFormater } from '@/formaters';
import { getHistoryQuartils, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
import { BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
import { useStorage } from '@vueuse/core';
import { computed, ref } from 'vue';
import { TrackingResult } from './tracking';
type Result = {
type: MarketType;
typeID: number;
name: string;
buy: number;
sell: number;
q1: number;
median: number;
q3: number;
profit: number;
score: number;
}
interface Props {
items?: TrackingResult[];
infoOnly?: boolean;
ignoredColums?: string[] | string;
}
interface Emits {
(e: 'buy', type: MarketType, buy: number, sell: number): void;
(e: 'remove', type: MarketType): void;
}
const scoreFormater = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0
});
const props = withDefaults(defineProps<Props>(), {
items: () => [],
infoOnly: false,
ignoredColums: () => []
});
defineEmits<Emits>();
const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-tracking-days', 365);
const threshold = useStorage('market-tracking-threshold', 10);
const filter = ref("");
const onlyCheap = ref(false);
const columnsToIgnore = computed(() => {
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
if (props.infoOnly && !ic.includes('buttons')) {
return [...ic, 'buttons'];
}
return ic;
});
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
.map(r => {
const quartils = getHistoryQuartils(r.history, days.value);
const profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : marketTaxStore.calculateProfit(quartils.q1, quartils.q3);
const score = profit <= 0 ? 0 : Math.sqrt((Math.pow(quartils.totalVolume, 1.1) * Math.pow(quartils.q1, 1.2) * Math.pow(profit, 0.5) * Math.pow(Math.max(1, r.orderCount), -0.7)) / days.value);
return {
type: r.type,
typeID: r.type.id,
name: r.type.name,
buy: r.buy,
sell: r.sell,
q1: quartils.q1,
median: quartils.median,
q3: quartils.q3,
profit,
score
};
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
defaultSortKey: 'score',
defaultSortDirection: 'desc',
ignoredColums: columnsToIgnore
})
const getLineColor = (result: Result) => {
if (props.infoOnly) {
return '';
} else if (result.profit < (threshold.value / 100)) {
return 'line-red';
} else if (result.sell > 0 && result.sell <= result.q1) {
return 'line-blue';
} else if (result.buy <= result.q1) {
return 'line-green';
}
return '';
}
</script>
<template>
<div v-if="!infoOnly" class="flex mb-2 mt-4">
<div class="flex justify-self-end ms-auto">
<TaxInput />
<div class="end">
<span>Profit Threshold: </span>
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
</div>
<div class="end">
<span>Days: </span>
<input type="number" min="1" max="365" step="1" v-model="days" />
</div>
<div class="end flex">
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
</div>
<div class="end">
<span>Filter: </span>
<input type="search" class="w-96" v-model="filter" />
</div>
</div>
</div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
</tr>
</thead>
<tbody>
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
<td v-if="showColumn('name')">
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
</td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</td>
<td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.data.profit) }}</td>
<td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td>
<td v-if="showColumn('buttons')" class="text-right">
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button>
<button class="btn-icon me-1" title="Untrack" @click="$emit('remove', r.data.type)"><BookmarkSlashIcon /></button>
</td>
</tr>
</tbody>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>
<style scoped lang="postcss">
div.end {
@apply justify-self-end ms-2;
}
</style>../history/HistoryQuartils

View File

@@ -0,0 +1,4 @@
export * from './tracking';
export { default as TrackingResultTable } from './TrackingResultTable.vue';

View File

@@ -0,0 +1,50 @@
import { marbasAxiosInstance, MarbasObject } from "@/marbas";
import { EsiMarketOrderHistory, getHistory, MarketType, MarketTypePrice } from "@/market";
import log from "loglevel";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export type TrackingResult = {
type: MarketType;
history: EsiMarketOrderHistory[];
buy: number,
sell: number,
orderCount: number,
}
export type MarbasTrackedType = MarbasObject & {
type: number
};
const endpoint = '/api/types_tracking/';
export const useMarketTrackingStore = defineStore('marketTracking', () => {
const trackedTypes = ref<MarbasTrackedType[]>([]);
const types = computed(() => trackedTypes.value.map(item => item.type) ?? []);
const addType = async (type: number) => {
const found = trackedTypes.value.find(item => item.type === type);
if (!found) {
trackedTypes.value = [...trackedTypes.value, (await marbasAxiosInstance.post<MarbasTrackedType>(endpoint, { type })).data];
log.info(`Tracking type ${type}`);
}
}
const removeType = async (type: number) => {
const found = trackedTypes.value.find(item => item.type === type);
if (!found) {
return;
}
trackedTypes.value = trackedTypes.value.filter(t => t.id !== found.id);
await marbasAxiosInstance.delete(`${endpoint}${found.id}`);
log.info(`Stopped tracking type ${type}`);
}
marbasAxiosInstance.get<MarbasTrackedType[]>(endpoint).then(res => trackedTypes.value = res.data);
return { types, addType, removeType };
});
export const createResult = async (id: number, price: MarketTypePrice): Promise<TrackingResult> => ({ history: await getHistory(id), ...price });

View File

@@ -0,0 +1,57 @@
import { marbasAxiosInstance } from "@/marbas";
export type MarketType = {
id: number;
group_id: number;
marketgroup_id: number;
name: string;
published: boolean;
description: string;
basePrice: number;
icon_id: number;
volume: number;
portionSize: number;
}
export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0];
export const getMarketTypes = async (types: (string | number)[]): Promise<MarketType[]> => {
if (types.length === 0) {
return [];
} else if (types.length === 1 && typeof types[0] === "number") {
return [(await marbasAxiosInstance.get<MarketType>(`/sde/types/${types[0]}/`)).data];
}
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", types.map(t => {
if (typeof t === "number") {
return { id: t };
} else {
return { name: t };
}
}))).data;
}
const blueprintMarketGrous = [ // TODO add all groups
2,
2157,
2159,
2339,
2160,
211,
1016,
339,
2290,
357,
1530,
359,
1531,
1532,
1533,
358
]
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", [{
name__icontains: search,
marketgroup_id___not: null,
marketgroup_id__in___not: blueprintMarketGrous,
}])).data;
}

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { vOnClickOutside } from '@vueuse/components';
import { useVirtualList } from '@vueuse/core';
import log from 'loglevel';
import { nextTick, ref, watch, watchEffect } from 'vue';
import { MarketType, searchMarketTypes } from './MarketType';
import MarketTypeLabel from "./MarketTypeLabel.vue";
interface Emits {
(e: 'submit'): void;
}
const modelValue = defineModel<MarketType>();
const emit = defineEmits<Emits>();
const isOpen = ref(false);
const name = ref('');
const suggestions = ref<MarketType[]>([]);
const currentIndex = ref(-1);
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(suggestions, {
itemHeight: 24,
overscan: 3
});
const moveDown = () => {
if (currentIndex.value < 0 || currentIndex.value >= suggestions.value.length - 1) {
currentIndex.value = 0;
} else if (currentIndex.value < suggestions.value.length - 1) {
currentIndex.value++;
}
scrollTo(currentIndex.value);
}
const moveUp = () => {
if (currentIndex.value <= 0) {
currentIndex.value = suggestions.value.length - 1;
} else if (currentIndex.value > 0) {
currentIndex.value--;
}
scrollTo(currentIndex.value);
}
const select = (type?: MarketType) => {
log.debug('Select:', type);
modelValue.value = type;
currentIndex.value = -1;
suggestions.value = [];
isOpen.value = false;
name.value = type?.name ?? '';
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
const submit = async () => {
if (currentIndex.value >= 0 && currentIndex.value < suggestions.value.length) {
const v = suggestions.value[currentIndex.value];
select(v);
await nextTick();
} else if (modelValue.value === undefined && suggestions.value.length > 0) {
select(suggestions.value[0]);
await nextTick();
}
if (modelValue.value === undefined) {
return;
}
emit('submit');
}
watch(() => modelValue.value, async v => {
if (v === undefined) {
name.value = '';
} else {
name.value = v.name;
}
})
watchEffect(async () => {
const search = name.value.split('\t')[0];
if (!isOpen.value || search.length < 3) {
suggestions.value = [];
} else {
suggestions.value = await searchMarketTypes(search);
scrollTo(0);
}
currentIndex.value = -1;
})
</script>
<template>
<div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false">
<div class="fake-input">
<img v-if="modelValue?.id" :src="`https://images.evetech.net/types/${modelValue.id}/icon?size=32`" alt="" />
<input type="text" v-model="name" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp" />
</div>
<div v-if="suggestions.length > 1" class="z-20 absolute w-96">
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
<div v-bind="wrapperProps">
<div v-for="s in list" :key="s.index" class="hover:bg-slate-700" :class="{'bg-slate-500': s.index !== currentIndex, 'bg-emerald-500': s.index === currentIndex}" @click="select(s.data)">
<MarketTypeLabel :id="s.data.id" :name="s.data.name" class="whitespace-nowrap overflow-hidden cursor-pointer" hideCopy />
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="postcss">
.fake-input {
@apply w-96 flex border bg-slate-500 rounded px-1 py-0.5;
&:has(> input:focus-visible) {
outline: -webkit-focus-ring-color auto 1px;
}
> input {
@apply w-full border-none bg-transparent block focus-visible:outline-none;
box-sizing: border-box;
}
> img {
@apply inline-block w-5 h-5 mt-1 me-1;
}
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ClipboardButton } from '@/components';
import { InformationCircleIcon } from '@heroicons/vue/24/outline';
interface Props {
name?: string;
id?: number;
hideCopy?: boolean;
}
withDefaults(defineProps<Props>(), {
name: "",
id: 0,
hideCopy: false
});
</script>
<template>
<div v-if="id || name" class="flex flex-row">
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon?size=32`" class="inline-block w-5 h-5 me-1 mt-1" alt="" />
<template v-if="name">
{{ name }}
<RouterLink v-if="id" :to="{ name: 'market-types', params: { type: id } }" class="button btn-icon ms-1 me-1 mt-1" title="Show item info">
<InformationCircleIcon />
</RouterLink>
<ClipboardButton v-if="!hideCopy" :value="name" />
</template>
</div>
</template>
<style scoped lang="postcss">
button:deep(>svg), .button:deep(>svg) {
@apply !w-4 !h-4;
}
</style>

4
src/market/type/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './MarketType';
export { default as MarketTypeLabel } from './MarketTypeLabel.vue';
export { default as MarketTypeInput } from './MarketTypeInput.vue';

8
src/pages/About.vue Normal file
View File

@@ -0,0 +1,8 @@
<script setup lang="ts">
</script>
<template>
<div>
<span>EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide. All other trademarks are the property of their respective owners. EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf. All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the intellectual property relating to these trademarks are likewise the intellectual property of CCP hf. CCP hf. has granted permission to Eveal to use EVE Online and all associated logos and designs for promotional and information purposes on its website but does not endorse, and is not in any way affiliated with, Eveal. CCP is in no way responsible for the content on or functioning of this website, nor can it be liable for any damage arising from the use of this website.</span>
</div>
</template>

11
src/pages/Characters.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
const addCharacter = () => {
// TODO
}
</script>
<template>
<div class="grid mb-2 mt-4">
<button class="justify-self-end" @click="addCharacter">Add chacarcter</button>
</div>
</template>

6
src/pages/Index.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<div></div>
</template>

30
src/pages/Market.vue Normal file
View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>
<template>
<div class="mt-4">
<div class="flex border-b-2 border-emerald-500">
<RouterLink :to="{name: 'market-types'}" class="tab">
<span>Item Info</span>
</RouterLink>
<RouterLink to="/market/tracking" class="tab">
<span>Tracking</span>
</RouterLink>
<RouterLink to="/market/acquisitions" class="tab">
<span>Acquisitions</span>
</RouterLink>
</div>
<RouterView />
</div>
</template>
<style scoped lang="postcss">
a.tab {
@apply flex items-center px-4 me-2 rounded-t-md bg-slate-600 hover:bg-slate-700;
&.router-link-active {
@apply bg-emerald-500 hover:bg-emerald-700;
}
}
</style>

View File

@@ -1,37 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { ReprocessInput, ReprocessItemValues, ReprocessResultTable, reprocess } from '@/reprocess';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { ref } from 'vue'; import { ref } from 'vue';
import ReprocessInput from './ReprocessInput.vue';
import ReprocessResultTable from './ReprocessResultTable.vue';
import { ReprocessItemValues, reprocess } from './reprocess';
const items = ref(""); const items = ref("");
const minerals = ref(""); const materials = ref("");
const efficiency = useStorage('reprocess-efficiency', 0.55); const efficiency = useStorage('reprocess-efficiency', 0.55);
const result = ref<ReprocessItemValues[]>([]); const result = ref<ReprocessItemValues[]>([]);
const send = async () => result.value = await reprocess(items.value, minerals.value, efficiency.value); const send = async () => result.value = await reprocess(items.value, materials.value, efficiency.value);
</script> </script>
<template> <template>
<div class="grid mb-2 mt-4 px-4"> <div class="grid mb-2 mt-4">
<div class="justify-self-end"> <div class="justify-self-end">
<span>Reprocess efficiency: </span> <span>Reprocess efficiency: </span>
<input type="number" min="0" max="1" step="0.05" v-model="efficiency" /> <input type="number" min="0" max="1" step="0.05" v-model="efficiency" />
</div> </div>
</div> </div>
<div class="flex items-stretch px-4"> <div class="flex items-stretch">
<ReprocessInput name="Item JSON" v-model="items" /> <ReprocessInput name="Item JSON" v-model="items" />
<ReprocessInput name="Mineral JSON" v-model="minerals" /> <ReprocessInput name="Materials JSON" v-model="materials" />
</div> </div>
<div class="grid my-2 px-4"> <div class="grid my-2">
<button class="justify-self-end" @click="send">Send</button> <button class="justify-self-end" @click="send">Send</button>
</div> </div>
<template v-if="result.length > 0"> <template v-if="result.length > 0">
<hr /> <hr />
<div class="grid mt-2 px-4"> <div class="grid mt-2">
<ReprocessResultTable :result="result" /> <ReprocessResultTable :result="result" />
</div> </div>
</template> </template>

11
src/pages/Tools.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { HaulerTank, ModuleDamage } from '@/tools';
</script>
<template>
<div class="mt-4">
<HaulerTank />
<hr class="mb-4">
<ModuleDamage />
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { MarketTypePrice, getMarketTypes, useApraisalStore } from "@/market";
import { AcquiredType, AcquisitionResultTable, BuyModal, SellModal, useAcquiredTypesStore } from '@/market/acquisition';
import { ref, watch } from 'vue';
const buyModal = ref<typeof BuyModal>();
const sellModal = ref<typeof SellModal>();
const apraisalStore = useApraisalStore();
const acquiredTypesStore = useAcquiredTypesStore();
const items = ref<AcquiredType[]>([]);
watch(() => acquiredTypesStore.acquiredTypes, async itms => {
if (itms.length === 0) {
return;
}
const prices = await apraisalStore.getPrices(await getMarketTypes(itms.map(i => i.type)));
items.value = itms.map(i => {
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
return {
...i,
type: price.type,
buy: price.buy,
sell: price.sell
};
});
}, { immediate: true })
</script>
<template>
<div class="mt-4">
<template v-if="items.length > 0">
<AcquisitionResultTable :items="items" @buy="(types, price, buy, sell) => buyModal?.open(types[0].type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="types => sellModal?.open(types)" ignoredColums="date" />
<BuyModal ref="buyModal" />
<SellModal ref="sellModal" />
</template>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { MarketType, MarketTypeInput, MarketTypePrice, getHistory, getMarketTypes, useApraisalStore } from "@/market";
import { BuyModal } from '@/market/acquisition';
import { TrackingResult, TrackingResultTable, createResult, useMarketTrackingStore } from '@/market/tracking';
import { ref, watch } from 'vue';
const buyModal = ref<typeof BuyModal>();
const item = ref<MarketType>();
const apraisalStore = useApraisalStore();
const marketTrackingStore = useMarketTrackingStore();
const items = ref<TrackingResult[]>([]);
const addOrRelaod = async (type: MarketType) => {
const typeID = type.id;
const [history, price] = await Promise.all([
getHistory(typeID),
apraisalStore.getPrice(type)
]);
const itm = {
type,
history,
buy: price.buy,
sell: price.sell,
orderCount: price.orderCount
};
if (items.value.some(i => i.type.id === typeID)) {
items.value = items.value.map(i => i.type.id === typeID ? itm : i);
} else {
items.value = [ ...items.value, itm];
marketTrackingStore.addType(typeID);
}
}
const addItem = async () => {
if (!item.value) {
// TODO error
return;
}
addOrRelaod(item.value);
item.value = undefined;
}
const removeItem = (type: MarketType) => {
items.value = items.value.filter(i => i.type.id !== type.id);
marketTrackingStore.removeType(type.id);
}
watch(() => marketTrackingStore.types, async t => {
const typesToLoad = t.filter(t => !items.value.some(i => i.type.id === t));
if (typesToLoad.length === 0) {
return;
}
const prices = await apraisalStore.getPrices(await getMarketTypes(typesToLoad));
items.value = [
...items.value
];
typesToLoad.forEach(async i => items.value.push(await createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice)));
}, { immediate: true });
</script>
<template>
<div class="grid mb-2 mt-4">
<div class="w-auto flex">
<span>Item: </span>
<MarketTypeInput class="ms-2" v-model="item" @submit="addItem"/>
<button class="justify-self-end ms-2" @click="addItem">Add</button>
</div>
</div>
<template v-if="items.length > 0">
<hr />
<TrackingResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
<BuyModal ref="buyModal" />
</template>
</template>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ClipboardButton } from '@/components';
import { MarketType, MarketTypeInput, getMarketType, useApraisalStore } from "@/market";
import { AcquisitionResultTable, BuyModal, useAcquiredTypesStore } from '@/market/acquisition';
import { TrackingResultTable, createResult, useMarketTrackingStore } from '@/market/tracking';
import { BookmarkIcon, BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
import { computedAsync } from '@vueuse/core/index.cjs';
import log from "loglevel";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const buyModal = ref<typeof BuyModal>();
const router = useRouter();
const item = ref<MarketType>();
const inputItem = ref<MarketType>();
const apraisalStore = useApraisalStore();
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
const marketTrackingStore = useMarketTrackingStore();
const result = computedAsync(async () => item.value && price.value ? await createResult(item.value?.id, price.value) : undefined);
const acquiredTypesStore = useAcquiredTypesStore();
const isTracked = computed(() => item.value ? marketTrackingStore.types.includes(item.value.id) : false);
const acquisitions = computed(() => {
const p = price.value;
return !p ?[] : acquiredTypesStore.acquiredTypes
.filter(t => t.type === item.value?.id)
.map(i => ({
...i,
type: p.type,
buy: p.buy,
sell: p.sell
}));
});
const toogleTracking = () => {
if (!item.value) {
return;
}
if (isTracked.value) {
marketTrackingStore.removeType(item.value.id);
} else {
marketTrackingStore.addType(item.value.id);
}
}
const view = () => {
if (!inputItem.value) {
return;
}
router.push({
name: 'market-types',
params: {
type: inputItem.value.id
}
});
}
watch(useRoute(), async route => {
if (route.params.type) {
const id = parseInt(typeof route.params.type === 'string' ? route.params.type : route.params.type[0]);
item.value = await getMarketType(id);
inputItem.value = item.value;
log.info('Loaded item:', item.value);
} else {
item.value = undefined;
inputItem.value = undefined;
log.info('No item to load');
}
}, { immediate: true })
</script>
<template>
<div class="grid mb-2 mt-4">
<div class="w-auto flex">
<span>Item: </span>
<MarketTypeInput class="ms-2" v-model="inputItem" @submit="view"/>
<button class="justify-self-end ms-2" @click="view">View</button>
</div>
</div>
<template v-if="item">
<hr>
<div class="p-2 mb-4 flex">
<img v-if="item.id" :src="`https://images.evetech.net/types/${item.id}/icon?size=64`" class="type-image inline-block me-2" alt="" />
<div class="inline-block align-top w-full">
<div class="flex">
<span class="text-lg font-semibold">{{ item.name }}</span>
<div class="ms-auto">
<ClipboardButton class="ms-1" :value="item.name" />
<button v-if="price" class="btn-icon ms-1" title="Add acquisitions" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button>
<button class="btn-icon ms-1" :title="isTracked ? 'Untrack' : 'Track'" @click="toogleTracking">
<BookmarkSlashIcon v-if="isTracked" />
<BookmarkIcon v-else />
</button>
</div>
</div>
<p v-if="item.description" class="text-sm">{{ item.description }}</p>
</div>
</div>
<div v-if="result" class="mb-4">
<span>Market Info:</span>
<TrackingResultTable :items="[result]" infoOnly ignoredColums="name" />
</div>
<div v-if="acquisitions && acquisitions.length > 0">
<span>Acquisitions:</span>
<AcquisitionResultTable :items="acquisitions" infoOnly showAll :ignoredColums="['name', 'buy', 'sell']" defaultSortKey="date"/>
</div>
</template>
<BuyModal ref="buyModal" />
</template>
<style scoped lang="postcss">
img.type-image {
width: 64px;
height: 64px;
}
</style>

View File

@@ -0,0 +1,22 @@
export class Preference<T> {
private key: string;
private description: string;
private value?: T;
private defaultValue?: T;
constructor(key: string, description: string, defaultValue?: T) {
this.key = key;
this.description = description;
this.defaultValue = defaultValue;
this.value = this.load();
}
private load() {
const value = localStorage.getItem(this.key);
if (value) {
return JSON.parse(value);
}
return this.defaultValue;
}
}

0
src/preferences/index.ts Normal file
View File

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
const modelValue = defineModel({ default: false });
</script>
<template>
<label class="flex items-center relative w-max cursor-pointer select-none">
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-14 h-7 rounded-full" v-model="modelValue" />
<span class="absolute font-medium text-xs right-1"> Buy </span>
<span class="absolute font-medium text-xs right-8"> Sell </span>
<span class="w-7 h-7 right-7 absolute rounded-full transform transition-transform bg-slate-100" />
</label>
</template>
<style scoped lang="postcss">
input:checked ~ span:last-child {
--tw-translate-x: 1.75rem;
}
</style>

View File

@@ -1,22 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { evepraisalAxiosInstance } from '@/service'; import { evepraisalAxiosInstance } from '@/market/appraisal/evepraisal';
import { useVModel } from '@vueuse/core';
interface Props { interface Props {
name: string; name: string;
modelValue?: string;
} }
interface Emits { const modelValue = defineModel({ default: '' });
(e: 'update:modelValue', value: string): void; defineProps<Props>();
}
const props = withDefaults(defineProps<Props>(), {
modelValue: ''
});
const emit = defineEmits<Emits>();
const value = useVModel(props, 'modelValue', emit);
const loadFromId = async (e: Event) => { const loadFromId = async (e: Event) => {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
@@ -31,7 +21,7 @@ const loadFromId = async (e: Event) => {
return; return;
} }
value.value = JSON.stringify(response.data); modelValue.value = JSON.stringify(response.data);
input.value = ''; input.value = '';
} }
</script> </script>
@@ -39,6 +29,6 @@ const loadFromId = async (e: Event) => {
<template> <template>
<div class="flex-1 mx-1"> <div class="flex-1 mx-1">
<span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" /> <span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" />
<textarea class="mt-1" v-model="value" /> <textarea class="mt-1" v-model="modelValue" />
</div> </div>
</template> </template>

View File

@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { iskFormater, percentFormater } from '@/formaters'; import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
import { formatIsk, percentFormater } from '@/formaters';
import { MarketTypeLabel } from '@/market/type';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { computed } from 'vue'; import { computed } from 'vue';
import BuySellSlider from './BuySellSlider.vue';
import { ReprocessItemValues } from './reprocess'; import { ReprocessItemValues } from './reprocess';
interface Props { interface Props {
@@ -12,40 +15,62 @@ const props = withDefaults(defineProps<Props>(), {
result: () => [] result: () => []
}); });
const computedResult = computed(() => props.result.map(r =>({ const threshold = useStorage('reprocess-threshold', 0);
...r, const useSellOrderForMarket = useStorage('reprocess-market-use-sell-order', false);
buy_ratio: (r.buy_reprocess / r.buy) - 1, const useSellOrderForMaterials = useStorage('reprocess-materials-use-sell-order', false);
sell_ratio: (r.sell_reprocess / r.sell) - 1
})).sort((a, b) => a.name.localeCompare(b.name)))
const threshold = useStorage('reprocess-threshold', 100); const { sortedArray, headerProps } = useSort(computed(() => props.result.map(r => {
const market = useSellOrderForMarket.value ? r.sell : r.buy;
const materials = useSellOrderForMaterials.value ? r.sell_reprocess : r.buy_reprocess;
const ratio = market === 0 ? 1 : ((materials / market) - 1);
return { ...r, market, materials, ratio };
})), {
defaultSortKey: 'name',
defaultSortDirection: 'asc'
})
</script> </script>
<template> <template>
<div class="grid mb-2 mt-4"> <div class="flex justify-self-end mb-2 mt-4 ms-auto">
<div class="justify-self-end"> <div class="justify-self-end me-4 flex flex-col">
<div class="flex mb-1">
<BuySellSlider v-model="useSellOrderForMarket" class="me-2" /> Market
</div>
<div class="flex">
<BuySellSlider v-model="useSellOrderForMaterials" class="me-2" /> Materials
</div>
</div>
<div class="justify-self-end mt-auto mb-auto">
<span>Threshold: </span> <span>Threshold: </span>
<input type="number" min="-100" max="1000" step="1" v-model="threshold" /> <input type="number" min="-100" max="100" step="1" v-model="threshold" />
</div> </div>
</div> </div>
<table> <VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<thead> <template #default="{ list }">
<tr> <thead>
<th>Item</th> <tr>
<th>Buy</th> <SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<th>Buy reprocess</th> <SortableHeader v-bind="headerProps" sortKey="market">Market</SortableHeader>
<th>Sell</th> <SortableHeader v-bind="headerProps" sortKey="materials">Materials</SortableHeader>
<th>Sell reprocess</th> <SortableHeader v-bind="headerProps" sortKey="ratio">Percent</SortableHeader>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in computedResult" :key="r.typeID" :class="{'bg-emerald-500': r.buy_ratio * threshold >= 100 }"> <tr v-for="r in list" :key="r.data.typeID" :class="{'line-green': r.data.ratio >= threshold / 100 }">
<td>{{ r.name }}</td> <td>
<td class="text-right">{{ iskFormater.format(r.buy) }}</td> <MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
<td class="text-right">{{ iskFormater.format(r.buy_reprocess) }} ({{percentFormater.format(r.buy_ratio)}})</td> </td>
<td class="text-right">{{ iskFormater.format(r.sell) }}</td> <td class="text-right">{{ formatIsk(r.data.market) }}</td>
<td class="text-right">{{ iskFormater.format(r.sell_reprocess) }} ({{percentFormater.format(r.sell_ratio)}})</td> <td class="text-right">{{ formatIsk(r.data.materials) }}</td>
</tr> <td class="text-right">{{ percentFormater.format(r.data.ratio) }}</td>
</tbody> </tr>
</table> </tbody>
</template>@/formaters </template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>

View File

@@ -1,2 +1,5 @@
export { default as Reprocess } from './Reprocess.vue' export * from './reprocess';
export * from './reprocess'
export { default as ReprocessInput } from './ReprocessInput.vue';
export { default as ReprocessResultTable } from './ReprocessResultTable.vue';

View File

@@ -1,4 +1,4 @@
import { apiAxiosInstance } from "@/service"; import { marbasAxiosInstance } from "@/marbas";
export type ReprocessItemValues = { export type ReprocessItemValues = {
typeID: number; typeID: number;
@@ -22,7 +22,7 @@ export const reprocess = async (items: string, minerals: string, efficiency?: nu
}; };
const source = JSON.stringify(sourceJson); const source = JSON.stringify(sourceJson);
const response = await apiAxiosInstance.post('/reprocess', source, {params: {efficiency: efficiency ?? 0.55}}); const response = await marbasAxiosInstance.post('/reprocess/', source, {params: {efficiency: efficiency ?? 0.55}});
return response.data; return response.data;
}; };

16
src/routes.ts Normal file
View File

@@ -0,0 +1,16 @@
import { RouteRecordRaw } from 'vue-router';
export const routes: RouteRecordRaw[] = [
{ path: '/', name: 'home', component: () => import('@/pages/Index.vue') },
{ path: '/callback', name: 'callback', component: () => import('@/pages/Index.vue') },
{ path: '/reprocess', component: () => import('@/pages/Reprocess.vue') },
{ path: '/market', component: () => import('@/pages/Market.vue'), children: [
{ path: '', redirect: '/market/types' },
{ path: 'types/:type?', name: 'market-types', component: () => import('@/pages/market/TypeInfo.vue') },
{ path: 'tracking', component: () => import('@/pages/market/Tracking.vue') },
{ path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue') },
] },
{ path: '/tools', component: () => import('@/pages/Tools.vue') },
{ path: '/characters', component: () => import('@/pages/Characters.vue') },
{ path: '/about', name: 'about', component: () => import('@/pages/About.vue') },
];

View File

@@ -1,17 +1,25 @@
import axios from 'axios'; import axios, { AxiosInstance } from 'axios';
import rateLimit from 'axios-rate-limit';
import log from 'loglevel';
export const apiAxiosInstance = axios.create({ export const logResource = (a: AxiosInstance) => {
baseURL: '/api/', a.interceptors.response.use(r => {
log.debug(`[${r.config.method?.toUpperCase()}] ${r.config.url}`);
return r;
}, e => {
if (!e?.config) {
log.error(e.message, e);
}
log.error(`[${e.config?.method?.toUpperCase()}] ${e.config?.url} failed with ${e.response?.status} ${e.response?.statusText}`, e);
return Promise.reject(e);
});
}
export const esiAxiosInstance = rateLimit(axios.create({
baseURL: import.meta.env.VITE_ESI_URL,
headers: { headers: {
'accept': 'application/json', 'Accept': 'application/json',
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
}) }), { maxRPS: 10 })
logResource(esiAxiosInstance)
export const evepraisalAxiosInstance = axios.create({
baseURL: '/appraisal/',
headers: {
'accept': 'application/json',
"Content-Type": "application/json"
},
})

73
src/sidebar/Sidebar.vue Normal file
View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useAuthStore } from '@/auth';
import { Dropdown } from '@/components';
import { RouterLink } from 'vue-router';
const links = [
{ name: "Market", path: "/market" },
{ name: "Reprocess", path: "/reprocess" },
{ name: "Tools", path: "/tools" }
];
const authStore = useAuthStore();
const logout = async () => {
await authStore.logout();
}
</script>
<template>
<aside class="fixed top-0 left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0">
<div class="h-full px-3 py-4 overflow-y-auto bg-slate-700 flex flex-col">
<div class="mb-2 border-b-2 border-emerald-500">
<Dropdown class="mb-2 user-dropdown">
<template #button>
<span>{{ authStore.username }}</span>
</template>
<ul>
<li>
<RouterLink class="sidebar-button py-0.5 px-2" to="/characters">Characters</RouterLink>
</li>
<li>
<RouterLink class="sidebar-button py-0.5 px-2" :to="{name: 'about'}">About EVE Online</RouterLink>
</li>
<li>
<a class="sidebar-button py-0.5 px-2 text-amber-700" @click="logout">Logout</a>
</li>
</ul>
</Dropdown>
</div>
<ul class="space-y-2 font-medium">
<li v-for="link in links" :key="link.name">
<RouterLink :to="link.path" class="sidebar-button p-2">
<span>{{ link.name }}</span>
</RouterLink>
</li>
</ul>
</div>
</aside>
</template>
<style scoped lang="postcss">
.sidebar-button {
@apply flex items-center rounded-md hover:bg-slate-800 cursor-pointer;
}
.router-link-active {
@apply bg-emerald-500 hover:bg-emerald-700;
}
.user-dropdown {
@apply w-full;
:deep(>div) {
@apply w-full;
>div {
@apply w-full bg-slate-800;
}
}
:deep(>button) {
@apply bg-slate-700 hover:bg-slate-800 border-none flex items-center w-full;
}
&.dropdown-open:deep(>button) {
@apply bg-slate-800 rounded-b-none;
}
}
</style>

1
src/sidebar/index.ts Normal file
View File

@@ -0,0 +1 @@
export { default as Sidebar } from './Sidebar.vue';

View File

@@ -3,7 +3,7 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
* { span, table, input, th, tr, td, button, div, hr {
@apply border-slate-600 text-slate-100 placeholder-slate-400; @apply border-slate-600 text-slate-100 placeholder-slate-400;
} }
@@ -12,7 +12,7 @@
} }
button { button {
@apply py-0.5 px-2 border rounded bg-slate-600 hover:bg-slate-800; @apply py-0.5 px-2 border rounded bg-slate-600 hover:bg-slate-700;
} }
input { input {
@apply border bg-slate-500 rounded px-1; @apply border bg-slate-500 rounded px-1;
@@ -21,24 +21,54 @@
@apply border rounded bg-slate-500 w-full; @apply border rounded bg-slate-500 w-full;
} }
table { table, .table {
@apply table-auto border-collapse border-slate-500 w-full; @apply table-auto border-collapse border-slate-500 w-full;
} }
th { .table-header {
@apply table-cell;
}
.table-cell {
@apply pt-px pb-px;
}
th, .table-header {
@apply border bg-slate-600 px-1; @apply border bg-slate-600 px-1;
} }
td { td, .table-cell {
@apply border px-1; @apply border px-1;
} }
tr, .table-row {
@apply hover:bg-slate-900;
&.line-red {
@apply bg-amber-900 hover:bg-amber-950;
}
&.line-blue {
@apply bg-sky-600 hover:bg-sky-800;
}
&.line-green {
@apply bg-emerald-500 hover:bg-emerald-600;
}
}
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply w-3; @apply w-3;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@apply bg-slate-500; @apply bg-slate-500 rounded;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-slate-600 hover:bg-slate-800; @apply bg-slate-600 hover:bg-slate-700;
border-radius: 5px; border-radius: 5px;
} }
input[type=search] {
@apply search-cancel:appearance-none search-cancel:w-4 search-cancel:h-4 search-cancel:bg-[url('/svg/search-cancel.svg')];
}
.btn-icon {
@apply p-0 border-none bg-transparent hover:text-slate-400 hover:bg-transparent;
> svg {
@apply w-6 h-6;
}
}
} }

31
src/tools/HaulerTank.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { formatIsk } from '@/formaters';
import { computed, ref } from 'vue';
const tornadosDPS = 24000;
const tornadosCost = 100000000;
const fitCost = ref(0);
const fitEHP = ref(0);
const haulableValue = computed(() => formatIsk(Math.ceil((fitEHP.value * 1000) / tornadosDPS) * tornadosCost - (fitCost.value * 1000000)));
</script>
<template>
<span class="font-bold text-lg">Haulable Value</span>
<div class="grid grid-cols-3 mb-2 mt-2">
<div class="flex">
<span>Cost (million ISK): </span>
<input type="number" class="flex-auto ms-1 me-2" step="1" v-model="fitCost" />
</div>
<div class="flex ms-2">
<span>EHP (thousand EHP): </span>
<input type="number" class="flex-auto ms-1 me-2" step="1" v-model="fitEHP" />
</div>
<div class="ms-2">
<span>Haulable Value: </span>
<span>{{ haulableValue }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
const percentFormater = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 2
});
const damageBonus = ref(10);
const rateOfFireBonus = ref(10.50);
const totalBonus = computed(() => percentFormater.format((1 + (damageBonus.value / 100)) / (1 - (rateOfFireBonus.value / 100)) - 1));
</script>
<template>
<span class="font-bold text-lg">Module Damage Bunus</span>
<div class="grid grid-cols-3 mb-2 mt-2">
<div class="flex">
<span>Damage Bonus (%): </span>
<input type="number" class="flex-auto ms-1 me-2" step="0.001" v-model="damageBonus" />
</div>
<div class="flex ms-2">
<span>Rate of Fire Bonus (%): </span>
<input type="number" class="flex-auto ms-1 me-2" step="0.001" v-model="rateOfFireBonus" />
</div>
<div class="ms-2">
<span>Total Bonus: </span>
<span>{{ totalBonus }}</span>
</div>
</div>
</template>

2
src/tools/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { default as HaulerTank } from './HaulerTank.vue';
export { default as ModuleDamage } from './ModuleDamage.vue';

1
src/utils.ts Normal file
View File

@@ -0,0 +1 @@
export const copyToClipboard = (s: string) => navigator.clipboard.writeText(s);

3
svg/search-cancel.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#F1F5F9" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -7,6 +7,10 @@ export default {
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [
require('tailwindcss/plugin')(({ addVariant }) => {
addVariant('search-cancel', '&::-webkit-search-cancel-button');
}),
],
} }

View File

@@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */

View File

@@ -1,9 +1,13 @@
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import * as path from "path"; import * as path from "path";
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import runtimeEnv from 'vite-plugin-runtime-env';
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
runtimeEnv(),
vue(),
],
resolve: { resolve: {
alias: { alias: {
'src': path.resolve(__dirname, './src/'), 'src': path.resolve(__dirname, './src/'),
@@ -14,19 +18,8 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
strictPort: true, strictPort: true,
proxy: { watch: {
'/api/': { usePolling: true
target: 'https://api.eveal.shendai.rip/',
changeOrigin: true,
followRedirects: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/appraisal/': {
target: 'https://evepraisal.shendai.rip/',
changeOrigin: true,
followRedirects: true,
rewrite: (path) => path.replace(/^\/appraisal/, ''),
}
} }
}, }
}) });