diff options
author | Rémy Raes <contact@remyraes.com> | 2022-11-25 19:55:35 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-11-25 19:55:35 +0100 |
commit | 2cc698a62119a45f87a20a9c56f9acca92c618d5 (patch) | |
tree | 2eea2ec26aef7af58222473f7a11f53e6cf238d6 /src-vue/src | |
parent | 345b617c765c29c44627f5657ed2056c731481c9 (diff) | |
download | FlightCore-2cc698a62119a45f87a20a9c56f9acca92c618d5.tar.gz FlightCore-2cc698a62119a45f87a20a9c56f9acca92c618d5.zip |
feat: Thunderstore mods listing (#54)
* feat: add ThunderstoreMod type
* feat: add view component to develop mods layout
* feat: mount view component in router
* feat: display thunderstore section in menu
* refactor: create thunderstore package
* feat: add ThunderstoreModVersion type
* feat: add icon field to mod version type
* feat: add basic card layout for mods
* refactor: card button text is computed
In the future, we want the button text to change regarding status of the current
mod (installed, deprecated or not installed at all).
* feat: display mod owners
* feat: display download and like counts
* feat: fetch mods from Thunderstore API
* fix: type issue
* fix: prevent texts from overflowing
* fix: all cards have same height
* feat: add some space between cards
* feat: add basic search bar to filter mods
* fix: convert search string to lowercase to avoid font case issues
* feat: remove some mods from listing
* feat: a button opens mod page on Thunderstore website
* feat: display some text if no mod matched searched words
* fix: description size
* fix: display mod's total downloads count
* docs: add documentation to methods
* refactor: store thunderstore mods in store
* docs: remove TODO notes
* style: add trailing line to ThunderstoreModVersion.d.ts
* feat: cards container is responsive
Mods cards will always appear centered on the screen, and cards container
width adjusts to window width.
* fix: debounce mods filtering
Since filtering mods with search string is costly, we don't do it for each
character entered into search bar, but rather wait (300ms) for the user to
stop typing.
* feat: add full_name field to ThunderstoreModVersion type
* feat: mods can be installed by clicking the card button
* feat: card displays a loader while mod is being installed
* refactor: move installed mods list to frontend store
Installed mods are now stored in the frontend store, so they can be used in
all views.
* feat: card button text varies regarding current mod's status
Button can now tell if current mod is being installed, or if it is already installed
on local file system.
* refactor: export mod status computation in a dedicated method
* feat: color buttons regarding associated mod's state
* fix: set search debounce timeout to 200ms
* refactor: local mods load is done by frontend store
* fix: load locally-installed mods before fetching thunderstore mods
* fix: display mods while typing in search bar
* fix: type issue
* fix: CI doesn't know NodeJS namespace
* fix: adjust NorthstarMod member types (string instead of String)
* feat: tell if a mod is outdated by checking its Thunderstore dependency string prefix
* fix: update mods list after installing one from Thunderstore
This way, after installing a mod, button text will display "Installed" instead
of "Install".
* refactor: export Thunderstore mod card to dedicated component file
* refactor: rename computed variables
* fix: use computed latestVersion member
* feat: display "updating" on button when updating an already-installed mod
* feat: add some background blur on thunderstore mods view
* Update src-vue/src/views/ThunderstoreModsView.vue
* Update src-vue/src/plugins/store.ts
* fix: zoom background container a bit to hide white border on Windows
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> |