aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src-vue/src/App.vue3
-rw-r--r--src-vue/src/components/ModsMenu.vue118
-rw-r--r--src-vue/src/main.ts2
-rw-r--r--src-vue/src/plugins/modules/search.ts19
-rw-r--r--src-vue/src/plugins/store.ts16
-rw-r--r--src-vue/src/utils/SortOptions.d.ts8
-rw-r--r--src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts2
-rw-r--r--src-vue/src/views/ModsView.vue128
-rw-r--r--src-vue/src/views/ThunderstoreModsView.vue240
-rw-r--r--src-vue/src/views/mods/LocalModsView.vue122
-rw-r--r--src-vue/src/views/mods/ThunderstoreModsView.vue272
-rw-r--r--src-vue/tsconfig.json2
12 files changed, 592 insertions, 340 deletions
diff --git a/src-vue/src/App.vue b/src-vue/src/App.vue
index fc6992b7..0ecea16d 100644
--- a/src-vue/src/App.vue
+++ b/src-vue/src/App.vue
@@ -59,7 +59,6 @@ export default {
<el-menu-item index="/">Play</el-menu-item>
<el-menu-item index="/changelog">Changelog</el-menu-item>
<el-menu-item index="/mods">Mods</el-menu-item>
- <el-menu-item index="/thunderstoreMods">Thunderstore</el-menu-item>
<el-menu-item index="/settings">Settings</el-menu-item>
<el-menu-item index="/dev" v-if="$store.state.developer_mode">Dev</el-menu-item>
</el-menu>
@@ -109,7 +108,7 @@ export default {
border-color: white;
}
-.el-menu > .el-menu-item {
+#fc__menu_items > .el-menu-item {
text-transform: uppercase;
border: none !important;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
diff --git a/src-vue/src/components/ModsMenu.vue b/src-vue/src/components/ModsMenu.vue
new file mode 100644
index 00000000..9b62fcfa
--- /dev/null
+++ b/src-vue/src/components/ModsMenu.vue
@@ -0,0 +1,118 @@
+<template>
+ <nav class="fc_mods__menu">
+ <el-menu
+ default-active="1"
+ text-color="#fff"
+ >
+ <h5>Mods</h5>
+ <el-menu-item index="1" @click="$emit('showLocalMods', true)">
+ <el-icon><Folder /></el-icon>
+ <span>Local</span>
+ </el-menu-item>
+ <el-menu-item index="2" @click="$emit('showLocalMods', false)">
+ <el-icon><Connection /></el-icon>
+ <span>Online</span>
+ </el-menu-item>
+
+ <!-- Search inputs -->
+ <h5>Filter</h5>
+ <el-input v-model="$store.state.search.searchValue" placeholder="Search" clearable />
+ <el-select
+ v-if="!showingLocalMods"
+ v-model="$store.state.search.sortValue"
+ placeholder="Sort mods"
+ >
+ <el-option
+ v-for="item of sortValues"
+ :key="item.value"
+ :label="item.label"
+ :value="item.value"
+ />
+ </el-select>
+ <el-select
+ v-if="!showingLocalMods"
+ v-model="$store.state.search.selectedCategories"
+ multiple
+ placeholder="Select categories"
+ >
+ <el-option
+ v-for="item in $store.state.thunderstoreModsCategories"
+ :key="item"
+ :label="item"
+ :value="item"
+ />
+ </el-select>
+
+ </el-menu>
+ </nav>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { SortOptions } from '../utils/SortOptions.d';
+
+export default defineComponent({
+ name: 'ModsMenu',
+ props: {
+ showingLocalMods: {
+ required: true,
+ type: Boolean
+ }
+ },
+ mounted() {
+ this.$store.state.search.sortValue = this.sortValues[3].value;
+ },
+ computed: {
+ sortValues(): {label: string, value: string}[] {
+ return Object.keys(SortOptions).map((key: string) => ({
+ value: key,
+ label: Object.values(SortOptions)[Object.keys(SortOptions).indexOf(key)]
+ }));
+ }
+ }
+})
+</script>
+
+<style scoped>
+.fc_mods__menu {
+ display: flex;
+ max-width: 222px;
+ min-width: 222px;
+ padding: 10px;
+}
+
+.fc_mods__menu h5 {
+ margin: 8px 0 16px 5px;
+}
+
+.fc_mods__menu h5:not(:first-child){
+ margin-top: 32px;
+}
+
+.fc_mods__menu > .el-menu {
+ background-color: transparent;
+ border: none;
+ width: 100%;
+}
+
+.fc_mods__menu > .el-menu > .el-menu-item {
+ height: 32px;
+ margin-bottom: 5px;
+ border-radius: 5px;
+ color: #e2e6e7;
+}
+
+.fc_mods__menu > .el-menu > .el-menu-item:hover {
+ background-color: #4e4e4e3b;
+}
+
+.fc_mods__menu > .el-menu > .el-menu-item.is-active {
+ color: white;
+ background-color: #4e4e4e7a;
+}
+
+.el-select {
+ width: 100%;
+ margin-top: 5px;
+}
+</style>
diff --git a/src-vue/src/main.ts b/src-vue/src/main.ts
index 25f1b064..95bea7af 100644
--- a/src-vue/src/main.ts
+++ b/src-vue/src/main.ts
@@ -6,7 +6,6 @@ import { store } from './plugins/store';
import PlayView from "./views/PlayView.vue";
import ChangelogView from "./views/ChangelogView.vue";
import ModsView from "./views/ModsView.vue";
-import ThunderstoreModsView from "./views/ThunderstoreModsView.vue";
import SettingsView from "./views/SettingsView.vue";
import DeveloperView from "./views/DeveloperView.vue";
import {createRouter, createWebHashHistory} from "vue-router";
@@ -35,7 +34,6 @@ const routes = [
{ path: '/', name: 'Main', component: async () => PlayView},
{ path: '/changelog', name: 'Changelog', component: async () => ChangelogView},
{ path: '/mods', name: 'Mods', component: async () => ModsView},
- { path: '/thunderstoreMods', name: 'Thunderstore mods', component: async () => ThunderstoreModsView},
{ path: '/settings', name: 'Settings', component: async () => SettingsView},
{ path: '/dev', name: 'Dev', component: async () => DeveloperView}
];
diff --git a/src-vue/src/plugins/modules/search.ts b/src-vue/src/plugins/modules/search.ts
new file mode 100644
index 00000000..16260387
--- /dev/null
+++ b/src-vue/src/plugins/modules/search.ts
@@ -0,0 +1,19 @@
+interface SearchStoreState {
+ searchValue: string
+}
+
+export const searchModule = {
+ state: () => ({
+ // This is the treated value of search input
+ searchValue: '',
+ // Selected mod categories
+ selectedCategories: [],
+ sortValue: {label: '', value: ''}
+ }),
+ getters: {
+ searchWords(state: SearchStoreState): string {
+ return state.searchValue.toLowerCase();
+ }
+ }
+ }
+ \ No newline at end of file
diff --git a/src-vue/src/plugins/store.ts b/src-vue/src/plugins/store.ts
index edeb13df..2eae843a 100644
--- a/src-vue/src/plugins/store.ts
+++ b/src-vue/src/plugins/store.ts
@@ -15,6 +15,7 @@ import { router } from "../main";
import ReleaseInfo from "../utils/ReleaseInfo";
import { ThunderstoreMod } from '../utils/thunderstore/ThunderstoreMod';
import { NorthstarMod } from "../utils/NorthstarMod";
+import { searchModule } from './modules/search';
const persistentStore = new Store('flight-core-settings.json');
@@ -33,6 +34,7 @@ export interface FlightCoreStore {
releaseNotes: ReleaseInfo[],
thunderstoreMods: ThunderstoreMod[],
+ thunderstoreModsCategories: string[],
installed_mods: NorthstarMod[],
northstar_is_running: boolean,
@@ -48,6 +50,9 @@ export interface FlightCoreStore {
let notification_handle: NotificationHandle;
export const store = createStore<FlightCoreStore>({
+ modules: {
+ search: searchModule
+ },
state(): FlightCoreStore {
return {
developer_mode: false,
@@ -63,6 +68,7 @@ export const store = createStore<FlightCoreStore>({
releaseNotes: [],
thunderstoreMods: [],
+ thunderstoreModsCategories: [],
installed_mods: [],
northstar_is_running: false,
@@ -244,6 +250,16 @@ export const store = createStore<FlightCoreStore>({
state.thunderstoreMods = mods.filter((mod: ThunderstoreMod) => {
return !removedMods.includes(mod.name) && !mod.is_deprecated;
});
+
+ // Retrieve categories from mods
+ state.thunderstoreModsCategories = mods
+ .map((mod: ThunderstoreMod) => mod.categories)
+ .filter((modCategories: string[]) => modCategories.length !== 0)
+ .reduce((accumulator: string[], modCategories: string[]) => {
+ accumulator.push( ...modCategories.filter((cat: string) => !accumulator.includes(cat)) );
+ return accumulator;
+ }, [])
+ .sort();
},
async loadInstalledMods(state: FlightCoreStore) {
let game_install = {
diff --git a/src-vue/src/utils/SortOptions.d.ts b/src-vue/src/utils/SortOptions.d.ts
new file mode 100644
index 00000000..6bdd0a4a
--- /dev/null
+++ b/src-vue/src/utils/SortOptions.d.ts
@@ -0,0 +1,8 @@
+export enum SortOptions {
+ NAME_ASC = 'By name (A to Z)',
+ NAME_DESC = 'By name (Z to A)',
+ DATE_ASC = 'By date (from oldest)',
+ DATE_DESC = 'By date (from newest)',
+ MOST_DOWNLOADED = "Most downloaded",
+ TOP_RATED = "Top rated"
+}
diff --git a/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts b/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts
index 6fddd06f..6387c47e 100644
--- a/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts
+++ b/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts
@@ -3,8 +3,10 @@ import { ThunderstoreModVersion } from "./ThunderstoreModVersion";
export interface ThunderstoreMod {
name: string;
owner: string;
+ date_updated: string;
rating_score: number;
package_url: string;
is_deprecated: boolean;
versions: ThunderstoreModVersion[];
+ categories: string[];
}
diff --git a/src-vue/src/views/ModsView.vue b/src-vue/src/views/ModsView.vue
index 2f352ded..7c309832 100644
--- a/src-vue/src/views/ModsView.vue
+++ b/src-vue/src/views/ModsView.vue
@@ -1,112 +1,50 @@
<template>
- <div class="fc-container">
- <el-scrollbar>
- <h3>Installed Mods:</h3>
- <div>
- <p v-if="installedMods.length === 0">No mods were found.</p>
- <el-card v-else shadow="hover" v-for="mod in installedMods" v-bind:key="mod.name">
- <el-switch style="--el-switch-on-color: #13ce66; --el-switch-off-color: #8957e5" v-model="mod.enabled"
- :before-change="() => updateWhichModsEnabled(mod)" :loading="global_load_indicator" />
- <el-popconfirm
- title="Are you sure to delete this mod?"
- @confirm="deleteMod(mod)"
- >
- <template #reference>
- <el-button type="danger">Delete</el-button>
- </template>
- </el-popconfirm>
- {{ mod.name }}
- </el-card>
- </div>
- </el-scrollbar>
+ <div class="fc-container" style="display: flex">
+ <!-- Local mods/Thunderstore mods menu -->
+ <mods-menu
+ :showingLocalMods="show_local_mods"
+ @showLocalMods="(v) => show_local_mods = v"
+ />
+
+ <!-- Mods content -->
+ <div class="fc_mods__container">
+ <local-mods-view
+ v-if="show_local_mods"
+ />
+
+ <thunderstore-mods-view
+ v-else
+ clearable
+ />
+ </div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
-import { ElNotification } from "element-plus";
-import { invoke } from '@tauri-apps/api/tauri';
-import { GameInstall } from "../utils/GameInstall";
-import { NorthstarMod } from "../utils/NorthstarMod"
+import ThunderstoreModsView from "./mods/ThunderstoreModsView.vue";
+import LocalModsView from "./mods/LocalModsView.vue";
+import ModsMenu from "../components/ModsMenu.vue";
export default defineComponent({
name: "ModsView",
+ components: {
+ ModsMenu,
+ LocalModsView,
+ ThunderstoreModsView
+ },
data() {
return {
- global_load_indicator: false
- }
- },
- async mounted() {
- this.$store.commit('loadInstalledMods');
- },
- computed: {
- installedMods(): NorthstarMod[] {
- return this.$store.state.installed_mods;
- }
- },
- methods: {
- async updateWhichModsEnabled(mod: NorthstarMod) {
- this.global_load_indicator = true;
-
- // Setup up struct
- let game_install = {
- game_path: this.$store.state.game_path,
- install_type: this.$store.state.install_type
- } as GameInstall;
-
- // enable/disable specific mod
- try {
- await invoke("set_mod_enabled_status", {
- gameInstall: game_install,
- modName: mod.name,
- // Need to set it to the opposite of current state,
- // as current state is only updated after command is run
- isEnabled: !mod.enabled,
- })
- }
- catch (error) {
- ElNotification({
- title: 'Error',
- message: `${error}`,
- type: 'error',
- position: 'bottom-right'
- });
- this.global_load_indicator = false;
- return false;
- }
-
- this.global_load_indicator = false;
- return true;
- },
- async deleteMod(mod: NorthstarMod) {
- let game_install = {
- game_path: this.$store.state.game_path,
- install_type: this.$store.state.install_type
- } as GameInstall;
- await invoke("delete_northstar_mod", { gameInstall: game_install, nsmodName: mod.name })
- .then((message) => {
- // Just a visual indicator that it worked
- ElNotification({
- title: `Success deleting ${mod.name}`,
- type: 'success',
- position: 'bottom-right'
- });
- })
- .catch((error) => {
- ElNotification({
- title: 'Error',
- message: error,
- type: 'error',
- position: 'bottom-right'
- });
- })
- .finally(() => {
- this.$store.commit('loadInstalledMods');
- });
+ show_local_mods: true,
}
}
});
</script>
-<style>
+<style scoped>
+.fc_mods__container {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+}
</style>
diff --git a/src-vue/src/views/ThunderstoreModsView.vue b/src-vue/src/views/ThunderstoreModsView.vue
deleted file mode 100644
index 5733d30b..00000000
--- a/src-vue/src/views/ThunderstoreModsView.vue
+++ /dev/null
@@ -1,240 +0,0 @@
-<template>
- <div class="fc-container">
- <div v-if="mods.length === 0" class="fc__changelog__container">
- <el-progress :show-text="false" :percentage="50" :indeterminate="true" />
- </div>
- <el-scrollbar v-else class="container" ref="scrollbar">
- <div class="card-container">
- <!-- Search filters -->
- <div class="filter_container">
- <el-input v-model="input" placeholder="Search" clearable @input="onFilterTextChange" />
- <!-- Message displayed when user is typing in search bar -->
- <div v-if="userIsTyping" class="modMessage search">
- Searching mods...
- </div>
-
- <!-- Pagination -->
- <el-pagination
- v-if="shouldDisplayPagination"
- :currentPage="currentPageIndex + 1"
- layout="prev, pager, next"
- :page-size="modsPerPage"
- :total="modsList.length"
- @current-change="(e: number) => currentPageIndex = e - 1"
- />
- </div>
-
- <!-- Message displayed if no mod matched searched words -->
- <div v-if="filteredMods.length === 0 && input.length !== 0 && !userIsTyping" class="modMessage">
- No matching mod has been found.<br/>
- Try another search!
- </div>
-
- <!-- Mod cards -->
- <thunderstore-mod-card v-for="mod of currentPageMods" v-bind:key="mod.name" :mod="mod" />
- </div>
-
- <!-- Bottom pagination -->
- <div class="card-container">
- <div class="filter_container">
- <el-pagination
- class="fc_bottom__pagination"
- v-if="shouldDisplayPagination"
- :currentPage="currentPageIndex + 1"
- layout="prev, pager, next"
- :page-size="modsPerPage"
- :total="modsList.length"
- @current-change="onBottomPaginationChange"
- />
- </div>
- </div>
- </el-scrollbar>
- </div>
-</template>
-
-<script lang="ts">
-import { defineComponent, ref } from 'vue';
-import { ThunderstoreMod } from "../utils/thunderstore/ThunderstoreMod";
-import ThunderstoreModCard from "../components/ThunderstoreModCard.vue";
-import { ElScrollbar, ScrollbarInstance } from "element-plus";
-
-export default defineComponent({
- name: "ThunderstoreModsView",
- components: {ThunderstoreModCard},
- async mounted() {
- this.$store.commit('fetchThunderstoreMods');
- },
- computed: {
- mods(): ThunderstoreMod[] {
- return this.$store.state.thunderstoreMods;
- },
- filteredMods(): ThunderstoreMod[] {
- if (this.searchValue.length === 0) {
- return this.mods;
- }
-
- return this.mods.filter((mod: ThunderstoreMod) => {
- return mod.name.toLowerCase().includes(this.searchValue)
- || mod.owner.toLowerCase().includes(this.searchValue)
- || mod.versions[0].description.toLowerCase().includes(this.searchValue);
- });
- },
- modsList(): ThunderstoreMod[] {
- return this.input.length !== 0 || this.userIsTyping ? this.filteredMods : this.mods;
- },
- modsPerPage(): number {
- return parseInt(this.$store.state.mods_per_page);
- },
- currentPageMods(): ThunderstoreMod[] {
- // User might want to display all mods on one page.
- const perPageValue = this.modsPerPage != 0 ? this.modsPerPage : this.modsList.length;
-
- const startIndex = this.currentPageIndex * perPageValue;
- const endIndexCandidate = startIndex + perPageValue;
- const endIndex = endIndexCandidate > this.modsList.length ? this.modsList.length : endIndexCandidate;
- return this.modsList.slice(startIndex, endIndex);
- },
- shouldDisplayPagination(): boolean {
- return this.modsPerPage != 0 && this.modsList.length > this.modsPerPage;
- }
- },
- data() {
- return {
- // This is the model for the search input.
- input: '',
- // This is the treated value of search input
- searchValue: '',
-
- modsBeingInstalled: [] as string[],
- userIsTyping: false,
-
- currentPageIndex: 0
- };
- },
- methods: {
- /**
- * This method is called each time search input is modified, and
- * triggered filtered mods recomputing by updating the `searchValue`
- * variable.
- *
- * This converts research string and all researched fields to
- * lower case, to match mods regardless of font case.
- */
- onFilterTextChange(value: string) {
- this.currentPageIndex = 0;
- this.searchValue = value.toLowerCase();
- },
-
- /**
- * This updates current pagination and scrolls view to the top.
- */
- onBottomPaginationChange(index: number) {
- this.currentPageIndex = index - 1;
- (this.$refs.scrollbar as ScrollbarInstance).scrollTo({ top: 0, behavior: 'smooth' });
- }
- }
-});
-</script>
-
-<style scoped>
-.fc__changelog__container {
- padding: 20px 30px;
-}
-
-.el-timeline-item__timestamp {
- color: white !important;
- user-select: none !important;
-}
-
-.search {
- display: inline-block;
- margin: 0 0 0 10px !important;
-}
-
-.modMessage {
- color: white;
- margin: 20px 5px;
-}
-
-.card-container {
- margin: 0 auto;
-}
-
-.fc_bottom__pagination {
- padding-bottom: 20px !important;
- padding-right: 10px;
-}
-
-/* Card container dynamic size */
-@media (max-width: 1000px) {
- .card-container {
- width: 752px;
- }
-}
-
-@media (max-width: 812px) {
- .card-container {
- width: 574px;
- }
-
- .el-pagination {
- float: none;
- margin-top: 5px;
- }
-}
-
-@media (max-width: 624px) {
- .card-container {
- width: 376px;
- }
-}
-
-.filter_container {
- margin-bottom: 10px;
-}
-
-.el-input {
- max-width: 200px;
-}
-
-@media (min-width: 812px) {
- .filter_container {
- margin: 5px auto;
- padding: 0 5px;
- max-width: 1000px;
- }
-
- .el-pagination {
- float: right;
- margin: 0;
- }
-}
-
-@media (min-width: 1000px) {
- .card-container {
- width: 940px;
- }
-
- .el-input {
- max-width: 300px;
- }
-}
-
-@media (min-width: 1188px) {
- .card-container {
- width: 1128px;
- }
-}
-
-@media (min-width: 1376px) {
- .card-container {
- width: 1316px;
- }
-}
-
-@media (min-width: 1565px) {
- .card-container {
- width: 1505px;
- }
-}
-</style>
diff --git a/src-vue/src/views/mods/LocalModsView.vue b/src-vue/src/views/mods/LocalModsView.vue
new file mode 100644
index 00000000..470ab4f7
--- /dev/null
+++ b/src-vue/src/views/mods/LocalModsView.vue
@@ -0,0 +1,122 @@
+<template>
+ <el-scrollbar>
+ <div>
+ <p v-if="mods.length === 0">No mods were found.</p>
+ <el-card v-else shadow="hover" v-for="mod in mods" v-bind:key="mod.name">
+ <el-switch style="--el-switch-on-color: #13ce66; --el-switch-off-color: #8957e5" v-model="mod.enabled"
+ :before-change="() => updateWhichModsEnabled(mod)" :loading="global_load_indicator" />
+ <el-popconfirm
+ title="Are you sure to delete this mod?"
+ @confirm="deleteMod(mod)"
+ >
+ <template #reference>
+ <el-button type="danger">Delete</el-button>
+ </template>
+ </el-popconfirm>
+ {{ mod.name }}
+ </el-card>
+ </div>
+ </el-scrollbar>
+</template>
+
+<script lang="ts">
+import { invoke } from '@tauri-apps/api';
+import { ElNotification } from 'element-plus';
+import { defineComponent } from 'vue';
+import { GameInstall } from '../../utils/GameInstall';
+import { NorthstarMod } from '../../utils/NorthstarMod';
+
+export default defineComponent({
+ name: 'LocalModsView',
+ computed: {
+ installedMods(): NorthstarMod[] {
+ return this.$store.state.installed_mods;
+ },
+ searchValue(): string {
+ return this.$store.getters.searchWords;
+ },
+ mods(): NorthstarMod[] {
+ if (this.searchValue.length === 0) {
+ return this.installedMods;
+ }
+
+ return this.installedMods.filter((mod: NorthstarMod) => {
+ return mod.name.toLowerCase().includes(this.searchValue);
+ });
+ }
+ },
+ data() {
+ return {
+ global_load_indicator: false,
+ };
+ },
+ methods: {
+ async updateWhichModsEnabled(mod: NorthstarMod) {
+ this.global_load_indicator = true;
+
+ // Setup up struct
+ let game_install = {
+ game_path: this.$store.state.game_path,
+ install_type: this.$store.state.install_type
+ } as GameInstall;
+
+ // enable/disable specific mod
+ try {
+ await invoke("set_mod_enabled_status", {
+ gameInstall: game_install,
+ modName: mod.name,
+ // Need to set it to the opposite of current state,
+ // as current state is only updated after command is run
+ isEnabled: !mod.enabled,
+ })
+ }
+ catch (error) {
+ ElNotification({
+ title: 'Error',
+ message: `${error}`,
+ type: 'error',
+ position: 'bottom-right'
+ });
+ this.global_load_indicator = false;
+ return false;
+ }
+
+ this.global_load_indicator = false;
+ return true;
+ },
+ async deleteMod(mod: NorthstarMod) {
+ let game_install = {
+ game_path: this.$store.state.game_path,
+ install_type: this.$store.state.install_type
+ } as GameInstall;
+ await invoke("delete_northstar_mod", { gameInstall: game_install, nsmodName: mod.name })
+ .then((message) => {
+ // Just a visual indicator that it worked
+ ElNotification({
+ title: `Success deleting ${mod.name}`,
+ type: 'success',
+ position: 'bottom-right'
+ });
+ })
+ .catch((error) => {
+ ElNotification({
+ title: 'Error',
+ message: error,
+ type: 'error',
+ position: 'bottom-right'
+ });
+ })
+ .finally(() => {
+ this.$store.commit('loadInstalledMods');
+ });
+ },
+ },
+ mounted() {
+ this.$store.commit('loadInstalledMods');
+ }
+})
+</script>
+
+<style scoped>
+
+</style>
diff --git a/src-vue/src/views/mods/ThunderstoreModsView.vue b/src-vue/src/views/mods/ThunderstoreModsView.vue
new file mode 100644
index 00000000..aaf15220
--- /dev/null
+++ b/src-vue/src/views/mods/ThunderstoreModsView.vue
@@ -0,0 +1,272 @@
+<template>
+ <div class="fc-container" style="padding: 0">
+ <div v-if="mods.length === 0" class="fc__changelog__container">
+ <el-progress :show-text="false" :percentage="50" :indeterminate="true" />
+ </div>
+ <el-scrollbar v-else class="container" ref="scrollbar">
+ <div class="card-container">
+ <div class="pagination_container">
+ <el-pagination
+ v-if="shouldDisplayPagination"
+ :currentPage="currentPageIndex + 1"
+ layout="prev, pager, next"
+ :page-size="modsPerPage"
+ :total="modsList.length"
+ @current-change="(e: number) => currentPageIndex = e - 1"
+ />
+ </div>
+
+ <!-- Message displayed if no mod matched searched words -->
+ <div v-if="filteredMods.length === 0" class="modMessage">
+ No matching mod has been found.<br/>
+ Try another search!
+ </div>
+
+ <!-- Mod cards -->
+ <thunderstore-mod-card v-for="mod of currentPageMods" v-bind:key="mod.name" :mod="mod" />
+ </div>
+
+ <!-- Bottom pagination -->
+ <div class="card-container">
+ <div class="pagination_container">
+ <el-pagination
+ class="fc_bottom__pagination"
+ v-if="shouldDisplayPagination"
+ :currentPage="currentPageIndex + 1"
+ layout="prev, pager, next"
+ :page-size="modsPerPage"
+ :total="modsList.length"
+ @current-change="onBottomPaginationChange"
+ />
+ </div>
+ </div>
+ </el-scrollbar>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { ThunderstoreMod } from "../../utils/thunderstore/ThunderstoreMod";
+import ThunderstoreModCard from "../../components/ThunderstoreModCard.vue";
+import { ElScrollbar, ScrollbarInstance } from "element-plus";
+import { SortOptions } from "../../utils/SortOptions.d";
+import { ThunderstoreModVersion } from '../../utils/thunderstore/ThunderstoreModVersion';
+
+export default defineComponent({
+ name: "ThunderstoreModsView",
+ components: {ThunderstoreModCard},
+ async mounted() {
+ this.$store.commit('fetchThunderstoreMods');
+ },
+ computed: {
+ searchValue(): string {
+ return this.$store.getters.searchWords;
+ },
+ selectedCategories(): Object[] {
+ return this.$store.state.search.selectedCategories;
+ },
+ modSorting(): SortOptions {
+ return Object.values(SortOptions)[Object.keys(SortOptions).indexOf(this.$store.state.search.sortValue)];
+ },
+ mods(): ThunderstoreMod[] {
+ return this.$store.state.thunderstoreMods;
+ },
+ filteredMods(): ThunderstoreMod[] {
+ if (this.searchValue.length === 0 && this.selectedCategories.length === 0) {
+ return this.mods;
+ }
+
+ return this.mods.filter((mod: ThunderstoreMod) => {
+ // Filter with search words (only if search field isn't empty)
+ const inputMatches: boolean = this.searchValue.length === 0
+ || (mod.name.toLowerCase().includes(this.searchValue)
+ || mod.owner.toLowerCase().includes(this.searchValue)
+ || mod.versions[0].description.toLowerCase().includes(this.searchValue));
+
+ // Filter with categories (only if some categories are selected)
+ const categoriesMatch: boolean = this.selectedCategories.length === 0
+ || mod.categories
+ .filter((category: string) => this.selectedCategories.includes(category))
+ .length === this.selectedCategories.length;
+
+ return inputMatches && categoriesMatch;
+ });
+ },
+ modsList(): ThunderstoreMod[] {
+ // Use filtered mods if user is searching, vanilla list otherwise.
+ const mods: ThunderstoreMod[] = this.searchValue.length !== 0 || this.selectedCategories.length !== 0
+ ? this.filteredMods
+ : this.mods;
+
+ // Sort mods regarding user selected algorithm.
+ let compare: (a: ThunderstoreMod, b: ThunderstoreMod) => number;
+ switch(this.modSorting) {
+ case SortOptions.NAME_ASC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => a.name.localeCompare(b.name);
+ break;
+ case SortOptions.NAME_DESC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => -1 * a.name.localeCompare(b.name);
+ break;
+ case SortOptions.DATE_ASC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => a.date_updated.localeCompare(b.date_updated);
+ break;
+ case SortOptions.DATE_DESC:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => -1 * a.date_updated.localeCompare(b.date_updated);
+ break;
+ case SortOptions.MOST_DOWNLOADED:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => {
+ const aTotal = a.versions.reduce((prev, next) => {
+ return {downloads: prev.downloads + next.downloads} as ThunderstoreModVersion;
+ }).downloads;
+ const bTotal = b.versions.reduce((prev, next) => {
+ return {downloads: prev.downloads + next.downloads} as ThunderstoreModVersion;
+ }).downloads;
+ return -1 * (aTotal - bTotal);
+ };
+ break;
+ case SortOptions.TOP_RATED:
+ compare = (a: ThunderstoreMod, b: ThunderstoreMod) => -1 * (a.rating_score - b.rating_score);
+ break;
+ default:
+ throw new Error('Unknown mod sorting.');
+ }
+
+ return mods.sort(compare);
+ },
+ modsPerPage(): number {
+ return parseInt(this.$store.state.mods_per_page);
+ },
+ currentPageMods(): ThunderstoreMod[] {
+ // User might want to display all mods on one page.
+ const perPageValue = this.modsPerPage != 0 ? this.modsPerPage : this.modsList.length;
+
+ const startIndex = this.currentPageIndex * perPageValue;
+ const endIndexCandidate = startIndex + perPageValue;
+ const endIndex = endIndexCandidate > this.modsList.length ? this.modsList.length : endIndexCandidate;
+ return this.modsList.slice(startIndex, endIndex);
+ },
+ shouldDisplayPagination(): boolean {
+ return this.modsPerPage != 0 && this.modsList.length > this.modsPerPage;
+ }
+ },
+ data() {
+ return {
+ modsBeingInstalled: [] as string[],
+ currentPageIndex: 0
+ };
+ },
+ methods: {
+ /**
+ * This updates current pagination and scrolls view to the top.
+ */
+ onBottomPaginationChange(index: number) {
+ this.currentPageIndex = index - 1;
+ (this.$refs.scrollbar as ScrollbarInstance).scrollTo({ top: 0, behavior: 'smooth' });
+ }
+ },
+ watch: {
+ searchValue(_: string, __: string) {
+ if (this.currentPageIndex !== 0) {
+ this.currentPageIndex = 0;
+ }
+ },
+ selectedCategories(_: string[], __: string[]) {
+ if (this.currentPageIndex !== 0) {
+ this.currentPageIndex = 0;
+ }
+ }
+ }
+});
+</script>
+
+<style scoped>
+.fc__changelog__container {
+ padding: 20px 30px;
+}
+
+.fc-container:deep(.el-scrollbar__view) {
+ padding-left: 0;
+ padding-right: 0;
+}
+
+.el-timeline-item__timestamp {
+ color: white !important;
+ user-select: none !important;
+}
+
+.search {
+ display: inline-block;
+ margin: 0 0 0 10px !important;
+}
+
+.modMessage {
+ color: white;
+ margin: 20px 5px;
+}
+
+.card-container {
+ margin: 0 auto;
+}
+
+.pagination_container {
+ margin: 5px auto;
+ padding: 0 5px;
+ max-width: 1000px;
+ justify-content: center;
+ display: flex;
+}
+
+.el-pagination {
+ margin: 0;
+}
+
+.fc_bottom__pagination {
+ padding-bottom: 20px !important;
+ padding-right: 10px;
+}
+
+/* Card container dynamic size */
+
+.card-container {
+ --thunderstore-mod-card-width: 178px;
+ --thunderstore-mod-card-margin: 5px;
+ --thunderstore-mod-card-columns-count: 1;
+
+ width: calc(var(--thunderstore-mod-card-width) * var(--thunderstore-mod-card-columns-count) + var(--thunderstore-mod-card-margin) * 2 * var(--thunderstore-mod-card-columns-count));
+}
+@media (min-width: 628px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 2;
+ }
+}
+@media (min-width: 836px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 3;
+ }
+}
+@media (min-width: 1006px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 4;
+ }
+}
+@media (min-width: 1196px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 5;
+ }
+}
+@media (min-width: 1386px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 6;
+ }
+}
+@media (min-width: 1576px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 7;
+ }
+}
+@media (min-width: 1766px) {
+ .card-container {
+ --thunderstore-mod-card-columns-count: 8;
+ }
+}
+</style>
diff --git a/src-vue/tsconfig.json b/src-vue/tsconfig.json
index d4aefa2c..7f0f0012 100644
--- a/src-vue/tsconfig.json
+++ b/src-vue/tsconfig.json
@@ -10,7 +10,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
- "lib": ["ESNext", "DOM"],
+ "lib": ["ESNext", "DOM", "ES2018"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],