diff options
Diffstat (limited to 'src-vue/src')
-rw-r--r-- | src-vue/src/App.vue | 12 | ||||
-rw-r--r-- | src-vue/src/components/ThunderstoreModCard.vue | 248 | ||||
-rw-r--r-- | src-vue/src/main.ts | 2 | ||||
-rw-r--r-- | src-vue/src/plugins/store.ts | 40 | ||||
-rw-r--r-- | src-vue/src/style.css | 1 | ||||
-rw-r--r-- | src-vue/src/utils/NorthstarMod.d.ts | 4 | ||||
-rw-r--r-- | src-vue/src/utils/thunderstore/ThunderstoreMod.d.ts | 9 | ||||
-rw-r--r-- | src-vue/src/utils/thunderstore/ThunderstoreModStatus.ts | 7 | ||||
-rw-r--r-- | src-vue/src/utils/thunderstore/ThunderstoreModVersion.d.ts | 9 | ||||
-rw-r--r-- | src-vue/src/views/ModsView.vue | 22 | ||||
-rw-r--r-- | src-vue/src/views/ThunderstoreModsView.vue | 190 |
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> |