Rework to use marbas and authentik instead of poketbase (#1)

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2024-05-17 23:00:52 +02:00
parent 9fb78329cc
commit 717eaa6ed8
47 changed files with 2306 additions and 1049 deletions

View File

@@ -6,5 +6,8 @@ COPY . ./
RUN npm run build
FROM nginx:alpine
ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
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;
}
}

View File

@@ -1,70 +0,0 @@
server {
listen 80 http2;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ $uri.html /index.html;
}
location /marbas/ {
proxy_pass https://${MARBAS_URL}/;
proxy_http_version 1.1;
rewrite /marbas/(.*) /$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "${MARBAS_URL}";
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Accept-Encoding "";
sub_filter 'https://${MARBAS_URL}/' '/marbas/';
sub_filter 'http://${MARBAS_URL}/' '/marbas/';
sub_filter_once off;
sub_filter_types application/json;
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirects;
}
location /pocketbase/ {
proxy_pass https://${POCKET_BASE_URL}/;
proxy_http_version 1.1;
rewrite /pocketbase/(.*) /$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "${POCKET_BASE_URL}";
proxy_set_header X-Forwarded-Proto https;
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirects;
}
location /evepraisal/ {
proxy_pass https://${EVEPRAISAL_URL}/;
proxy_http_version 1.1;
rewrite /evepraisal/(.*) /$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "${EVEPRAISAL_URL}";
proxy_set_header X-Forwarded-Proto https;
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirects;
}
location /fuzzwork/ {
proxy_pass https://${FUZZWORK_URL}/;
proxy_http_version 1.1;
rewrite /fuzzwork/(.*) /$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "${FUZZWORK_URL}";
proxy_set_header X-Forwarded-Proto https;
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirects;
}
location /esi/ {
proxy_pass https://esi.evetech.net/;
proxy_http_version 1.1;
rewrite /esi/(.*) /latest/$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "esi.evetech.net";
proxy_set_header X-Forwarded-Proto https;
proxy_set_header User-Agent "${ESI_USER_AGENT}";
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirects;
}
location @handle_redirects {
set $saved_redirect_location '$upstream_http_location';
proxy_pass $saved_redirect_location;
}
}

2363
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,30 +6,32 @@
"scripts": {
"dev": "vite --host --debug",
"build": "vue-tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@heroicons/vue": "^2.0.18",
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.2.1",
"@vueuse/integrations": "^10.2.1",
"axios": "^1.4.0",
"loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4",
"oidc-client-ts": "^3.0.1",
"pinia": "^2.1.6",
"pocketbase": "^0.18.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@types/node": "^20.4.5",
"@vitejs/plugin-vue": "^4.2.3",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vue-tsc": "^1.8.5"
"vite": "^5.2.11",
"vite-plugin-runtime-env": "^0.1.1",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.18"
}
}

View File

@@ -1,13 +1,19 @@
<script setup lang="ts">
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>
<template>
<template v-if="route.name === 'login'">
<template v-if="hideSidebar">
<RouterView />
</template>
<template v-else>

47
src/auth.ts Normal file
View File

@@ -0,0 +1,47 @@
import log from "loglevel";
import { Log, User, UserManager } 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
});
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,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

