Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/api/customDiscs/api.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
import { Config } from "@/config";
import type { CustomDiscsOverview } from "./customDiscs";
import type { CustomDiscsOverview, CustomDiscStats } from "./customDiscs";
import { itemsStore } from "@/store/items-state";

export async function retrieveCustomDiscs(): Promise<CustomDiscsOverview> {
// Don't reload if data is recent
if (itemsStore.customDiscs != null) return itemsStore.customDiscs;
if (itemsStore.customDiscs != null && new Date().getTime() - itemsStore.customDiscsLastUpdated.getTime() < 30000)
return itemsStore.customDiscs;

// Get custom discs
let httpResponse = await fetch(`${Config.APIURL}/api/v2/customDiscs`, { method: "get" });
if (httpResponse.status !== 200) return { discs: [], unsoldDiscs: 0 };

itemsStore.customDiscs = (await httpResponse.json()).result as CustomDiscsOverview;
for (const disc of itemsStore.customDiscs.discs) {
itemsStore.customDiscLookup[disc.name] = disc;
}
itemsStore.customDiscsLastUpdated = new Date();
return itemsStore.customDiscs;
}

export async function retrieveCustomDiscStats(): Promise<CustomDiscStats[]> {
// Don't reload if data is recent
if (itemsStore.customDiscStats != null && new Date().getTime() - itemsStore.customDiscsStatsLastUpdated.getTime() < 30000)
return itemsStore.customDiscStats;

// Get custom disc stats
let httpResponse = await fetch(`${Config.APIURL}/api/customDiscs/stats`, { method: "get" });
if (httpResponse.status !== 200) return [];

itemsStore.customDiscStats = (await httpResponse.json()).result.discs as CustomDiscStats[];
itemsStore.customDiscsStatsLastUpdated = new Date();
return itemsStore.customDiscStats;
}
10 changes: 10 additions & 0 deletions src/api/customDiscs/customDiscs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,14 @@ export interface CustomDiscsOverview {
export interface CustomDisc {
name: string;
displayName: string;
source: string;
version: string;
}

export interface CustomDiscStats {
discName: string;
numSales: number;
minPrice: number;
maxPrice: number;
averagePrice: number;
}
171 changes: 171 additions & 0 deletions src/components/CustomDiscStats.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { initFlowbite } from "flowbite";
import { computed, onMounted, ref, watch } from "vue";
import type { DropdownOption } from "./DropdownFilter.vue";
import DropdownFilter from "./DropdownFilter.vue";
import SearchBox from "./SearchBox.vue";
import type { CustomDisc, CustomDiscStats } from "@/api/customDiscs/customDiscs";
import { itemsStore } from "@/store/items-state";
import * as CustomDiscsAPI from "@/api/customDiscs/api";
import { formatNumber } from "@/utilities/number-format";
import Loading from "./Loading.vue";

interface DiscOverview {
stats: CustomDiscStats;
discInfo: CustomDisc;
}

const loading = ref(true);
const filteredDiscs = ref([] as DiscOverview[]);
const allDiscs = computed(() =>
itemsStore.customDiscStats.map((d) => ({ stats: d, discInfo: itemsStore.customDiscLookup[d.discName] }) as DiscOverview),
);
// TODO: Pagination?
const paginatedDiscs = computed(() => applySort(filteredDiscs.value));

// Setup
onMounted(async () => {
loading.value = true;
initFlowbite(); // Include on any component where you need flowbite JS functionality
await CustomDiscsAPI.retrieveCustomDiscStats();
applyFilters();
loading.value = false;
});

//
// Filter: Source
const sourceOptions = computed(() =>
[...new Set(itemsStore.customDiscs?.discs.map((d) => d.source))].sort().map(
(v) =>
({
text: v,
value: v,
}) as DropdownOption,
),
);
const sourceFilter = ref([] as string[]);

watch(sourceFilter, async (_, __) => {
applyFilters();
});

//
// Filter: Version
const versionOptions = computed(() =>
[...new Set(itemsStore.customDiscs?.discs.map((d) => d.version))].sort().map(
(v) =>
({
text: v,
value: v,
}) as DropdownOption,
),
);
const versionFilter = ref([] as string[]);

watch(versionFilter, async (_, __) => {
applyFilters();
});

//
// Filter: Disc name
const nameFilter = ref("");
watch(nameFilter, async (_, __) => {
applyFilters();
});

// Sorting
let sortProperty = ref("");
let sortAscending = ref(true);
function sort(property: string, ascendingByDefault: boolean) {
if (sortProperty.value == property) sortAscending.value = !sortAscending.value;
else {
sortProperty.value = property;
sortAscending.value = ascendingByDefault;
}
}

function applySort(items: DiscOverview[]) {
items.sort((a, b) => {
let first = sortAscending.value ? a : b;
let second = sortAscending.value ? b : a;
let sortResult = 0;

// Sales
if (sortProperty.value == "numSales") sortResult = first.stats.numSales - second.stats.numSales;
// Average price
else if (sortProperty.value == "averagePrice") sortResult = first.stats.averagePrice - second.stats.averagePrice;

// Otherwise name
return sortResult || first.discInfo.displayName.localeCompare(second.discInfo.displayName);
});

return items;
}

function applyFilters() {
filteredDiscs.value = allDiscs.value.filter((d) => {
// Source (artist/game)
if (sourceFilter.value.length && !sourceFilter.value.includes(d.discInfo.source)) return false;

// Version (5.0, 5.1)
if (versionFilter.value.length && !versionFilter.value.includes(d.discInfo.version)) return false;

// Name
if (nameFilter.value.length && !d.discInfo.displayName.includes(nameFilter.value) && !d.discInfo.name.includes(nameFilter.value))
return false;

return true;
});
}
</script>

<template>
<div>
<!-- Filters -->
<div class="flex flex-row flex-wrap gap-1 items-end mb-2">
<DropdownFilter :placeholder="'Source'" :icon="'fa-solid fa-user'" :options="sourceOptions" v-model="sourceFilter"> </DropdownFilter>

<DropdownFilter :placeholder="'Version'" :icon="'fa-solid fa-flask'" :options="versionOptions" v-model="versionFilter">
</DropdownFilter>

<SearchBox :placeholder="'Disc Name'" v-model="nameFilter"></SearchBox>
</div>

<div class="max-h-[400px] overflow-y-auto">
<table class="w-full text-left rtl:text-right text-gray-400 text-xs md:text-base">
<thead class="table-head">
<tr>
<th class="table-item">
<span>Name</span>
<font-awesome-icon icon="fa-solid fa-sort" class="text-xs ml-1 cursor-pointer" @click="sort('displayName', true)" />
</th>
<th class="table-item">
<span>Sales</span>
<font-awesome-icon icon="fa-solid fa-sort" class="text-xs ml-1 cursor-pointer" @click="sort('numSales', false)" />
</th>
<th class="table-item hidden md:table-cell">
<span>Price</span>
<font-awesome-icon icon="fa-solid fa-sort" class="text-xs ml-1 cursor-pointer" @click="sort('averagePrice', false)" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="disc in paginatedDiscs" class="stripped-row">
<td class="table-item">
<div>{{ disc.discInfo.displayName }}</div>
<div class="hint-text">{{ disc.stats.discName.replace("smponline_discs:", "") }}</div>
</td>
<td class="table-item">{{ disc.stats.numSales }}</td>
<td class="table-item" v-if="disc.stats.numSales == 1">{{ disc.stats.maxPrice }} 💎</td>
<td class="table-item" v-else>
<div>{{ disc.stats.minPrice }} - {{ disc.stats.maxPrice }} 💎</div>
<div>Average: {{ formatNumber(disc.stats.averagePrice, 2) }} 💎</div>
</td>
</tr>
</tbody>
</table>

<Loading v-if="loading" :fill-space="true"></Loading>
</div>
</div>
</template>
14 changes: 11 additions & 3 deletions src/store/items-state.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import type { CustomDiscsOverview } from "@/api/customDiscs/customDiscs";
import type { CustomDisc, CustomDiscsOverview, CustomDiscStats } from "@/api/customDiscs/customDiscs";
import type { VillagerTrade } from "@/api/villagerTrades/villagerTrades";
import { reactive } from "vue";

export interface ItemsOverview {
customDiscs: CustomDiscsOverview | null;
customDiscs: CustomDiscsOverview;
customDiscLookup: { [discName: string]: CustomDisc };
customDiscsLastUpdated: Date;
customDiscStats: CustomDiscStats[];
customDiscsStatsLastUpdated: Date;
villagerTrades: VillagerTrade[];
villagerTradesLastUpdated: Date;
}

export const itemsStore = reactive({
customDiscs: null,
customDiscs: { discs: [], unsoldDiscs: 0 },
customDiscLookup: {},
customDiscsLastUpdated: new Date(0),
customDiscStats: [],
customDiscsStatsLastUpdated: new Date(0),
villagerTrades: [],
villagerTradesLastUpdated: new Date(0),
} as ItemsOverview);
79 changes: 30 additions & 49 deletions src/views/ItemTypeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { itemsStore } from "@/store/items-state";
import { setPageTitle } from "@/router/pageTitle";
import { userStore } from "@/store/user-state";
import { normalisePrice, simpleNormalisedPrice } from "@/utilities/normalise-price";
import CustomDiscStats from "@/components/CustomDiscStats.vue";

interface Props {
itemType: string;
Expand Down Expand Up @@ -260,12 +261,11 @@ function getWikiLink() {
<div class="flex flex-col gap-8">
<div class="mb-2 flex flex-col gap-4">
<div class="flex flex-row items-center justify-center">
<ItemTypeSearch
@selection="
(itemType) => {
if (itemType) $router.push({ name: 'itemSales', params: { itemType: itemType } });
}
">
<ItemTypeSearch @selection="
(itemType) => {
if (itemType) $router.push({ name: 'itemSales', params: { itemType: itemType } });
}
">
</ItemTypeSearch>
</div>
</div>
Expand Down Expand Up @@ -310,7 +310,8 @@ function getWikiLink() {
<div class="flex flex-col" v-if="villagerTrades.length > 0">
<h2 class="text-xl font-bold capitalize">Villager Trade</h2>
<p class="text-gray-300" v-for="villagerTrade in villagerTrades">
{{ villagerTrade.villager }}: {{ villagerTrade.price }} {{ villagerTrade.currency }} for {{ villagerTrade.quantity }}
{{ villagerTrade.villager }}: {{ villagerTrade.price }} {{ villagerTrade.currency }} for {{
villagerTrade.quantity }}
{{ villagerTrade.itemType }} {{ villagerTrade.extraInfo ? `(${villagerTrade.extraInfo})` : "" }}
</p>
<p class="text-gray-300 text-xs">
Expand All @@ -319,6 +320,15 @@ function getWikiLink() {
</div>
</div>

<!-- Custom disc stats -->
<div v-if="props.itemType == 'CUSTOM_MUSIC_DISC'">
<div>
<h2 class="text-2xl font-bold">Statistics</h2>
</div>

<CustomDiscStats></CustomDiscStats>
</div>

<!-- Latest sales -->
<div>
<div class="flex flex-column sm:flex-row flex-wrap space-y-1 items-end justify-between pb-2">
Expand All @@ -329,31 +339,17 @@ function getWikiLink() {
</div>

<div class="flex flex-row flex-wrap gap-1 items-end">
<DropdownFilter
v-if="enchantments.length > 0"
:placeholder="'Enchantments'"
:icon="'fa-solid fa-wand-sparkles'"
:options="enchantments"
:single-selection="true"
<DropdownFilter v-if="enchantments.length > 0" :placeholder="'Enchantments'"
:icon="'fa-solid fa-wand-sparkles'" :options="enchantments" :single-selection="true"
v-model="enchantmentFilter">
</DropdownFilter>

<DropdownFilter
v-if="potionEffects.length > 0"
:placeholder="'Potion Effect'"
:icon="'fa-solid fa-flask'"
:options="potionEffects"
:single-selection="true"
v-model="potionEffectFilter">
<DropdownFilter v-if="potionEffects.length > 0" :placeholder="'Potion Effect'" :icon="'fa-solid fa-flask'"
:options="potionEffects" :single-selection="true" v-model="potionEffectFilter">
</DropdownFilter>

<DropdownFilter
v-if="customDiscs.length > 0"
:placeholder="'Custom Discs'"
:icon="'fa-solid fa-record-vinyl'"
:options="customDiscs"
:single-selection="true"
v-model="customDiscFilter">
<DropdownFilter v-if="customDiscs.length > 0" :placeholder="'Custom Discs'" :icon="'fa-solid fa-record-vinyl'"
:options="customDiscs" :single-selection="true" v-model="customDiscFilter">
</DropdownFilter>

<SearchBox :placeholder="'Item Name'" v-model="nameFilter"></SearchBox>
Expand All @@ -378,39 +374,24 @@ function getWikiLink() {
</div>

<div class="flex flex-row flex-wrap gap-1 items-end">
<DropdownFilter
v-if="enchantments.length > 0"
:placeholder="'Enchantments'"
:icon="'fa-solid fa-wand-sparkles'"
:options="enchantments"
:single-selection="true"
<DropdownFilter v-if="enchantments.length > 0" :placeholder="'Enchantments'"
:icon="'fa-solid fa-wand-sparkles'" :options="enchantments" :single-selection="true"
v-model="enchantmentFilter">
</DropdownFilter>

<DropdownFilter
v-if="potionEffects.length > 0"
:placeholder="'Potion Effect'"
:icon="'fa-solid fa-flask'"
:options="potionEffects"
:single-selection="true"
v-model="potionEffectFilter">
<DropdownFilter v-if="potionEffects.length > 0" :placeholder="'Potion Effect'" :icon="'fa-solid fa-flask'"
:options="potionEffects" :single-selection="true" v-model="potionEffectFilter">
</DropdownFilter>

<DropdownFilter
v-if="customDiscs.length > 0"
:placeholder="'Custom Discs'"
:icon="'fa-solid fa-record-vinyl'"
:options="customDiscs"
:single-selection="true"
v-model="customDiscFilter">
<DropdownFilter v-if="customDiscs.length > 0" :placeholder="'Custom Discs'" :icon="'fa-solid fa-record-vinyl'"
:options="customDiscs" :single-selection="true" v-model="customDiscFilter">
</DropdownFilter>

<SearchBox :placeholder="'Item Name'" v-model="nameFilter"></SearchBox>
</div>
</div>

<div
v-if="itemInfo?.shopCaveats"
<div v-if="itemInfo?.shopCaveats"
class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-gray-800 dark:text-yellow-300"
role="alert">
{{ itemInfo.shopCaveats }}
Expand Down
Loading