aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src-vue/src/App.vue12
-rw-r--r--src-vue/src/components/ThunderstoreModCard.vue248
-rw-r--r--src-vue/src/main.ts2
-rw-r--r--src-vue/src/plugins/store.ts40
-rw-r--r--src-vue/src/style.css1
-rw-r--r--src-vue/src/utils/NorthstarMod.d.ts4
-rw-r--r--src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts9
-rw-r--r--src-vue/src/utils/thunderstore/ThunderstoreModStatus.ts7
-rw-r--r--src-vue/src/utils/thunderstore/ThunderstoreModVersion.d.ts9
-rw-r--r--src-vue/src/views/ModsView.vue22
-rw-r--r--src-vue/src/views/ThunderstoreModsView.vue190
11 files changed, 520 insertions, 24 deletions
diff --git a/src-vue/src/App.vue b/src-vue/src/App.vue
index c17b8817..189403cc 100644
--- a/src-vue/src/App.vue
+++ b/src-vue/src/App.vue
@@ -29,13 +29,20 @@ export default {
close() {
appWindow.close()
}
- }
+ },
+ computed: {
+ bgStyle(): string {
+ // @ts-ignore
+ const shouldBlur = ['/thunderstoreMods'].includes(this.$route.path);
+ return `filter: brightness(0.8) ${shouldBlur ? 'blur(5px)' : ''};`;
+ }
+ }
}
</script>
<template>
<div class="app-inner">
- <div id="fc_bg__container" />
+ <div id="fc_bg__container" :style="bgStyle"/>
<el-menu
default-active="/"
@@ -47,6 +54,7 @@ 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>
diff --git a/src-vue/src/components/ThunderstoreModCard.vue b/src-vue/src/components/ThunderstoreModCard.vue
new file mode 100644
index 00000000..1e742fa2
--- /dev/null
+++ b/src-vue/src/components/ThunderstoreModCard.vue
@@ -0,0 +1,248 @@
+<template>
+ <el-card :body-style="{ padding: '0px' }">
+ <img
+ :src="latestVersion.icon"
+ class="image"
+ />
+ <div style="padding: 0 10px 10px;">
+ <span class="statContainer">
+ <el-icon class="no-inherit">
+ <Download />
+ </el-icon>
+ {{ modDownloadsCount }}
+ </span>
+
+ <span class="statContainer">
+ {{ mod.rating_score }}
+ <el-icon class="no-inherit">
+ <Star />
+ </el-icon>
+ </span>
+ <br/>
+
+ <div class="name hide-text-overflow">{{ mod.name }}</div>
+ <div class="author hide-text-overflow">by {{ mod.owner }}</div>
+ <div class="desc">
+ {{ latestVersion.description }}
+ </div>
+
+ <span style="display: flex">
+ <el-button
+ :type="modButtonType"
+ style="flex: 6"
+ :loading="isBeingInstalled || isBeingUpdated"
+ @click.stop="installMod(mod)"
+ >
+ {{ modButtonText }}
+ </el-button>
+ <el-button link type="info" class="infoBtn" @click="openURL(mod.package_url)">
+ <el-icon>
+ <InfoFilled />
+ </el-icon>
+ </el-button>
+ </span>
+ </div>
+ </el-card>
+</template>
+
+<script lang="ts">
+import {defineComponent} from "vue";
+import {ThunderstoreMod} from "../utils/thunderstore/ThunderstoreMod";
+import {ThunderstoreModVersion} from "../utils/thunderstore/ThunderstoreModVersion";
+import {invoke, shell} from "@tauri-apps/api";
+import {ThunderstoreModStatus} from "../utils/thunderstore/ThunderstoreModStatus";
+import {NorthstarMod} from "../utils/NorthstarMod";
+import {GameInstall} from "../utils/GameInstall";
+import {ElNotification} from "element-plus";
+
+export default defineComponent({
+ name: "ThunderstoreModCard",
+ props: {
+ mod: {
+ required: true,
+ type: Object as () => ThunderstoreMod
+ }
+ },
+ data: () => ({
+ isBeingInstalled: false,
+ isBeingUpdated: false
+ }),
+ computed: {
+ latestVersion (): ThunderstoreModVersion {
+ return this.mod.versions[0];
+ },
+
+ /**
+ * Returns the status of a given mod.
+ */
+ modStatus(): ThunderstoreModStatus {
+ if (this.isBeingInstalled) {
+ return ThunderstoreModStatus.BEING_INSTALLED;
+ }
+ if (this.isBeingUpdated) {
+ return ThunderstoreModStatus.BEING_UPDATED;
+ }
+
+ // Ensure mod is up-to-date.
+ const tsModPrefix = this.getThunderstoreDependencyStringPrefix(this.latestVersion.full_name);
+ const matchingMods: NorthstarMod[] = this.$store.state.installed_mods.filter((mod: NorthstarMod) => {
+ if (!mod.thunderstore_mod_string) return false;
+ return this.getThunderstoreDependencyStringPrefix(mod.thunderstore_mod_string!) === tsModPrefix;
+ });
+ if (matchingMods.length !== 0) {
+ // There shouldn't be several mods with same dependency string, but we never know...
+ const matchingMod = matchingMods[0];
+ // A mod is outdated if its dependency strings differs from Thunderstore dependency string
+ // (no need for semver check here)
+ return matchingMod.thunderstore_mod_string === this.latestVersion.full_name
+ ? ThunderstoreModStatus.INSTALLED
+ : ThunderstoreModStatus.OUTDATED;
+ }
+
+ return ThunderstoreModStatus.NOT_INSTALLED;
+ },
+
+ /**
+ * Returns button text associated to a mod.
+ */
+ modButtonText(): string {
+ switch (this.modStatus) {
+ case ThunderstoreModStatus.BEING_INSTALLED:
+ return "Installing...";
+ case ThunderstoreModStatus.BEING_UPDATED:
+ return "Updating...";
+ case ThunderstoreModStatus.INSTALLED:
+ return "Installed";
+ case ThunderstoreModStatus.NOT_INSTALLED:
+ return "Install";
+ case ThunderstoreModStatus.OUTDATED:
+ return "Update";
+ }
+ },
+
+ /**
+ * Returns button type associated to a mod.
+ */
+ modButtonType(): string {
+ switch (this.modStatus) {
+ case ThunderstoreModStatus.BEING_INSTALLED:
+ return "primary";
+ case ThunderstoreModStatus.INSTALLED:
+ return "success";
+ case ThunderstoreModStatus.NOT_INSTALLED:
+ return "primary";
+ case ThunderstoreModStatus.OUTDATED:
+ case ThunderstoreModStatus.BEING_UPDATED:
+ return "warning";
+ }
+ },
+
+ /**
+ * This computes the total count of downloads of a given mod, by adding
+ * download count of each of its releases.
+ */
+ modDownloadsCount(): number {
+ let totalDownloads = 0;
+ this.mod.versions.map((version: ThunderstoreModVersion) => totalDownloads += version.downloads);
+ return totalDownloads;
+ },
+ },
+ methods: {
+ /**
+ * This opens an URL in user's favorite web browser.
+ * This is used to open Thunderstore mod pages.
+ */
+ openURL(url: string): void {
+ shell.open(url);
+ },
+
+ /**
+ * Strips off a Thunderstore dependency string from its version
+ * (e.g. "taskinoz-WallrunningTitans-1.0.0" to
+ * "taskinoz-WallrunningTitans").
+ */
+ getThunderstoreDependencyStringPrefix (dependency: string): string {
+ const dependencyStringMembers = dependency.split('-');
+ return `${dependencyStringMembers[0]}-${dependencyStringMembers[1]}`;
+ },
+
+ async installMod (mod: ThunderstoreMod) {
+ let game_install = {
+ game_path: this.$store.state.game_path,
+ install_type: this.$store.state.install_type
+ } as GameInstall;
+
+ // set internal state according to current installation state
+ if (this.modStatus === ThunderstoreModStatus.OUTDATED) {
+ this.isBeingUpdated = true;
+ } else {
+ this.isBeingInstalled = true;
+ }
+
+ await invoke("install_mod_caller", { gameInstall: game_install, thunderstoreModString: this.latestVersion.full_name }).then((message) => {
+ ElNotification({
+ title: `Installed ${mod.name}`,
+ message: message as string,
+ type: 'success',
+ position: 'bottom-right'
+ });
+ })
+ .catch((error) => {
+ ElNotification({
+ title: 'Error',
+ message: error,
+ type: 'error',
+ position: 'bottom-right'
+ });
+ })
+ .finally(() => {
+ this.isBeingInstalled = false;
+ this.isBeingUpdated = false;
+ this.$store.commit('loadInstalledMods');
+ });
+ },
+ }
+});
+</script>
+
+<style scoped>
+.el-card {
+ display: inline-block;
+ max-width: 178px;
+ margin: 5px;
+}
+
+.author {
+ font-size: 14px;
+ font-style: italic;
+}
+
+.hide-text-overflow {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.desc {
+ font-size: 12px;
+ margin: 8px 0 16px;
+ height: 57px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.statContainer {
+ font-size: 14px;
+}
+
+.statContainer:nth-child(2) {
+ float: right;
+}
+
+.infoBtn {
+ width: 20px;
+ padding: 0;
+ font-size: 20px;
+ border: none;
+}
+</style>
diff --git a/src-vue/src/main.ts b/src-vue/src/main.ts
index 95bea7af..f40edec2 100644
--- a/src-vue/src/main.ts
+++ b/src-vue/src/main.ts
@@ -17,6 +17,7 @@ const app = createApp(App);
// styles
import 'element-plus/theme-chalk/index.css';
import './style.css'
+import ThunderstoreModsView from "./views/ThunderstoreModsView.vue";
app.use(ElementPlus);
@@ -34,6 +35,7 @@ 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/store.ts b/src-vue/src/plugins/store.ts
index e010ca12..11240ea6 100644
--- a/src-vue/src/plugins/store.ts
+++ b/src-vue/src/plugins/store.ts
@@ -12,6 +12,8 @@ import { open } from '@tauri-apps/api/dialog';
import { Store } from 'tauri-plugin-store-api';
import {router} from "../main";
import ReleaseInfo from "../utils/ReleaseInfo";
+import { ThunderstoreMod } from '../utils/thunderstore/ThunderstoreMod';
+import { NorthstarMod } from "../utils/NorthstarMod";
const persistentStore = new Store('flight-core-settings.json');
@@ -28,6 +30,9 @@ export interface FlightCoreStore {
northstar_release_canal: ReleaseCanal,
releaseNotes: ReleaseInfo[],
+ thunderstoreMods: ThunderstoreMod[],
+ installed_mods: NorthstarMod[],
+
northstar_is_running: boolean,
origin_is_running: boolean
}
@@ -48,6 +53,9 @@ export const store = createStore<FlightCoreStore>({
northstar_release_canal: ReleaseCanal.RELEASE,
releaseNotes: [],
+ thunderstoreMods: [],
+ installed_mods: [],
+
northstar_is_running: false,
origin_is_running: false
}
@@ -201,6 +209,38 @@ export const store = createStore<FlightCoreStore>({
async fetchReleaseNotes(state: FlightCoreStore) {
if (state.releaseNotes.length !== 0) return;
state.releaseNotes = await invoke("get_northstar_release_notes");
+ },
+ async fetchThunderstoreMods(state: FlightCoreStore) {
+ // To check if some Thunderstore mods are already installed/outdated, we need to load locally-installed mods.
+ await store.commit('loadInstalledMods');
+ if (state.thunderstoreMods.length !== 0) return;
+
+ const response = await fetch('https://northstar.thunderstore.io/api/v1/package/');
+ let mods = JSON.parse(await (await response.blob()).text());
+
+ // Remove some mods from listing
+ const removedMods = ['Northstar', 'NorthstarReleaseCandidate', 'r2modman'];
+ state.thunderstoreMods = mods.filter((mod: ThunderstoreMod) => !removedMods.includes(mod.name));
+ },
+ async loadInstalledMods(state: FlightCoreStore) {
+ let game_install = {
+ game_path: state.game_path,
+ install_type: state.install_type
+ } as GameInstall;
+ // Call back-end for installed mods
+ await invoke("get_installed_mods_caller", { gameInstall: game_install })
+ .then((message) => {
+ state.installed_mods = (message as NorthstarMod[]);
+ })
+ .catch((error) => {
+ console.error(error);
+ ElNotification({
+ title: 'Error',
+ message: error,
+ type: 'error',
+ position: 'bottom-right'
+ });
+ });
}
}
});
diff --git a/src-vue/src/style.css b/src-vue/src/style.css
index 66e512f5..d8e10375 100644
--- a/src-vue/src/style.css
+++ b/src-vue/src/style.css
@@ -27,6 +27,7 @@ body {
width: 100%;
position: fixed;
filter: brightness(0.8);
+ scale: 1.02;
}
.el-scrollbar {
diff --git a/src-vue/src/utils/NorthstarMod.d.ts b/src-vue/src/utils/NorthstarMod.d.ts
index 890414d0..747836a2 100644
--- a/src-vue/src/utils/NorthstarMod.d.ts
+++ b/src-vue/src/utils/NorthstarMod.d.ts
@@ -1,6 +1,6 @@
// Matches Rust struct (in lib.rs).
export interface NorthstarMod {
- name: String,
- thunderstore_mod_string?: String,
+ name: string,
+ thunderstore_mod_string?: string,
enabled: bool,
}
diff --git a/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts b/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts
new file mode 100644
index 00000000..c14a83a0
--- /dev/null
+++ b/src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts
@@ -0,0 +1,9 @@
+import { ThunderstoreModVersion } from "./ThunderstoreModVersion";
+
+export interface ThunderstoreMod {
+ name: string;
+ owner: string;
+ rating_score: number;
+ package_url: string;
+ versions: ThunderstoreModVersion[];
+}
diff --git a/src-vue/src/utils/thunderstore/ThunderstoreModStatus.ts b/src-vue/src/utils/thunderstore/ThunderstoreModStatus.ts
new file mode 100644
index 00000000..f2351226
--- /dev/null
+++ b/src-vue/src/utils/thunderstore/ThunderstoreModStatus.ts
@@ -0,0 +1,7 @@
+export enum ThunderstoreModStatus {
+ INSTALLED,
+ BEING_INSTALLED,
+ BEING_UPDATED,
+ NOT_INSTALLED,
+ OUTDATED
+}
diff --git a/src-vue/src/utils/thunderstore/ThunderstoreModVersion.d.ts b/src-vue/src/utils/thunderstore/ThunderstoreModVersion.d.ts
new file mode 100644
index 00000000..f53f0362
--- /dev/null
+++ b/src-vue/src/utils/thunderstore/ThunderstoreModVersion.d.ts
@@ -0,0 +1,9 @@
+export interface ThunderstoreModVersion {
+ full_name: string;
+ description: string;
+ icon: string;
+ version_number: string;
+ download_url: string;
+ downloads: number;
+ date_created: string;
+}
diff --git a/src-vue/src/views/ModsView.vue b/src-vue/src/views/ModsView.vue
index 48e9fbee..3c2b3cfe 100644
--- a/src-vue/src/views/ModsView.vue
+++ b/src-vue/src/views/ModsView.vue
@@ -3,7 +3,7 @@
<el-scrollbar>
<h3>Installed Mods:</h3>
<div>
- <el-card shadow="hover" v-for="mod in installed_mods">
+ <el-card shadow="hover" v-for="mod in $store.state.installed_mods">
<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" />
{{mod.name}}
@@ -24,29 +24,11 @@ export default defineComponent({
name: "ModsView",
data() {
return {
- installed_mods: [] as NorthstarMod[],
global_load_indicator: false
}
},
async mounted() {
- let game_install = {
- game_path: this.$store.state.game_path,
- install_type: this.$store.state.install_type
- } as GameInstall;
- // Call back-end for installed mods
- await invoke("get_installed_mods_caller", { gameInstall: game_install })
- .then((message) => {
- this.installed_mods = (message as NorthstarMod[]);
- })
- .catch((error) => {
- console.error(error);
- ElNotification({
- title: 'Error',
- message: error,
- type: 'error',
- position: 'bottom-right'
- });
- });
+ this.$store.commit('loadInstalledMods');
},
methods: {
async updateWhichModsEnabled(mod: NorthstarMod) {
diff --git a/src-vue/src/views/ThunderstoreModsView.vue b/src-vue/src/views/ThunderstoreModsView.vue
new file mode 100644
index 00000000..cc9db0b5
--- /dev/null
+++ b/src-vue/src/views/ThunderstoreModsView.vue
@@ -0,0 +1,190 @@
+<template>
+ <div style="height: calc(100% - var(--fc-menu_height))">
+ <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">
+ <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>
+ </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 modsList" v-bind:key="mod.name" :mod="mod" />
+ </div>
+ </el-scrollbar>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { ThunderstoreMod } from "../utils/thunderstore/ThunderstoreMod";
+import ThunderstoreModCard from "../components/ThunderstoreModCard.vue";
+
+export default defineComponent({
+ name: "ThunderstoreModsView",
+ components: {ThunderstoreModCard},
+ async mounted() {
+ this.$store.commit('fetchThunderstoreMods');
+ },
+ computed: {
+ mods(): ThunderstoreMod[] {
+ return this.$store.state.thunderstoreMods;
+ },
+ modsList(): ThunderstoreMod[] {
+ return this.input.length === 0 || this.userIsTyping ? this.mods : this.filteredMods;
+ }
+ },
+ data() {
+ return {
+ input: '',
+ filteredMods: [] as ThunderstoreMod[],
+ modsBeingInstalled: [] as string[],
+ userIsTyping: false,
+ debouncedSearch: this.debounce((i: string) => this.filterMods(i))
+ };
+ },
+ methods: {
+ /**
+ * This is a debounced version of the filterMods method, that calls
+ * filterMods when user has stopped typing in the search bar (i.e.
+ * waits 300ms).
+ * It allows not to trigger filtering method (which is costly) each
+ * time user inputs a character.
+ */
+ onFilterTextChange (searchString: string) {
+ this.debouncedSearch(searchString);
+ },
+
+ /**
+ * This method is called each time search input is modified, and
+ * filters mods matching the input string.
+ *
+ * This converts research string and all researched fields to
+ * lower case, to match mods regardless of font case.
+ */
+ filterMods(value: string) {
+ if (value === '') {
+ this.filteredMods = [];
+ return;
+ }
+
+ const searchValue = value.toLowerCase();
+
+ this.filteredMods = this.mods.filter((mod: ThunderstoreMod) => {
+ return mod.name.toLowerCase().includes(searchValue)
+ || mod.owner.toLowerCase().includes(searchValue)
+ || mod.versions[0].description.toLowerCase().includes(searchValue);
+ });
+ },
+
+ /**
+ * This debounces a method, i.e. it prevents input method from being called
+ * multiple times in a short period of time.
+ * Stolen from https://www.freecodecamp.org/news/javascript-debounce-example/
+ */
+ debounce (func: Function, timeout = 200) {
+ let timer: any;
+ return (...args: any) => {
+ this.userIsTyping = true;
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ this.userIsTyping = false;
+ func.apply(this, args);
+ }, timeout);
+ };
+ }
+ }
+});
+</script>
+
+<style scoped>
+.fc__changelog__container {
+ padding: 20px 30px;
+ position: relative;
+ overflow-y: auto;
+ height: calc(100% - var(--fc-menu_height));
+ color: white;
+}
+
+.el-timeline-item__timestamp {
+ color: white !important;
+ user-select: none !important;
+}
+
+.filter_container {
+ margin: 5px;
+}
+
+.el-input {
+ max-width: 300px;
+}
+
+.search {
+ display: inline-block;
+ margin: 0 0 0 10px !important;
+}
+
+.modMessage {
+ color: white;
+ margin: 20px 5px;
+}
+
+.card-container {
+ margin: 0 auto;
+}
+
+/* Card container dynamic size */
+@media (max-width: 1000px) {
+ .card-container {
+ width: 752px;
+ }
+}
+
+@media (max-width: 812px) {
+ .card-container {
+ width: 574px;
+ }
+}
+
+@media (max-width: 624px) {
+ .card-container {
+ width: 376px;
+ }
+}
+
+@media (min-width: 1000px) {
+ .card-container {
+ width: 940px;
+ }
+}
+
+@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>