@@ -54,7 +54,7 @@ useEventListener('keyup', e => {
}
.fade-enter-active, .fade-leave-active {
transition: opacity 100ms ease-out;
@apply transition-opacity;
}
</style>

View File

@@ -1,4 +1,5 @@
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';

View File

@@ -35,7 +35,7 @@ th {
@apply relative h-8 pe-3;
}
span.asc, span.desc {
@apply absolute end-1 cursor-pointer text-xs;
@apply absolute end-1 cursor-pointer text-xs transition-opacity;
}
span.asc {
@apply top-0.5;

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

@@ -16,7 +16,8 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
};
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
const headerProps = computed(() => ({
onSort: sortBy, showColumn,
onSort: sortBy,
showColumn,
currentSortKey: sortKey.value,
sortDirection: sortDirection.value
}));

View File

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

View File

@@ -0,0 +1,8 @@
import { MarketType } from "..";
import { AcquiredMarketType } from "./acquisition";
export type AcquiredType = Omit<AcquiredMarketType, 'type'> & {
type: MarketType,
buy: number,
sell: number
}

View File

@@ -2,7 +2,7 @@
import { LoadingSpinner, Tooltip } from '@/components';
import { formatIsk } from '@/formaters';
import { getHistory, jitaId } from '@/market';
import { getHistoryQuartils } from '@/market/scan';
import { getHistoryQuartils } from '@/market/tracking';
import { ArrowTrendingDownIcon, ArrowTrendingUpIcon } from '@heroicons/vue/24/outline';
import { computedAsync } from '@vueuse/core';
import { ref, watchEffect } from 'vue';

View File

@@ -5,8 +5,8 @@ import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/mark
import { MinusIcon, PlusIcon } from '@heroicons/vue/24/outline';
import { useStorage } from '@vueuse/core';
import { computed, ref } from 'vue';
import { TrackedItem } from '.';
import TrackQuantilsTooltip from './TrackQuantilsTooltip.vue';
import { AcquiredType } from './AcquiredType';
import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue';
type Result = {
type: MarketType;
@@ -21,7 +21,7 @@ type Result = {
}
interface Props {
items?: TrackedItem[];
items?: AcquiredType[];
}
interface Emits {
@@ -36,12 +36,12 @@ defineEmits<Emits>();
const marketTaxStore = useMarketTaxStore();
const threshold = useStorage('market-track-threshold', 10);
const threshold = useStorage('market-acquisition-threshold', 10);
const filter = ref("");
const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
.map(r => {
const precentProfit = marketTaxStore.calculateProfit(r.averagePrice, r.sell);
const precentProfit = marketTaxStore.calculateProfit(r.price, r.sell);
return {
type: r.type,
@@ -49,10 +49,10 @@ const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items
name: r.type.name,
buy: r.buy,
sell: r.sell,
price: r.averagePrice,
count: r.count,
price: r.price,
count: r.remaining,
precentProfit,
iskProfit: r.averagePrice * precentProfit * r.count
iskProfit: r.price * precentProfit * r.remaining
};
})), {
defaultSortKey: 'precentProfit',
@@ -100,14 +100,14 @@ const getLineColor = (result: Result) => {
<td>
<div class="flex">
<MarketTypeLabel :id="r.typeID" :name="r.name" />
<TrackQuantilsTooltip :id="r.typeID" :buy="r.buy" :sell="r.sell" />
<AcquisitionQuantilsTooltip :id="r.typeID" :buy="r.buy" :sell="r.sell" />
</div>
</td>
<td class="text-right">{{ formatIsk(r.buy) }}</td>
<td class="text-right">{{ formatIsk(r.sell) }}</td>
<td class="text-right">{{ formatIsk(r.price) }}</td>
<td class="text-right">{{ r.count }}</td>
<td class="text-right">{{ percentFormater.format(r.precentProfit) }}</td>
<td class="text-right">{{ percentFormater.format(r.precentProfit) }}</td>
<td class="text-right">{{ formatIsk(r.iskProfit) }}</td>
<td class="text-right">
<button class="btn-icon me-1" @click="$emit('buy', r.type, r.price, r.buy, r.sell)"><PlusIcon /></button>
@@ -122,4 +122,4 @@ const getLineColor = (result: Result) => {
div.end {
@apply justify-self-end ms-2;
}
</style>@/components/table
</style>

View File

@@ -3,10 +3,10 @@ import { Modal } from '@/components';
import { formatIsk } from '@/formaters';
import { MarketType, MarketTypeLabel } from '@/market';
import { ref } from 'vue';
import { useTrackedItemStore } from './track';
import { useAcquiredTypesStore } from './acquisition';
const trackedItemStore = useTrackedItemStore();
const acquiredTypesStore = useAcquiredTypesStore();
const modalOpen = ref<boolean>(false);
const type = ref<MarketType>();
@@ -38,7 +38,7 @@ const add = () => {
return;
}
trackedItemStore.addTrackedItem(id, count.value, price.value);
acquiredTypesStore.addType(id, count.value, price.value);
modalOpen.value = false;
}

View File

@@ -2,10 +2,10 @@
import { Modal } from '@/components';
import { MarketType, MarketTypeLabel } from '@/market';
import { ref } from 'vue';
import { useTrackedItemStore } from './track';
import { useAcquiredTypesStore } from './acquisition';
const trackedItemStore = useTrackedItemStore();
const acquiredTypesStore = useAcquiredTypesStore();
const modalOpen = ref<boolean>(false);
const type = ref<MarketType>();
@@ -24,7 +24,7 @@ const remove = () => {
return;
}
trackedItemStore.removeTrackedItem(id, count.value);
acquiredTypesStore.removeType(id, count.value);
modalOpen.value = false;
}

View File

@@ -0,0 +1,63 @@
import { marbasAxiosInstance } from "@/service";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export type AcquiredMarketType = {
id: number;
type: number;
quantity: number;
remaining: number;
price: number;
date: Date;
source: 'bo' | 'so' | 'prod';
user: number;
}
const endpoint = '/api/acquisitions/';
export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
const acquiredTypes = ref<AcquiredMarketType[]>([]);
const types = computed(() => acquiredTypes.value);
const addType = async (type: number, quantity: number, price: number) => {
acquiredTypes.value = [...acquiredTypes.value, (await marbasAxiosInstance.post<AcquiredMarketType>(endpoint, {
type: type,
quantity: quantity,
remaining: quantity,
price: price,
date: new Date(),
source: 'bo'
})).data];
};
const removeType = async (type: number, quantity: number) => {
const found = acquiredTypes.value.find(item => item.type === type);
if (!found) {
return;
}
if (found.remaining <= 0) {
acquiredTypes.value = acquiredTypes.value.filter(i => i.type !== type);
} else {
acquiredTypes.value = acquiredTypes.value.map(i => {
if (i.type === item.type) {
return item;
} else {
return i;
}
});
}
const item = {
...found,
remaining: found.remaining - quantity
};
await marbasAxiosInstance.put(`${endpoint}${item.id}`, item);
};
marbasAxiosInstance.get<AcquiredMarketType[]>(endpoint).then(res => acquiredTypes.value = res.data.filter(item => item.remaining > 0));
return { types, addType, removeType };
});

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

@@ -4,7 +4,7 @@ import { MarketType } from "../type";
import { PriceGetter } from './MarketTypePrice';
export const evepraisalAxiosInstance = axios.create({
baseURL: '/evepraisal/',
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"

View File

@@ -4,7 +4,7 @@ import { MarketType } from "../type";
import { PriceGetter } from './MarketTypePrice';
export const fuzzworkAxiosInstance = axios.create({
baseURL: '/fuzzwork/',
baseURL: import.meta.env.VITE_FUZZWORK_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"

View File

@@ -1,5 +0,0 @@
export * from './HistoryQuartils';
export * from './scan';
export { default as ScanResultTable } from './ScanResultTable.vue';

View File

@@ -1,56 +0,0 @@
import { MarketOrderHistory, MarketType, MarketTypePrice, getHistory, jitaId } from "@/market";
import { usePocketBase, watchCollection } from "@/pocketbase";
import { defineStore } from "pinia";
import { RecordModel } from "pocketbase";
import { computed, onMounted, ref } from "vue";
export type ScanResult = {
type: MarketType;
history: MarketOrderHistory[];
buy: number,
sell: number,
orderCount: number,
}
interface MarketScan extends RecordModel {
owner: string;
types: number[];
};
const marketScans = 'marketScans';
export const useMarketScanStore = defineStore(marketScans, () => {
const pb = usePocketBase();
const marketScan = ref<MarketScan>();
const types = computed(() => marketScan.value?.types ?? []);
const setTypes = async (types: number[]) => {
if (marketScan.value?.id) {
marketScan.value = await pb.collection(marketScans).update(marketScan.value.id, { owner: pb.authStore.model!.id, types });
} else {
marketScan.value = await pb.collection(marketScans).create({ owner: pb.authStore.model!.id, types });
}
}
const addType = async (type: number) => {
if (!types.value.includes(type)) {
await setTypes([...types.value, type]);
}
}
const removeType = async (type: number) => {
if (types.value.includes(type)) {
await setTypes(types.value.filter(t => t !== type));
}
}
watchCollection<MarketScan>(marketScans, '*', data => {
if (data.action === 'delete') {
marketScan.value = undefined;
} else if (!marketScan.value || data.record.id === marketScan.value.id) {
marketScan.value = data.record;
}
});
onMounted(async () => marketScan.value = await pb.collection(marketScans).getFirstListItem<MarketScan>('').catch(() => undefined));
return { types, setTypes, addType, removeType };
});
export const createResult = async (id: number, price: MarketTypePrice): Promise<ScanResult> => ({ history: await getHistory(jitaId, id), ...price });

View File

@@ -1,9 +0,0 @@
import { MarketType } from "@/market";
export type TrackedItem = {
type: MarketType;
count: number;
averagePrice: number;
buy: number,
sell: number
}

View File

@@ -1,7 +0,0 @@
export * from './TrackedItem';
export * from './track';
export { default as BuyModal } from './BuyModal.vue';
export { default as SellModal } from './SellModal.vue';
export { default as TrackResultTable } from './TrackResultTable.vue';

View File

@@ -1,58 +0,0 @@
import { useCollection, usePocketBase } from "@/pocketbase";
import { createSharedComposable, useLocalStorage } from '@vueuse/core';
import { defineStore } from "pinia";
import { RecordModel } from "pocketbase";
import { computed } from "vue";
export type TrackedMarketItemStorage = {
typeID: number;
count: number;
averagePrice: number;
}
interface TrackedMarketItem extends RecordModel {
owner: string;
typeID: number;
count: number;
averagePrice: number;
}
const marketTrackings = 'marketTrackings';
export const useTrackedItemsStorage = createSharedComposable(() => useLocalStorage<TrackedMarketItemStorage[]>('market-track-items', []));
export const useTrackedItemStore = defineStore(marketTrackings, () => {
const pb = usePocketBase();
const trackedItems = useCollection<TrackedMarketItem>(marketTrackings);
const items = computed(() => trackedItems);
const addTrackedItem = async (typeID: number, count: number, averagePrice: number) => {
const oldItem = trackedItems.value.find(i => i.typeID === typeID);
if (oldItem?.id) {
pb.collection(marketTrackings).update(oldItem.id, {
...oldItem,
count: count + oldItem.count,
averagePrice: ((averagePrice * count) + (oldItem.averagePrice * oldItem.count)) / (count + oldItem.count)
});
} else {
pb.collection(marketTrackings).create({ owner: pb.authStore.model!.id, typeID, count, averagePrice});
}
};
const removeTrackedItem = async (typeID: number, count: number) => {
const oldItem = trackedItems.value.find(i => i.typeID === typeID);
if (!oldItem?.id) {
return;
} else if (oldItem.count > count) {
pb.collection(marketTrackings).update(oldItem.id, {
...oldItem,
count: oldItem.count - count
});
} else {
pb.collection(marketTrackings).delete(oldItem.id);
}
};
return { items, addTrackedItem, removeTrackedItem };
});

View File

@@ -6,7 +6,7 @@ import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/mark
import { BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
import { useStorage } from '@vueuse/core';
import { computed, ref } from 'vue';
import { ScanResult, getHistoryQuartils } from '.';
import { TrackingResult, getHistoryQuartils } from '.';
type Result = {
type: MarketType;
@@ -22,7 +22,7 @@ type Result = {
}
interface Props {
items?: ScanResult[];
items?: TrackingResult[];
infoOnly?: boolean;
ignoredColums?: string[];
}
@@ -45,8 +45,8 @@ defineEmits<Emits>();
const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-scan-days', 365);
const threshold = useStorage('market-scan-threshold', 10);
const days = useStorage('market-tracking-days', 365);
const threshold = useStorage('market-tracking-threshold', 10);
const filter = ref("");
const onlyCheap = ref(false);
const columnsToIgnore = computed(() => {
@@ -153,4 +153,4 @@ const getLineColor = (result: Result) => {
div.end {
@apply justify-self-end ms-2;
}
</style>@/components/table
</style>

View File

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

View File

@@ -0,0 +1,41 @@
import { MarketOrderHistory, MarketType, MarketTypePrice, getHistory, jitaId } from "@/market";
import { marbasAxiosInstance } from "@/service";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export type TrackingResult = {
type: MarketType;
history: MarketOrderHistory[];
buy: number,
sell: number,
orderCount: number,
}
type MarketTracking = {
id: number,
type: number
};
const endpoint = '/api/types_tracking/';
export const useMarketTrackingStore = defineStore('marketTracking', () => {
const trackedTypes = ref<number[]>([]);
const types = computed(() => trackedTypes.value ?? []);
const addType = async (type: number) => {
if (!trackedTypes.value.includes(type)) {
await marbasAxiosInstance.post(endpoint, { type });
}
}
const removeType = async (type: number) => {
if (trackedTypes.value.includes(type)) {
await marbasAxiosInstance.delete(`${endpoint}${type}`);
}
}
marbasAxiosInstance.get<MarketTracking[]>(endpoint).then(res => trackedTypes.value = res.data.map(item => item.type));
return { types, addType, removeType };
});
export const createResult = async (id: number, price: MarketTypePrice): Promise<TrackingResult> => ({ history: await getHistory(jitaId, id), ...price });

View File

@@ -96,7 +96,7 @@ watchEffect(async () => {
<template>
<div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false">
<div class="fake-input">
<img v-if="value?.id" :src="`https://images.evetech.net/types/${value.id}/icon`" />
<img v-if="value?.id" :src="`https://images.evetech.net/types/${value.id}/icon`" 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-10 absolute w-96">

View File

@@ -17,7 +17,7 @@ withDefaults(defineProps<Props>(), {
<template>
<div v-if="id || name">
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon`" class="inline-block w-5 h-5 me-1" />
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon`" class="inline-block w-5 h-5 me-1" alt="" />
<template v-if="name">
{{ name }}
<ClipboardButton v-if="!hideCopy" :value="name" />

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import { usePocketBase } from '@/pocketbase';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const pb = usePocketBase();
const router = useRouter();
const username = ref("");
const password = ref("");
const login = async () => {
await pb.collection('users').authWithPassword(username.value, password.value);
await router.push('/');
}
</script>
<template>
<div class="p-4 mx-auto mt-10 grid justify-center gap-2 w-64">
<div class="grid">
Login:
<input type="text" name="username" v-model="username" @keyup.enter="login" />
</div>
<div class="grid">
Password:
<input type="password" name="password" v-model="password" @keyup.enter="login" />
</div>
<button class="justify-self-end" name="login" @click="login">Login</button>
</div>
</template>

View File

@@ -6,15 +6,15 @@ import { RouterLink, RouterView } from 'vue-router';
<template>
<div class="mt-4">
<div class="flex border-b-2 border-emerald-500">
<RouterLink :to="{name: 'market-type'}" class="tab">
<RouterLink :to="{name: 'market-types'}" class="tab">
<span>Item Info</span>
</RouterLink>
<RouterLink to="/market/scan" class="tab">
<span>Scan</span>
</RouterLink>
<RouterLink to="/market/track" class="tab">
<RouterLink to="/market/tracking" class="tab">
<span>Tracking</span>
</RouterLink>
<RouterLink to="/market/acquisitions" class="tab">
<span>Acquisitions</span>
</RouterLink>
</div>
<RouterView />
</div>

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.types, 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="(type, price, buy, sell) => buyModal?.open(type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="type => sellModal?.open(type)" />
<BuyModal ref="buyModal" />
<SellModal ref="sellModal" />
</template>
</div>
</template>

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { MarketType, MarketTypeInput, MarketTypePrice, getHistory, getMarketTypes, jitaId, useApraisalStore } from "@/market";
import { ScanResult, ScanResultTable, createResult, useMarketScanStore } from '@/market/scan';
import { BuyModal } from '@/market/track';
import { BuyModal } from '@/market/acquisition';
import { TrackingResult, TrackingResultTable, createResult, useMarketTrackingStore } from '@/market/tracking';
import { ref, watch } from 'vue';
@@ -10,8 +10,8 @@ const buyModal = ref<typeof BuyModal>();
const item = ref<MarketType>();
const apraisalStore = useApraisalStore();
const markeyScanStore = useMarketScanStore();
const items = ref<ScanResult[]>([]);
const marketTrackingStore = useMarketTrackingStore();
const items = ref<TrackingResult[]>([]);
const addOrRelaod = async (type: MarketType) => {
const typeID = type.id;
const [history, price] = await Promise.all([
@@ -30,6 +30,7 @@ const addOrRelaod = async (type: MarketType) => {
items.value = items.value.map(i => i.type.id === typeID ? itm : i);
} else {
items.value = [ ...items.value, itm];
marketTrackingStore.addType(typeID);
}
}
const addItem = async () => {
@@ -43,11 +44,10 @@ const addItem = async () => {
}
const removeItem = (type: MarketType) => {
items.value = items.value.filter(i => i.type.id !== type.id);
marketTrackingStore.removeType(type.id);
}
watch(items, async itms => markeyScanStore.setTypes(itms.map(i => i.type.id)));
watch(() => markeyScanStore.types, async t => {
watch(() => marketTrackingStore.types, async t => {
const typesToLoad = t.filter(t => !items.value.some(i => i.type.id === t));
if (typesToLoad.length === 0) {
@@ -56,7 +56,10 @@ watch(() => markeyScanStore.types, async t => {
const prices = await apraisalStore.getPrices(await getMarketTypes(typesToLoad));
items.value = [...items.value, ...(await Promise.all(typesToLoad.map(i => createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice))))];
items.value = [
...items.value,
...(await Promise.all(typesToLoad.map(i => createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice))))
];
}, { immediate: true });
</script>
@@ -70,7 +73,7 @@ watch(() => markeyScanStore.types, async t => {
</div>
<template v-if="items.length > 0">
<hr />
<ScanResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
<TrackingResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
<BuyModal ref="buyModal" />
</template>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ClipboardButton } from '@/components';
import { MarketType, MarketTypeInput, getMarketType, useApraisalStore } from "@/market";
import { ScanResultTable, createResult, useMarketScanStore } from '@/market/scan';
import { BuyModal } from '@/market/track';
import { BuyModal } 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";
@@ -18,18 +18,18 @@ const inputItem = ref<MarketType>();
const apraisalStore = useApraisalStore();
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
const markeyScanStore = useMarketScanStore();
const marketTrackingStore = useMarketTrackingStore();
const result = computedAsync(async () => item.value && price.value ? await createResult(item.value?.id, price.value) : undefined);
const isTracked = computed(() => item.value ? markeyScanStore.types.includes(item.value.id) : false);
const isTracked = computed(() => item.value ? marketTrackingStore.types.includes(item.value.id) : false);
const toogleTracking = () => {
if (!item.value) {
return;
}
if (isTracked.value) {
markeyScanStore.removeType(item.value.id);
marketTrackingStore.removeType(item.value.id);
} else {
markeyScanStore.addType(item.value.id);
marketTrackingStore.addType(item.value.id);
}
}
@@ -39,7 +39,7 @@ const view = () => {
}
router.push({
name: 'market-type',
name: 'market-types',
params: {
type: inputItem.value.id
}
@@ -72,7 +72,7 @@ watch(useRoute(), async route => {
<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" />
<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>
@@ -88,7 +88,7 @@ watch(useRoute(), async route => {
<p v-if="item.description" class="text-sm">{{ item.description }}</p>
</div>
</div>
<ScanResultTable v-if="result" :items="[result]" infoOnly />
<TrackingResultTable v-if="result" :items="[result]" infoOnly />
</template>
<BuyModal ref="buyModal" />
</template>

View File

@@ -1,27 +0,0 @@
import { usePocketBase } from "@/pocketbase";
import { RecordModel, RecordSubscription } from "pocketbase";
import { Ref, computed, onMounted, ref } from "vue";
export const watchCollection = <T extends RecordModel = RecordModel>(collection: string, topic: string, callback: (data: RecordSubscription<T>) => void) => {
const pb = usePocketBase();
onMounted(async () => await pb.collection(collection).subscribe<T>(topic, callback));
};
export const useCollection = <T extends RecordModel = RecordModel>(collection: string) => {
const pb = usePocketBase();
const list = ref<T[]>([]) as Ref<T[]>;
watchCollection<T>(collection, '*', data => {
if (data.action === 'delete') {
list.value = list.value.filter(i => i.id !== data.record.id);
} else if (data.action === 'update') {
list.value = list.value.map(i => i.id === data.record.id ? data.record : i);
} else if (data.action === 'create') {
list.value = [...list.value, data.record];
}
});
onMounted(async () => list.value = await pb.collection(collection).getFullList<T>().catch(() => [] as T[]));
return computed(() => list.value);
}

View File

@@ -1,2 +0,0 @@
export * from './collection';
export * from './pocketbase';

View File

@@ -1,13 +0,0 @@
import PocketBase from 'pocketbase';
import { App, inject } from 'vue';
const pocketBaseSymbol = Symbol('pocketBase');
export const providePocketBase = (app: App) => {
const pb = new PocketBase('/pocketbase/');
app.provide(pocketBaseSymbol, pb);
return pb;
}
export const usePocketBase = () => inject<PocketBase>(pocketBaseSymbol)!;

View File

@@ -2,14 +2,14 @@ import { RouteRecordRaw } from 'vue-router';
export const routes: RouteRecordRaw[] = [
{ path: '/', name: 'home', component: () => import('@/pages/Index.vue') },
{ path: '/login', name: 'login', component: () => import('@/pages/Login.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/type' },
{ path: 'type/:type?', name: 'market-type', component: () => import('@/pages/market/TypeInfo.vue') },
{ path: 'scan', component: () => import('@/pages/market/Scan.vue') },
{ path: 'track', component: () => import('@/pages/market/Track.vue') },
{ 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: '/about', component: () => import('@/pages/About.vue') },
{ path: '/about', name: 'about', component: () => import('@/pages/About.vue') },
];

View File

@@ -1,25 +1,39 @@
import { useAuthStore } from '@/auth';
import axios, { AxiosInstance } from 'axios';
import log from 'loglevel';
export const logResource = (a: AxiosInstance) => {
a.interceptors.response.use(r => {
log.debug(`[${r.config.method?.toUpperCase()}] ${r.config.url}`);
return r;
}, e => {
log.error(`[${e.config.method?.toUpperCase()}] ${e.config.url} failed with ${e.response?.status} ${e.response?.statusText}`);
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 marbasAxiosInstance = axios.create({
baseURL: '/marbas/',
baseURL: import.meta.env.VITE_MARBAS_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"
'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 };
}
@@ -44,9 +58,9 @@ marbasAxiosInstance.interceptors.response.use(async r => {
})
export const esiAxiosInstance = axios.create({
baseURL: '/esi/',
baseURL: import.meta.env.VITE_ESI_URL,
headers: {
'accept': 'application/json',
'Accept': 'application/json',
"Content-Type": "application/json"
},
})

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { usePocketBase } from '@/pocketbase';
import { RouterLink, useRouter } from 'vue-router';
import { useAuthStore } from '@/auth';
import { Dropdown } from '@/components';
import { RouterLink } from 'vue-router';
const links = [
{ name: "Market", path: "/market" },
@@ -8,34 +9,62 @@ const links = [
{ name: "Tools", path: "/tools" }
];
const pb = usePocketBase();
const router = useRouter();
const authStore = useAuthStore();
const logout = async () => {
pb.authStore.clear();
await router.push({ name: 'login' });
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="{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="flex items-center p-2 rounded-md hover:bg-slate-800">
<RouterLink :to="link.path" class="sidebar-button p-2">
<span>{{ link.name }}</span>
</RouterLink>
</li>
</ul>
<div class="mt-auto">
<button @click="logout">Logout</button>
</div>
</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>

View File

@@ -1,94 +1,25 @@
import vue from '@vitejs/plugin-vue';
import * as path from "path";
import { defineConfig, loadEnv } from 'vite';
import zlib from 'zlib';
import { defineConfig } from 'vite';
import runtimeEnv from 'vite-plugin-runtime-env';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [vue()],
resolve: {
alias: {
'src': path.resolve(__dirname, './src/'),
'@': path.resolve(__dirname, './src/'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
export default defineConfig({
plugins: [
runtimeEnv(),
vue(),
],
resolve: {
alias: {
'src': path.resolve(__dirname, './src/'),
'@': path.resolve(__dirname, './src/'),
},
server: {
port: 3000,
strictPort: true,
watch: {
usePolling: true
},
proxy: {
'/marbas/': {
target: env.MARBAS_URL,
changeOrigin: true,
followRedirects: true,
rewrite: path => path.replace(/^\/marbas/, ''),
selfHandleResponse: true,
configure: proxy => {
proxy.on('proxyRes', (proxyRes, req, res) => {
const chunks = [];
proxyRes.on("data", (chunk) => chunks.push(chunk));
proxyRes.on("end", () => {
const buffer = Buffer.concat(chunks);
const encoding = proxyRes.headers["content-encoding"];
const relace = (b: Buffer) => {
let remoteBody = b.toString();
const modifiedBody = remoteBody.replace(env.MARBAS_URL, '/marbas/');
res.write(modifiedBody);
res.end();
}
if (!encoding) {
relace(buffer);
} else if (encoding === "gzip" || encoding === "deflate") {
zlib.unzip(buffer, (err, b) => {
if (!err) {
relace(b);
} else {
console.error(err);
}
});
} else {
console.error(`Unsupported encoding: ${encoding}`);
}
});
});
}
},
'/pocketbase/': {
target: env.POCKET_BASE_URL,
changeOrigin: true,
followRedirects: true,
rewrite: path => path.replace(/^\/pocketbase/, ''),
},
'/evepraisal/': {
target: env.EVEPRAISAL_URL,
changeOrigin: true,
followRedirects: true,
rewrite: path => path.replace(/^\/evepraisal/, ''),
},
'/fuzzwork/': {
target: env.FUZZWORK_URL,
changeOrigin: true,
followRedirects: true,
rewrite: path => path.replace(/^\/fuzzwork/, ''),
},
'/esi/': {
target: env.ESI_URL,
changeOrigin: true,
followRedirects: true,
rewrite: path => path.replace(/^\/esi/, ''),
headers: {
'User-Agent': env.ESI_USER_AGENT
},
}
}
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
},
server: {
port: 3000,
strictPort: true,
watch: {
usePolling: true
}
};
})
}
});